wip: simplify wisp architecture - single db with Wisp flag (bd-bkul)

Progress on eliminating separate .beads-wisp/ directory:

- Add --wisp flag to bd create (creates issue with Wisp=true)
- Update bd wisp create to use main db instead of separate wisp storage
- Update bd wisp list to query main db with Wisp filter
- Update bd wisp gc to work with main database
- Add Wisp field to RPC ListArgs for daemon mode support
- Fix terminology: use "old/abandoned" for time-based cleanup,
  reserve "stale" for graph-pressure staleness (per Gas Town taxonomy)

Still TODO:
- Finish mol squash simplification (remove cross-store logic)
- Remove runWispSquash and squashWispToPermanent functions
- Update mol burn similarly
- Deprecate .beads-wisp/ functions in internal/beads/beads.go
- Test all changes

🤖 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-24 20:31:27 -08:00
parent 5f16d10634
commit c0271aedbf
5 changed files with 135 additions and 201 deletions

View File

@@ -107,6 +107,7 @@ var createCmd = &cobra.Command{
waitsForGate, _ := cmd.Flags().GetString("waits-for-gate") waitsForGate, _ := cmd.Flags().GetString("waits-for-gate")
forceCreate, _ := cmd.Flags().GetBool("force") forceCreate, _ := cmd.Flags().GetBool("force")
repoOverride, _ := cmd.Flags().GetString("repo") repoOverride, _ := cmd.Flags().GetString("repo")
wisp, _ := cmd.Flags().GetBool("wisp")
// Get estimate if provided // Get estimate if provided
var estimatedMinutes *int var estimatedMinutes *int
@@ -221,6 +222,7 @@ var createCmd = &cobra.Command{
Dependencies: deps, Dependencies: deps,
WaitsFor: waitsFor, WaitsFor: waitsFor,
WaitsForGate: waitsForGate, WaitsForGate: waitsForGate,
Wisp: wisp,
} }
resp, err := daemonClient.Create(createArgs) resp, err := daemonClient.Create(createArgs)
@@ -265,6 +267,7 @@ var createCmd = &cobra.Command{
Assignee: assignee, Assignee: assignee,
ExternalRef: externalRefPtr, ExternalRef: externalRefPtr,
EstimatedMinutes: estimatedMinutes, EstimatedMinutes: estimatedMinutes,
Wisp: wisp,
} }
ctx := rootCtx ctx := rootCtx
@@ -443,6 +446,7 @@ func init() {
createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix")
createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)") createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)")
createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)") createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
createCmd.Flags().Bool("wisp", false, "Create as wisp (ephemeral, not exported to JSONL)")
// Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(createCmd) rootCmd.AddCommand(createCmd)
} }

View File

