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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-9
@@ -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,
|
||||||
|
|||||||
+107
-184
@@ -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{},
|
|
||||||
Count: 0,
|
|
||||||
WispDir: wispDir,
|
|
||||||
WispDirError: err.Error(),
|
|
||||||
})
|
})
|
||||||
|
if rpcErr != nil {
|
||||||
|
err = rpcErr
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
defer func() { _ = wispStore.Close() }()
|
} else {
|
||||||
|
// Direct database access
|
||||||
// List all issues from wisp storage
|
filter := types.IssueFilter{
|
||||||
issues, err := listWispIssues(ctx, wispStore, showAll)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,9 +380,7 @@ 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user