Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions cmd/bd/cli_fast_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1324,3 +1324,175 @@ func TestCLI_CreateRejectsFlagLikeTitles(t *testing.T) {
}
})
}

// TestCLI_CreateNoHistory tests that the --no-history CLI flag is wired through
// to the created issue (GH#2619). A storage-layer test already covers the DB
// semantics; this test verifies the CLI flag is actually parsed and passed.
func TestCLI_CreateNoHistory(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}

t.Run("NoHistoryFlagSetOnCreatedIssue", func(t *testing.T) {
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "No-history agent bead", "-p", "2", "--no-history", "--json")

var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out)
}
if result["no_history"] != true {
t.Errorf("Expected no_history=true on created issue, got: %v", result["no_history"])
}
// Must NOT be ephemeral (mutually exclusive)
if result["ephemeral"] == true {
t.Errorf("no-history issue must not be ephemeral, but ephemeral=true")
}
})

t.Run("NoHistoryPersistedAfterShow", func(t *testing.T) {
tmpDir := setupCLITestDB(t)
out := runBDInProcess(t, tmpDir, "create", "No-history bead for show test", "-p", "2", "--no-history", "--json")

var created map[string]interface{}
if err := json.Unmarshal([]byte(out), &created); err != nil {
t.Fatalf("Failed to parse create output: %v\nOutput: %s", err, out)
}
id := created["id"].(string)

showOut := runBDInProcess(t, tmpDir, "show", id, "--json")
var issues []map[string]interface{}
if err := json.Unmarshal([]byte(showOut), &issues); err != nil {
t.Fatalf("Failed to parse show output: %v\nOutput: %s", err, showOut)
}
if len(issues) == 0 {
t.Fatalf("show returned no issues for id %s", id)
}
if issues[0]["no_history"] != true {
t.Errorf("Expected no_history=true after show, got: %v", issues[0]["no_history"])
}
})

t.Run("EphemeralAndNoHistoryMutuallyExclusive", func(t *testing.T) {
tmpDir := setupCLITestDB(t)
_, stderr, err := runBDInProcessAllowError(t, tmpDir, "create", "Should fail", "--ephemeral", "--no-history")
if err == nil {
t.Error("Expected error when combining --ephemeral and --no-history, got none")
}
if !strings.Contains(stderr, "mutually exclusive") {
t.Errorf("Expected 'mutually exclusive' in stderr, got: %s", stderr)
}
})
}

// TestCLI_WispListTypeFilter tests that bd mol wisp list --type filters correctly.
func TestCLI_WispListTypeFilter(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}

tmpDir := setupCLITestDB(t)

// Create two ephemeral wisps of different built-in types
runBDInProcess(t, tmpDir, "create", "Bug wisp", "--ephemeral", "--type", "bug", "-p", "2")
runBDInProcess(t, tmpDir, "create", "Task wisp", "--ephemeral", "--type", "task", "-p", "2")

// --type bug should return only the bug wisp
out := runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--type", "bug", "--json")
var result map[string]interface{}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out)
}
wisps, ok := result["wisps"].([]interface{})
if !ok {
t.Fatalf("Expected 'wisps' array in JSON output, got: %v", result)
}
if len(wisps) != 1 {
t.Errorf("Expected 1 wisp with type=bug, got %d: %v", len(wisps), wisps)
}
if len(wisps) == 1 {
w := wisps[0].(map[string]interface{})
if w["type"] != "bug" {
t.Errorf("Expected wisp type=bug, got: %v", w["type"])
}
}

// --type task should return only the task wisp
out = runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--type", "task", "--json")
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out)
}
wisps, ok = result["wisps"].([]interface{})
if !ok {
t.Fatalf("Expected 'wisps' array, got: %v", result)
}
if len(wisps) != 1 {
t.Errorf("Expected 1 wisp with type=task, got %d", len(wisps))
}
if len(wisps) == 1 {
w := wisps[0].(map[string]interface{})
if w["type"] != "task" {
t.Errorf("Expected wisp type=task, got: %v", w["type"])
}
}

// No --type filter returns both
out = runBDInProcess(t, tmpDir, "mol", "wisp", "list", "--json")
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, out)
}
wisps = result["wisps"].([]interface{})
if len(wisps) != 2 {
t.Errorf("Expected 2 wisps without type filter, got %d", len(wisps))
}
}

// TestCLI_WispGCExcludeType tests that bd mol wisp gc --exclude-type skips
// wisps of the excluded type during garbage collection.
func TestCLI_WispGCExcludeType(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}

tmpDir := setupCLITestDB(t)

// Create two ephemeral wisps of different built-in types.
// Use --age=0s so that ALL wisps (regardless of actual age) are treated
// as abandoned GC candidates.
bugOut := runBDInProcess(t, tmpDir, "create", "Bug wisp to keep", "--ephemeral", "--type", "bug", "-p", "2", "--json")
taskOut := runBDInProcess(t, tmpDir, "create", "Task wisp to gc", "--ephemeral", "--type", "task", "-p", "2", "--json")