@@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
@@ -21,15 +20,17 @@ var molSquashCmd = &cobra.Command{
Short: "Compress molecule execution into a digest", Short: "Compress molecule execution into a digest",
Long: `Squash a molecule's wisp children into a single digest issue. Long: `Squash a molecule's wisp children into a single digest issue.
This command collects all wisp child issues of a molecule, generates This command collects all wisp child issues of a molecule (Wisp=true),
a summary digest, and optionally deletes the wisps. generates a summary digest, and promotes the wisps to persistent by
clearing their Wisp flag (or optionally deletes them).
The squash operation: The squash operation:
1. Loads the molecule and all its children 1. Loads the molecule and all its children
2. Filters to only wisps (ephemeral vapor from the Steam Engine) 2. Filters to only wisps (ephemeral issues with Wisp=true)
3. Generates a digest (summary of work done) 3. Generates a digest (summary of work done)
4. Creates a permanent digest issue 4. Creates a permanent digest issue (Wisp=false)
5. Deletes the wisps (unless --keep-children) 5. Clears Wisp flag on children (promotes to persistent)
OR deletes them with --delete-children
AGENT INTEGRATION: AGENT INTEGRATION:
Use --summary to provide an AI-generated summary. This keeps bd as a pure Use --summary to provide an AI-generated summary. This keeps bd as a pure
@@ -38,12 +39,12 @@ for generating intelligent summaries. Without --summary, a basic concatenation
of child issue content is used. of child issue content is used.
This is part of the wisp workflow: spawn creates wisps, This is part of the wisp workflow: spawn creates wisps,
execution happens, squash compresses the trace into an outcome. execution happens, squash compresses the trace into an outcome (digest).
Example: Example:
bd mol squash bd-abc123 # Squash with auto-generated digest bd mol squash bd-abc123 # Squash and promote children
bd mol squash bd-abc123 --dry-run # Preview what would be squashed bd mol squash bd-abc123 --dry-run # Preview what would be squashed
bd mol squash bd-abc123 --keep-children # Create digest but keep children bd mol squash bd-abc123 --delete-children # Delete wisps after digest
bd mol squash bd-abc123 --summary "Agent-generated summary of work done"`, bd mol squash bd-abc123 --summary "Agent-generated summary of work done"`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: runMolSquash, Run: runMolSquash,

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"slices" "slices"
@@ -9,8 +10,7 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
@@ -18,9 +18,9 @@ import (
// Wisp commands - manage ephemeral molecules // Wisp commands - manage ephemeral molecules
// //
// Wisps are ephemeral molecules stored in .beads-wisp/ (gitignored). // Wisps are ephemeral issues with Wisp=true in the main database.
// They're used for patrol cycles and operational loops that shouldn't // They're used for patrol cycles and operational loops that shouldn't
// accumulate in the permanent database. // be exported to JSONL (and thus not synced via git).
// //
// Commands: // Commands:
// bd wisp list - List all wisps in current context // bd wisp list - List all wisps in current context
@@ -31,15 +31,16 @@ var wispCmd = &cobra.Command{
Short: "Manage ephemeral molecules (wisps)", Short: "Manage ephemeral molecules (wisps)",
Long: `Manage wisps - ephemeral molecules for operational workflows. Long: `Manage wisps - ephemeral molecules for operational workflows.
Wisps are ephemeral molecules stored in .beads-wisp/ (gitignored). Wisps are issues with Wisp=true in the main database. They're stored
locally but NOT exported to JSONL (and thus not synced via git).
They're used for patrol cycles, operational loops, and other workflows They're used for patrol cycles, operational loops, and other workflows
that shouldn't accumulate in the permanent database. that shouldn't accumulate in the shared issue database.
The wisp lifecycle: The wisp lifecycle:
1. Create: bd mol bond --wisp ... (creates in .beads-wisp/) 1. Create: bd wisp create <proto> or bd create --wisp
2. Execute: Normal bd operations work on wisps 2. Execute: Normal bd operations work on wisps
3. Squash: bd mol squash <id> (creates permanent digest, deletes wisp) 3. Squash: bd mol squash <id> (clears Wisp flag, promotes to persistent)
4. Or burn: bd mol burn <id> (deletes wisp with no digest) 4. Or burn: bd mol burn <id> (deletes wisp without creating digest)
Commands: Commands:
list List all wisps in current context list List all wisps in current context
@@ -54,22 +55,18 @@ type WispListItem struct {
Priority int `json:"priority"` Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Orphaned bool `json:"orphaned,omitempty"` Old bool `json:"old,omitempty"` // Not updated in 24+ hours
Stale bool `json:"stale,omitempty"`
} }
// WispListResult is the JSON output for wisp list // WispListResult is the JSON output for wisp list
type WispListResult struct { type WispListResult struct {
Wisps []WispListItem `json:"wisps"` Wisps []WispListItem `json:"wisps"`
Count int `json:"count"` Count int `json:"count"`
OrphanCount int `json:"orphan_count,omitempty"` OldCount int `json:"old_count,omitempty"`
StaleCount int `json:"stale_count,omitempty"`
WispDir string `json:"wisp_dir"`
WispDirError string `json:"wisp_dir_error,omitempty"`
} }
// StaleThreshold is how old a wisp must be to be considered stale // OldThreshold is how old a wisp must be to be flagged as old (time-based, for ephemeral cleanup)
const StaleThreshold = 24 * time.Hour const OldThreshold = 24 * time.Hour
// wispCreateCmd instantiates a proto as an ephemeral wisp // wispCreateCmd instantiates a proto as an ephemeral wisp
var wispCreateCmd = &cobra.Command{ var wispCreateCmd = &cobra.Command{
@@ -200,7 +197,7 @@ func runWispCreate(cmd *cobra.Command, args []string) {
if dryRun { if dryRun {
fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID) fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID)
fmt.Printf("Storage: wisp (.beads-wisp/)\n\n") fmt.Printf("Storage: main database (wisp=true, not exported to JSONL)\n\n")
for _, issue := range subgraph.Issues { for _, issue := range subgraph.Issues {
newTitle := substituteVariables(issue.Title, vars) newTitle := substituteVariables(issue.Title, vars)
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID) fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
@@ -208,27 +205,14 @@ func runWispCreate(cmd *cobra.Command, args []string) {
return return
} }
// Open wisp storage // Spawn as wisp in main database (ephemeral=true sets Wisp flag, skips JSONL export)
wispStore, err := beads.NewWispStorage(ctx) result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open wisp storage: %v\n", err)
os.Exit(1)
}
defer func() { _ = wispStore.Close() }()
// Ensure wisp directory is gitignored
if err := beads.EnsureWispGitignore(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not update .gitignore: %v\n", err)
}
// Spawn as wisp (ephemeral=true)
result, err := spawnMolecule(ctx, wispStore, subgraph, vars, "", actor, true)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err) fmt.Fprintf(os.Stderr, "Error creating wisp: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Don't schedule flush - wisps are not synced // Wisps are in main db but don't trigger JSONL export (Wisp flag excludes them)
if jsonOutput { if jsonOutput {
type wispCreateResult struct { type wispCreateResult struct {
@@ -241,11 +225,11 @@ func runWispCreate(cmd *cobra.Command, args []string) {
fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created) fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created)
fmt.Printf(" Root issue: %s\n", result.NewEpicID) fmt.Printf(" Root issue: %s\n", result.NewEpicID)
fmt.Printf(" Phase: vapor (ephemeral in .beads-wisp/)\n") fmt.Printf(" Phase: vapor (ephemeral, not exported to JSONL)\n")
fmt.Printf("\nNext steps:\n") fmt.Printf("\nNext steps:\n")
fmt.Printf(" bd close %s.<step> # Complete steps\n", result.NewEpicID) fmt.Printf(" bd close %s.<step> # Complete steps\n", result.NewEpicID)
fmt.Printf(" bd mol squash %s # Condense to digest\n", result.NewEpicID) fmt.Printf(" bd mol squash %s # Condense to digest (promotes to persistent)\n", result.NewEpicID)
fmt.Printf(" bd mol burn %s # Discard without digest\n", result.NewEpicID) fmt.Printf(" bd mol burn %s # Discard without creating digest\n", result.NewEpicID)
} }
// isProtoIssue checks if an issue is a proto (has the template label) // isProtoIssue checks if an issue is a proto (has the template label)
@@ -285,8 +269,8 @@ var wispListCmd = &cobra.Command{
Short: "List all wisps in current context", Short: "List all wisps in current context",
Long: `List all ephemeral molecules (wisps) in the current context. Long: `List all ephemeral molecules (wisps) in the current context.
Wisps are stored in .beads-wisp/ alongside .beads/. They are gitignored Wisps are issues with Wisp=true in the main database. They are stored
and will be garbage collected over time. locally but not exported to JSONL (and thus not synced via git).
The list shows: The list shows:
- ID: Issue ID of the wisp - ID: Issue ID of the wisp
@@ -295,10 +279,9 @@ The list shows:
- Started: When the wisp was created - Started: When the wisp was created
- Updated: Last modification time - Updated: Last modification time
Orphan detection: Old wisp detection:
- Orphaned wisps have no root molecule (parent was deleted) - Old wisps haven't been updated in 24+ hours
- Stale wisps haven't been updated in 24+ hours - Use 'bd wisp gc' to clean up old/abandoned wisps
- Use 'bd wisp gc' to clean up orphaned/stale wisps
Examples: Examples:
bd wisp list # List all wisps bd wisp list # List all wisps
@@ -312,64 +295,63 @@ func runWispList(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all") showAll, _ := cmd.Flags().GetBool("all")
// Check wisp directory exists // Check for database connection
wispDir := beads.FindWispDir() if store == nil && daemonClient == nil {
if wispDir == "" {
if jsonOutput { if jsonOutput {
outputJSON(WispListResult{ outputJSON(WispListResult{
Wisps: []WispListItem{}, Wisps: []WispListItem{},
Count: 0, Count: 0,
WispDirError: "no .beads directory found",
}) })
} else { } else {
fmt.Println("No wisp storage found (no .beads directory)") fmt.Println("No database connection")
} }
return return
} }
// Check if wisp directory exists // Query wisps from main database using Wisp filter
if _, err := os.Stat(wispDir); os.IsNotExist(err) { wispFlag := true
if jsonOutput { var issues []*types.Issue
outputJSON(WispListResult{ var err error
Wisps: []WispListItem{},
Count: 0,
WispDir: wispDir,
})
} else {
fmt.Println("No wisps found (wisp directory does not exist)")
}
return
}
// Open wisp storage if daemonClient != nil {
wispStore, err := beads.NewWispStorage(ctx) // Use daemon RPC
if err != nil { resp, rpcErr := daemonClient.List(&rpc.ListArgs{
if jsonOutput { Wisp: &wispFlag,
outputJSON(WispListResult{ })
Wisps: []WispListItem{}, if rpcErr != nil {
Count: 0, err = rpcErr
WispDir: wispDir,
WispDirError: err.Error(),
})
} else { } else {
fmt.Fprintf(os.Stderr, "Error opening wisp storage: %v\n", err) if jsonErr := json.Unmarshal(resp.Data, &issues); jsonErr != nil {
err = jsonErr
}
} }
return } else {
// Direct database access
filter := types.IssueFilter{
Wisp: &wispFlag,
}
issues, err = store.SearchIssues(ctx, "", filter)
} }
defer func() { _ = wispStore.Close() }()
// List all issues from wisp storage
issues, err := listWispIssues(ctx, wispStore, showAll)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err) fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Convert to list items and detect orphans/stale // Filter closed issues unless --all is specified
if !showAll {
var filtered []*types.Issue
for _, issue := range issues {
if issue.Status != types.StatusClosed {
filtered = append(filtered, issue)
}
}
issues = filtered
}
// Convert to list items and detect old wisps
now := time.Now() now := time.Now()
items := make([]WispListItem, 0, len(issues)) items := make([]WispListItem, 0, len(issues))
orphanCount := 0 oldCount := 0
staleCount := 0
for _, issue := range issues { for _, issue := range issues {
item := WispListItem{ item := WispListItem{
@@ -381,16 +363,12 @@ func runWispList(cmd *cobra.Command, args []string) {
UpdatedAt: issue.UpdatedAt, UpdatedAt: issue.UpdatedAt,
} }
// Check if stale (not updated in 24+ hours) // Check if old (not updated in 24+ hours)
if now.Sub(issue.UpdatedAt) > StaleThreshold { if now.Sub(issue.UpdatedAt) > OldThreshold {
item.Stale = true item.Old = true
staleCount++ oldCount++
} }
// Orphan detection would require checking if parent exists
// For now, we consider root wisps without children as potential orphans
// This is a heuristic - true orphan detection requires dependency analysis
items = append(items, item) items = append(items, item)
} }
@@ -400,11 +378,9 @@ func runWispList(cmd *cobra.Command, args []string) {
}) })
result := WispListResult{ result := WispListResult{
Wisps: items, Wisps: items,
Count: len(items), Count: len(items),
OrphanCount: orphanCount, OldCount: oldCount,
StaleCount: staleCount,
WispDir: wispDir,
} }
if jsonOutput { if jsonOutput {
@@ -437,7 +413,7 @@ func runWispList(cmd *cobra.Command, args []string) {
// Format updated time // Format updated time
updated := formatTimeAgo(item.UpdatedAt) updated := formatTimeAgo(item.UpdatedAt)
if item.Stale { if item.Old {
updated = ui.RenderWarn(updated + " ⚠") updated = ui.RenderWarn(updated + " ⚠")
} }
@@ -446,42 +422,13 @@ func runWispList(cmd *cobra.Command, args []string) {
} }
// Print warnings // Print warnings
if staleCount > 0 { if oldCount > 0 {
fmt.Printf("\n%s %d stale wisp(s) (not updated in 24+ hours)\n", fmt.Printf("\n%s %d old wisp(s) (not updated in 24+ hours)\n",
ui.RenderWarn("⚠"), staleCount) ui.RenderWarn("⚠"), oldCount)
fmt.Println(" Hint: Use 'bd wisp gc' to clean up stale wisps") fmt.Println(" Hint: Use 'bd wisp gc' to clean up old wisps")
} }
} }
// listWispIssues retrieves issues from wisp storage
func listWispIssues(ctx context.Context, s storage.Storage, includeAll bool) ([]*types.Issue, error) {
// Build filter - by default, exclude closed issues
filter := types.IssueFilter{}
if !includeAll {
// When not showing all, we need to get everything and filter in Go
// since the filter only supports single status
}
// Get all issues from wisp storage
issues, err := s.SearchIssues(ctx, "", filter)
if err != nil {
return nil, err
}
// If not showing all, filter out closed issues
if !includeAll {
var filtered []*types.Issue
for _, issue := range issues {
if issue.Status != types.StatusClosed {
filtered = append(filtered, issue)
}
}
return filtered, nil
}
return issues, nil
}
// formatTimeAgo returns a human-readable relative time // formatTimeAgo returns a human-readable relative time
func formatTimeAgo(t time.Time) string { func formatTimeAgo(t time.Time) string {
d := time.Since(t) d := time.Since(t)
@@ -514,18 +461,20 @@ func formatTimeAgo(t time.Time) string {
var wispGCCmd = &cobra.Command{ var wispGCCmd = &cobra.Command{
Use: "gc", Use: "gc",
Short: "Garbage collect orphaned wisps", Short: "Garbage collect old/abandoned wisps",
Long: `Garbage collect orphaned wisps from wisp storage. Long: `Garbage collect old or abandoned wisps from the database.
A wisp is considered orphaned if: A wisp is considered abandoned if:
- It has a process_id and that process is no longer running
- It hasn't been updated in --age duration and is not closed - It hasn't been updated in --age duration and is not closed
Orphaned wisps are deleted without creating a digest. Use 'bd mol squash' Abandoned wisps are deleted without creating a digest. Use 'bd mol squash'
if you want to preserve a summary before garbage collection. if you want to preserve a summary before garbage collection.
Note: This uses time-based cleanup, appropriate for ephemeral wisps.
For graph-pressure staleness detection (blocking other work), see 'bd mol stale'.
Examples: Examples:
bd wisp gc # Clean orphans (default: 1h threshold) bd wisp gc # Clean abandoned wisps (default: 1h threshold)
bd wisp gc --dry-run # Preview what would be cleaned bd wisp gc --dry-run # Preview what would be cleaned
bd wisp gc --age 24h # Custom age threshold bd wisp gc --age 24h # Custom age threshold
bd wisp gc --all # Also clean closed wisps older than threshold`, bd wisp gc --all # Also clean closed wisps older than threshold`,
@@ -538,7 +487,6 @@ type WispGCResult struct {
CleanedCount int `json:"cleaned_count"` CleanedCount int `json:"cleaned_count"`
Candidates int `json:"candidates,omitempty"` Candidates int `json:"candidates,omitempty"`
DryRun bool `json:"dry_run,omitempty"` DryRun bool `json:"dry_run,omitempty"`
WispDir string `json:"wisp_dir"`
} }
func runWispGC(cmd *cobra.Command, args []string) { func runWispGC(cmd *cobra.Command, args []string) {
@@ -561,95 +509,71 @@ func runWispGC(cmd *cobra.Command, args []string) {
} }
} }
// Find wisp storage // Wisp gc requires direct store access for deletion
wispDir := beads.FindWispDir() if store == nil {
if wispDir == "" { if daemonClient != nil {
if jsonOutput { fmt.Fprintf(os.Stderr, "Error: wisp gc requires direct database access\n")
outputJSON(WispGCResult{ fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon wisp gc\n")
CleanedIDs: []string{},
CleanedCount: 0,
})
} else { } else {
fmt.Println("No wisp storage found") fmt.Fprintf(os.Stderr, "Error: no database connection\n")
} }
return
}
// Check if wisp directory exists
if _, err := os.Stat(wispDir); os.IsNotExist(err) {
if jsonOutput {
outputJSON(WispGCResult{
CleanedIDs: []string{},
CleanedCount: 0,
WispDir: wispDir,
})
} else {
fmt.Println("No wisps to clean (wisp directory does not exist)")
}
return
}
// Open wisp storage
wispStore, err := beads.NewWispStorage(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening wisp storage: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer func() { _ = wispStore.Close() }()
// Get all issues from wisp storage // Query wisps from main database using Wisp filter
filter := types.IssueFilter{} wispFlag := true
issues, err := wispStore.SearchIssues(ctx, "", filter) filter := types.IssueFilter{
Wisp: &wispFlag,
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err) fmt.Fprintf(os.Stderr, "Error listing wisps: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Find orphans // Find old/abandoned wisps
now := time.Now() now := time.Now()
var orphans []*types.Issue var abandoned []*types.Issue
for _, issue := range issues { for _, issue := range issues {
// Skip closed issues unless --all is specified // Skip closed issues unless --all is specified
if issue.Status == types.StatusClosed && !cleanAll { if issue.Status == types.StatusClosed && !cleanAll {
continue continue
} }
// Check if stale (not updated within age threshold) // Check if old (not updated within age threshold)
if now.Sub(issue.UpdatedAt) > ageThreshold { if now.Sub(issue.UpdatedAt) > ageThreshold {
orphans = append(orphans, issue) abandoned = append(abandoned, issue)
} }
} }
if len(orphans) == 0 { if len(abandoned) == 0 {
if jsonOutput { if jsonOutput {
outputJSON(WispGCResult{ outputJSON(WispGCResult{
CleanedIDs: []string{}, CleanedIDs: []string{},
CleanedCount: 0, CleanedCount: 0,
WispDir: wispDir,
DryRun: dryRun, DryRun: dryRun,
}) })
} else { } else {
fmt.Println("No orphaned wisps found") fmt.Println("No abandoned wisps found")
} }
return return
} }
if dryRun { if dryRun {
if jsonOutput { if jsonOutput {
ids := make([]string, len(orphans)) ids := make([]string, len(abandoned))
for i, o := range orphans { for i, o := range abandoned {
ids[i] = o.ID ids[i] = o.ID
} }
outputJSON(WispGCResult{ outputJSON(WispGCResult{
CleanedIDs: ids, CleanedIDs: ids,
Candidates: len(orphans), Candidates: len(abandoned),
CleanedCount: 0, CleanedCount: 0,
WispDir: wispDir,
DryRun: true, DryRun: true,
}) })
} else { } else {
fmt.Printf("Dry run: would clean %d orphaned wisp(s):\n\n", len(orphans)) fmt.Printf("Dry run: would clean %d abandoned wisp(s):\n\n", len(abandoned))
for _, issue := range orphans { for _, issue := range abandoned {
age := formatTimeAgo(issue.UpdatedAt) age := formatTimeAgo(issue.UpdatedAt)
fmt.Printf(" %s: %s (last updated: %s)\n", issue.ID, issue.Title, age) fmt.Printf(" %s: %s (last updated: %s)\n", issue.ID, issue.Title, age)
} }
@@ -658,15 +582,15 @@ func runWispGC(cmd *cobra.Command, args []string) {
return return
} }
// Delete orphans // Delete abandoned wisps
var cleanedIDs []string var cleanedIDs []string
sqliteStore, ok := wispStore.(*sqlite.SQLiteStorage) sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok { if !ok {
fmt.Fprintf(os.Stderr, "Error: wisp gc requires SQLite storage backend\n") fmt.Fprintf(os.Stderr, "Error: wisp gc requires SQLite storage backend\n")
os.Exit(1) os.Exit(1)
} }
for _, issue := range orphans { for _, issue := range abandoned {
if err := sqliteStore.DeleteIssue(ctx, issue.ID); err != nil { if err := sqliteStore.DeleteIssue(ctx, issue.ID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", issue.ID, err) fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", issue.ID, err)
continue continue
@@ -677,7 +601,6 @@ func runWispGC(cmd *cobra.Command, args []string) {
result := WispGCResult{ result := WispGCResult{
CleanedIDs: cleanedIDs, CleanedIDs: cleanedIDs,
CleanedCount: len(cleanedIDs), CleanedCount: len(cleanedIDs),
WispDir: wispDir,
} }
if jsonOutput { if jsonOutput {
@@ -685,7 +608,7 @@ func runWispGC(cmd *cobra.Command, args []string) {
return return
} }
fmt.Printf("%s Cleaned %d orphaned wisp(s)\n", ui.RenderPass("✓"), result.CleanedCount) fmt.Printf("%s Cleaned %d abandoned wisp(s)\n", ui.RenderPass("✓"), result.CleanedCount)
for _, id := range cleanedIDs { for _, id := range cleanedIDs {
fmt.Printf(" - %s\n", id) fmt.Printf(" - %s\n", id)
} }
@@ -699,7 +622,7 @@ func init() {
wispListCmd.Flags().Bool("all", false, "Include closed wisps") wispListCmd.Flags().Bool("all", false, "Include closed wisps")
wispGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned") wispGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned")
wispGCCmd.Flags().String("age", "1h", "Age threshold for orphan detection") wispGCCmd.Flags().String("age", "1h", "Age threshold for abandoned wisp detection")
wispGCCmd.Flags().Bool("all", false, "Also clean closed wisps older than threshold") wispGCCmd.Flags().Bool("all", false, "Also clean closed wisps older than threshold")
wispCmd.AddCommand(wispCreateCmd) wispCmd.AddCommand(wispCreateCmd)

View File

@@ -178,6 +178,9 @@ type ListArgs struct {
// Parent filtering (bd-yqhh) // Parent filtering (bd-yqhh)
ParentID string `json:"parent_id,omitempty"` ParentID string `json:"parent_id,omitempty"`
// Wisp filtering (bd-bkul)
Wisp *bool `json:"wisp,omitempty"`
} }
// CountArgs represents arguments for the count operation // CountArgs represents arguments for the count operation

View File

@@ -821,6 +821,9 @@ func (s *Server) handleList(req *Request) Response {
filter.ParentID = &listArgs.ParentID filter.ParentID = &listArgs.ParentID
} }
// Wisp filtering (bd-bkul)
filter.Wisp = listArgs.Wisp
// Guard against excessive ID lists to avoid SQLite parameter limits // Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000 const maxIDs = 1000
if len(filter.IDs) > maxIDs { if len(filter.IDs) > maxIDs {