var bugCreated, taskCreated map[string]interface{}
if err := json.Unmarshal([]byte(bugOut), &bugCreated); err != nil {
t.Fatalf("Failed to parse bug create output: %v\nOutput: %s", err, bugOut)
}
if err := json.Unmarshal([]byte(taskOut), &taskCreated); err != nil {
t.Fatalf("Failed to parse task create output: %v\nOutput: %s", err, taskOut)
}
bugID := bugCreated["id"].(string)
taskID := taskCreated["id"].(string)

// Dry-run GC with --exclude-type bug, --age=0s (treats all wisps as abandoned).
// The WispGCResult dry-run JSON uses cleaned_ids to list what would be deleted.
gcOut := runBDInProcess(t, tmpDir, "mol", "wisp", "gc", "--exclude-type", "bug", "--age", "0s", "--dry-run", "--json")
var gcResult map[string]interface{}
if err := json.Unmarshal([]byte(gcOut), &gcResult); err != nil {
t.Fatalf("Failed to parse gc JSON: %v\nOutput: %s", err, gcOut)
}

// cleaned_ids holds the IDs that would be deleted (dry_run=true).
cleanedRaw, _ := gcResult["cleaned_ids"].([]interface{})
cleanedIDs := make(map[string]bool, len(cleanedRaw))
for _, v := range cleanedRaw {
if id, ok := v.(string); ok {
cleanedIDs[id] = true
}
}

if !cleanedIDs[taskID] {
t.Errorf("Expected task wisp %s in GC candidates (not excluded), got cleaned_ids: %v", taskID, cleanedRaw)
}
if cleanedIDs[bugID] {
t.Errorf("Bug wisp %s should be excluded from GC via --exclude-type bug, but appeared in cleaned_ids", bugID)
}
}
14 changes: 11 additions & 3 deletions cmd/bd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ var createCmd = &cobra.Command{
rigOverride, _ := cmd.Flags().GetString("rig")
prefixOverride, _ := cmd.Flags().GetString("prefix")
wisp, _ := cmd.Flags().GetBool("ephemeral")
noHistory, _ := cmd.Flags().GetBool("no-history")
if wisp && noHistory {
FatalError("--ephemeral and --no-history are mutually exclusive")
}
molTypeStr, _ := cmd.Flags().GetString("mol-type")
var molType types.MolType
if molTypeStr != "" {
Expand Down Expand Up @@ -241,6 +245,7 @@ var createCmd = &cobra.Command{
Assignee: assignee,
ExternalRef: externalRefPtr,
Ephemeral: wisp,
NoHistory: noHistory,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
MolType: molType,
Expand Down Expand Up @@ -315,7 +320,7 @@ var createCmd = &cobra.Command{
// Found a matching route - auto-route to that rig
rigName := routing.ExtractProjectFromPath(route.Path)
if rigName != "" {
createInRig(cmd, rigName, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp)
createInRig(cmd, rigName, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp, noHistory)
return
}
}
Expand All @@ -335,7 +340,7 @@ var createCmd = &cobra.Command{
targetRig = prefixOverride
}
if targetRig != "" {
createInRig(cmd, targetRig, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp)
createInRig(cmd, targetRig, explicitID, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, specID, wisp, noHistory)
return
}

Expand Down Expand Up @@ -526,6 +531,7 @@ var createCmd = &cobra.Command{
ExternalRef: externalRefPtr,
EstimatedMinutes: estimatedMinutes,
Ephemeral: wisp,
NoHistory: noHistory,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
MolType: molType,
Expand Down Expand Up @@ -808,6 +814,7 @@ func init() {
createCmd.Flags().String("prefix", "", "Create issue in rig by prefix (e.g., --prefix bd- or --prefix bd or --prefix beads)")
createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (short-lived, subject to TTL compaction)")
createCmd.Flags().Bool("no-history", false, "Skip Dolt commit history without making GC-eligible (for permanent agent beads)")
createCmd.Flags().String("mol-type", "", "Molecule type: swarm (multi-polecat), patrol (recurring ops), work (default)")
createCmd.Flags().String("wisp-type", "", "Wisp type for TTL-based compaction: heartbeat, ping, patrol, gc_report, recovery, error, escalation")
createCmd.Flags().Bool("validate", false, "Validate description contains required sections for issue type")
Expand Down Expand Up @@ -835,7 +842,7 @@ func init() {

// createInRig creates an issue in a different rig using --rig flag or auto-routing.
// This directly creates in the target rig's database.
func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef, specID string, wisp bool) {
func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef, specID string, wisp, noHistory bool) {
ctx := rootCtx

// Find the town-level beads directory (where routes.jsonl lives)
Expand Down Expand Up @@ -952,6 +959,7 @@ func createInRig(cmd *cobra.Command, rigName, explicitID, title, description, is
Assignee: assignee,
ExternalRef: externalRefPtr,
Ephemeral: wisp,
NoHistory: noHistory,
CreatedBy: getActorWithGit(),
Owner: getOwner(),
// Event fields (bd-xwvo fix)
Expand Down
134 changes: 134 additions & 0 deletions cmd/bd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"testing"

"github.com/steveyegge/beads/internal/testutil"
"github.com/steveyegge/beads/internal/types"
)

func TestExportToFile(t *testing.T) {
Expand Down Expand Up @@ -401,3 +402,136 @@ func TestFilterOutPollution(t *testing.T) {
}
}
}

func TestExportNoHistoryBeadRoundTrip(t *testing.T) {
// GH#2619: NoHistory beads are stored in the wisps table. The JSONL export
// must include them with no_history=true, and import must preserve the flag.
// If no_history is dropped during import, the bead becomes GC-eligible.
if testDoltServerPort == 0 {
t.Skip("Dolt test server not available")
}
if testutil.DoltContainerCrashed() {
t.Skipf("Dolt test server crashed: %v", testutil.DoltContainerCrashError())
}

ensureTestMode(t)
saved := saveAndRestoreGlobals(t)
_ = saved

tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}

origWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(origWd) })

dbName := uniqueTestDBName(t)
testDBPath := filepath.Join(beadsDir, "dolt")
writeTestMetadata(t, testDBPath, dbName)
s := newTestStore(t, testDBPath)
store = s
storeMutex.Lock()
storeActive = true
storeMutex.Unlock()
t.Cleanup(func() {
store = nil
storeMutex.Lock()
storeActive = false
storeMutex.Unlock()
})

ctx := context.Background()
rootCtx = ctx

// Create a NoHistory bead using the store API (routes to wisps table with no_history=1).
noHistoryBead := &types.Issue{
Title: "NoHistory export test bead",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
NoHistory: true,
}
if err := s.CreateIssue(ctx, noHistoryBead, "test"); err != nil {
t.Fatalf("CreateIssue (NoHistory): %v", err)
}

// Also create a regular issue to ensure the export contains both.
if _, err := s.DB().ExecContext(ctx, `INSERT INTO issues (id, title, description, design, acceptance_criteria, notes, status, priority, issue_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"nohistory-regular-1", "Regular issue", "", "", "", "", "open", 1, "task"); err != nil {
t.Fatalf("insert regular issue: %v", err)
}

// Export to file.
exportFile := filepath.Join(tmpDir, "nohistory_export.jsonl")
exportOutput = exportFile
exportAll = true // include everything
exportIncludeInfra = false
exportScrub = false
t.Cleanup(func() {
exportOutput = ""
exportAll = false
})

if err := runExport(nil, nil); err != nil {
t.Fatalf("runExport: %v", err)
}

data, err := os.ReadFile(exportFile)
if err != nil {
t.Fatalf("read export file: %v", err)
}

// Verify the NoHistory bead appears in the export with no_history=true.
lines := splitJSONL(data)
if len(lines) < 2 {
t.Fatalf("expected at least 2 lines in export (regular + NoHistory), got %d", len(lines))
}

var noHistoryLine map[string]interface{}
for _, line := range lines {
var rec map[string]interface{}
if err := json.Unmarshal(line, &rec); err != nil {
t.Fatalf("parse exported JSONL: %v", err)
}
if rec["title"] == "NoHistory export test bead" {
noHistoryLine = rec
break
}
}
if noHistoryLine == nil {
t.Fatal("NoHistory bead not found in exported JSONL — export missed wisps with no_history=true")
}
if noHistoryLine["no_history"] != true {
t.Errorf("exported NoHistory bead has no_history=%v, want true", noHistoryLine["no_history"])
}

// Import the exported JSONL into a fresh store and verify no_history survives.
tmpDir2 := t.TempDir()
dbPath2 := filepath.Join(tmpDir2, "dolt")
store2 := newTestStore(t, dbPath2)

count, err := importFromLocalJSONL(ctx, store2, exportFile)
if err != nil {
t.Fatalf("importFromLocalJSONL: %v", err)
}
if count < 2 {
t.Errorf("expected at least 2 issues imported, got %d", count)
}

// Retrieve the NoHistory bead from the new store and check the flag.
imported, err := store2.GetIssue(ctx, noHistoryBead.ID)
if err != nil {
t.Fatalf("GetIssue(%s) after import: %v", noHistoryBead.ID, err)
}
if !imported.NoHistory {
t.Error("no_history=true was lost during export→import roundtrip: bead is now GC-eligible")
}
if imported.Ephemeral {
t.Error("NoHistory bead must not become ephemeral=true after roundtrip")
}
}
Loading