Files
beads/cmd/bd/ephemeral.go
Steve Yegge bf2f200754 Move pour and ephemeral under bd mol subcommand (bd-2fs7)
For consistency, all molecule operations are now under bd mol:
- bd mol pour <proto>     - create persistent mol
- bd mol ephemeral <proto> - create ephemeral mol
- bd mol ephemeral list   - list ephemeral issues
- bd mol ephemeral gc     - garbage collect old ephemeral issues

This aligns with existing mol subcommands: bond, squash, burn, etc.
2025-12-26 23:41:20 -08:00

678 lines
20 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// Wisp commands - manage ephemeral molecules
//
// Ephemeral issues are ephemeral issues with Wisp=true in the main database.
// They're used for patrol cycles and operational loops that shouldn't
// be exported to JSONL (and thus not synced via git).
//
// Commands:
// bd ephemeral list - List all ephemeral issues in current context
// bd ephemeral gc - Garbage collect orphaned ephemeral issues
var ephemeralCmd = &cobra.Command{
Use: "ephemeral [proto-id]",
Short: "Create or manage ephemeral molecules",
Long: `Create or manage ephemeral issues - ephemeral molecules for operational workflows.
When called with a proto-id argument, creates an ephemeral mol from that proto.
When called with a subcommand (list, gc), manages existing ephemeral issues.
Ephemeral issues are issues with Ephemeral=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
that shouldn't accumulate in the shared issue database.
The ephemeral lifecycle:
1. Create: bd mol ephemeral <proto> or bd create --ephemeral
2. Execute: Normal bd operations work on ephemeral issues
3. Squash: bd mol squash <id> (clears Ephemeral flag, promotes to persistent)
4. Or burn: bd mol burn <id> (deletes without creating digest)
Examples:
bd mol ephemeral mol-patrol # Create ephemeral from proto
bd mol ephemeral list # List all ephemeral issues
bd mol ephemeral gc # Garbage collect old ephemeral issues
Subcommands:
list List all ephemeral issues in current context
gc Garbage collect orphaned ephemeral issues`,
Args: cobra.MaximumNArgs(1),
Run: runEphemeral,
}
// EphemeralListItem represents a wisp in list output
type EphemeralListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Old bool `json:"old,omitempty"` // Not updated in 24+ hours
}
// EphemeralListResult is the JSON output for wisp list
type EphemeralListResult struct {
Wisps []EphemeralListItem `json:"ephemeral_items"`
Count int `json:"count"`
OldCount int `json:"old_count,omitempty"`
}
// OldThreshold is how old a wisp must be to be flagged as old (time-based, for ephemeral cleanup)
const OldThreshold = 24 * time.Hour
// runEphemeral handles the ephemeral command when called directly with a proto-id
// It delegates to runEphemeralCreate for the actual work
func runEphemeral(cmd *cobra.Command, args []string) {
if len(args) == 0 {
// No proto-id provided, show help
cmd.Help()
return
}
// Delegate to the create logic
runEphemeralCreate(cmd, args)
}
// ephemeralCreateCmd instantiates a proto as an ephemeral wisp (kept for backwards compat)
var ephemeralCreateCmd = &cobra.Command{
Use: "create <proto-id>",
Short: "Instantiate a proto as an ephemeral wisp (solid -> vapor)",
Long: `Create a wisp from a proto - sublimation from solid to vapor.
This is the chemistry-inspired command for creating ephemeral work from templates.
The resulting wisp is stored in the main database with Wisp=true and NOT exported to JSONL.
Phase transition: Proto (solid) -> Wisp (vapor)
Use wisp create for:
- Patrol cycles (deacon, witness)
- Health checks and monitoring
- One-shot orchestration runs
- Routine operations with no audit value
The wisp will:
- Be stored in main database with Wisp=true flag
- NOT be exported to JSONL (and thus not synced via git)
- Either evaporate (burn) or condense to digest (squash)
Examples:
bd ephemeral create mol-patrol # Ephemeral patrol cycle
bd ephemeral create mol-health-check # One-time health check
bd ephemeral create mol-diagnostics --var target=db # Diagnostic run`,
Args: cobra.ExactArgs(1),
Run: runEphemeralCreate,
}
func runEphemeralCreate(cmd *cobra.Command, args []string) {
CheckReadonly("wisp create")
ctx := rootCtx
// Wisp create requires direct store access
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: wisp create requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon wisp create %s ...\n", args[0])
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
dryRun, _ := cmd.Flags().GetBool("dry-run")
varFlags, _ := cmd.Flags().GetStringSlice("var")
// Parse variables
vars := make(map[string]string)
for _, v := range varFlags {
parts := strings.SplitN(v, "=", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v)
os.Exit(1)
}
vars[parts[0]] = parts[1]
}
// Try to load as formula first (ephemeral proto - gt-4v1eo)
// If that fails, fall back to loading from DB (legacy proto beads)
var subgraph *TemplateSubgraph
var protoID string
// Try to cook formula inline (gt-4v1eo: ephemeral protos)
// This works for any valid formula name, not just "mol-" prefixed ones
sg, err := resolveAndCookFormula(args[0], nil)
if err == nil {
subgraph = sg
protoID = sg.Root.ID
}
if subgraph == nil {
// Resolve proto ID (legacy path)
protoID = args[0]
// Try to resolve partial ID if it doesn't look like a full ID
if !strings.HasPrefix(protoID, "bd-") && !strings.HasPrefix(protoID, "gt-") && !strings.HasPrefix(protoID, "mol-") {
// Might be a partial ID, try to resolve
if resolved, err := resolvePartialIDDirect(ctx, protoID); err == nil {
protoID = resolved
}
}
// Check if it's a named molecule (mol-xxx) - look up in catalog
if strings.HasPrefix(protoID, "mol-") {
// Find the proto by name
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Labels: []string{MoleculeLabel},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error searching for proto: %v\n", err)
os.Exit(1)
}
found := false
for _, issue := range issues {
if strings.Contains(issue.Title, protoID) || issue.ID == protoID {
protoID = issue.ID
found = true
break
}
}
if !found {
fmt.Fprintf(os.Stderr, "Error: '%s' not found as formula or proto\n", args[0])
fmt.Fprintf(os.Stderr, "Hint: run 'bd formula list' to see available formulas\n")
os.Exit(1)
}
}
// Load the proto
protoIssue, err := store.GetIssue(ctx, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto %s: %v\n", protoID, err)
os.Exit(1)
}
if !isProtoIssue(protoIssue) {
fmt.Fprintf(os.Stderr, "Error: %s is not a proto (missing '%s' label)\n", protoID, MoleculeLabel)
os.Exit(1)
}
// Load the proto subgraph from DB
subgraph, err = loadTemplateSubgraph(ctx, store, protoID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading proto: %v\n", err)
os.Exit(1)
}
}
// Apply variable defaults from formula (gt-4v1eo)
vars = applyVariableDefaults(vars, subgraph)
// Check for missing required variables (those without defaults)
requiredVars := extractRequiredVariables(subgraph)
var missingVars []string
for _, v := range requiredVars {
if _, ok := vars[v]; !ok {
missingVars = append(missingVars, v)
}
}
if len(missingVars) > 0 {
fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", "))
fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0])
os.Exit(1)
}
if dryRun {
fmt.Printf("\nDry run: would create wisp with %d issues from proto %s\n\n", len(subgraph.Issues), protoID)
fmt.Printf("Storage: main database (ephemeral=true, not exported to JSONL)\n\n")
for _, issue := range subgraph.Issues {
newTitle := substituteVariables(issue.Title, vars)
fmt.Printf(" - %s (from %s)\n", newTitle, issue.ID)
}
return
}
// Spawn as ephemeral in main database (Ephemeral=true, skips JSONL export)
// bd-hobo: Use "eph" prefix for distinct visual recognition
result, err := spawnMolecule(ctx, store, subgraph, vars, "", actor, true, "eph")
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating ephemeral: %v\n", err)
os.Exit(1)
}
// Ephemeral issues are in main db but don't trigger JSONL export (Wisp flag excludes them)
if jsonOutput {
type ephemeralCreateResult struct {
*InstantiateResult
Phase string `json:"phase"`
}
outputJSON(ephemeralCreateResult{result, "vapor"})
return
}
fmt.Printf("%s Created wisp: %d issues\n", ui.RenderPass("✓"), result.Created)
fmt.Printf(" Root issue: %s\n", result.NewEpicID)
fmt.Printf(" Phase: vapor (ephemeral, not exported to JSONL)\n")
fmt.Printf("\nNext steps:\n")
fmt.Printf(" bd close %s.<step> # Complete steps\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 creating digest\n", result.NewEpicID)
}
// isProtoIssue checks if an issue is a proto (has the template label)
func isProtoIssue(issue *types.Issue) bool {
for _, label := range issue.Labels {
if label == MoleculeLabel {
return true
}
}
return false
}
// resolvePartialIDDirect resolves a partial ID directly from store
func resolvePartialIDDirect(ctx context.Context, partial string) (string, error) {
// Try direct lookup first
if issue, err := store.GetIssue(ctx, partial); err == nil {
return issue.ID, nil
}
// Search by prefix
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
IDs: []string{partial + "*"},
})
if err != nil {
return "", err
}
if len(issues) == 1 {
return issues[0].ID, nil
}
if len(issues) > 1 {
return "", fmt.Errorf("ambiguous ID: %s matches %d issues", partial, len(issues))
}
return "", fmt.Errorf("not found: %s", partial)
}
var ephemeralListCmd = &cobra.Command{
Use: "list",
Short: "List all ephemeral issues in current context",
Long: `List all ephemeral molecules (ephemeral issues) in the current context.
Ephemeral issues are issues with Wisp=true in the main database. They are stored
locally but not exported to JSONL (and thus not synced via git).
The list shows:
- ID: Issue ID of the wisp
- Title: Wisp title
- Status: Current status (open, in_progress, closed)
- Started: When the wisp was created
- Updated: Last modification time
Old ephemeral issue detection:
- Old ephemeral issues haven't been updated in 24+ hours
- Use 'bd ephemeral gc' to clean up old/abandoned ephemeral issues
Examples:
bd ephemeral list # List all ephemeral issues
bd ephemeral list --json # JSON output for programmatic use
bd ephemeral list --all # Include closed ephemeral issues`,
Run: runEphemeralList,
}
func runEphemeralList(cmd *cobra.Command, args []string) {
ctx := rootCtx
showAll, _ := cmd.Flags().GetBool("all")
// Check for database connection
if store == nil && daemonClient == nil {
if jsonOutput {
outputJSON(EphemeralListResult{
Wisps: []EphemeralListItem{},
Count: 0,
})
} else {
fmt.Println("No database connection")
}
return
}
// Query ephemeral issues from main database using Wisp filter
ephemeralFlag := true
var issues []*types.Issue
var err error
if daemonClient != nil {
// Use daemon RPC
resp, rpcErr := daemonClient.List(&rpc.ListArgs{
Ephemeral: &ephemeralFlag,
})
if rpcErr != nil {
err = rpcErr
} else {
if jsonErr := json.Unmarshal(resp.Data, &issues); jsonErr != nil {
err = jsonErr
}
}
} else {
// Direct database access
filter := types.IssueFilter{
Ephemeral: &ephemeralFlag,
}
issues, err = store.SearchIssues(ctx, "", filter)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing ephemeral issues: %v\n", err)
os.Exit(1)
}
// 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 ephemeral issues
now := time.Now()
items := make([]EphemeralListItem, 0, len(issues))
oldCount := 0
for _, issue := range issues {
item := EphemeralListItem{
ID: issue.ID,
Title: issue.Title,
Status: string(issue.Status),
Priority: issue.Priority,
CreatedAt: issue.CreatedAt,
UpdatedAt: issue.UpdatedAt,
}
// Check if old (not updated in 24+ hours)
if now.Sub(issue.UpdatedAt) > OldThreshold {
item.Old = true
oldCount++
}
items = append(items, item)
}
// Sort by updated_at descending (most recent first)
slices.SortFunc(items, func(a, b EphemeralListItem) int {
return b.UpdatedAt.Compare(a.UpdatedAt) // descending order
})
result := EphemeralListResult{
Wisps: items,
Count: len(items),
OldCount: oldCount,
}
if jsonOutput {
outputJSON(result)
return
}
// Human-readable output
if len(items) == 0 {
fmt.Println("No ephemeral issues found")
return
}
fmt.Printf("Ephemeral issues (%d):\n\n", len(items))
// Print header
fmt.Printf("%-12s %-10s %-4s %-46s %s\n",
"ID", "STATUS", "PRI", "TITLE", "UPDATED")
fmt.Println(strings.Repeat("-", 90))
for _, item := range items {
// Truncate title if too long
title := item.Title
if len(title) > 44 {
title = title[:41] + "..."
}
// Format status with color
status := ui.RenderStatus(item.Status)
// Format updated time
updated := formatTimeAgo(item.UpdatedAt)
if item.Old {
updated = ui.RenderWarn(updated + " ⚠")
}
fmt.Printf("%-12s %-10s P%-3d %-46s %s\n",
item.ID, status, item.Priority, title, updated)
}
// Print warnings
if oldCount > 0 {
fmt.Printf("\n%s %d old ephemeral issue(s) (not updated in 24+ hours)\n",
ui.RenderWarn("⚠"), oldCount)
fmt.Println(" Hint: Use 'bd ephemeral gc' to clean up old ephemeral issues")
}
}
// formatTimeAgo returns a human-readable relative time
func formatTimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
mins := int(d.Minutes())
if mins == 1 {
return "1 min ago"
}
return fmt.Sprintf("%d mins ago", mins)
case d < 24*time.Hour:
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
case d < 7*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Format("2006-01-02")
}
}
var ephemeralGCCmd = &cobra.Command{
Use: "gc",
Short: "Garbage collect old/abandoned ephemeral issues",
Long: `Garbage collect old or abandoned ephemeral issues from the database.
A wisp is considered abandoned if:
- It hasn't been updated in --age duration and is not closed
Abandoned ephemeral issues are deleted without creating a digest. Use 'bd mol squash'
if you want to preserve a summary before garbage collection.
Note: This uses time-based cleanup, appropriate for ephemeral ephemeral issues.
For graph-pressure staleness detection (blocking other work), see 'bd mol stale'.
Examples:
bd ephemeral gc # Clean abandoned ephemeral issues (default: 1h threshold)
bd ephemeral gc --dry-run # Preview what would be cleaned
bd ephemeral gc --age 24h # Custom age threshold
bd ephemeral gc --all # Also clean closed ephemeral issues older than threshold`,
Run: runEphemeralGC,
}
// EphemeralGCResult is the JSON output for ephemeral gc
type EphemeralGCResult struct {
CleanedIDs []string `json:"cleaned_ids"`
CleanedCount int `json:"cleaned_count"`
Candidates int `json:"candidates,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
func runEphemeralGC(cmd *cobra.Command, args []string) {
CheckReadonly("ephemeral gc")
ctx := rootCtx
dryRun, _ := cmd.Flags().GetBool("dry-run")
ageStr, _ := cmd.Flags().GetString("age")
cleanAll, _ := cmd.Flags().GetBool("all")
// Parse age threshold
ageThreshold := time.Hour // Default 1 hour
if ageStr != "" {
var err error
ageThreshold, err = time.ParseDuration(ageStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --age duration: %v\n", err)
os.Exit(1)
}
}
// Wisp gc requires direct store access for deletion
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: ephemeral gc requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon ephemeral gc\n")
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
// Query ephemeral issues from main database using Wisp filter
ephemeralFlag := true
filter := types.IssueFilter{
Ephemeral: &ephemeralFlag,
}
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing ephemeral issues: %v\n", err)
os.Exit(1)
}
// Find old/abandoned ephemeral issues
now := time.Now()
var abandoned []*types.Issue
for _, issue := range issues {
// Skip closed issues unless --all is specified
if issue.Status == types.StatusClosed && !cleanAll {
continue
}
// Check if old (not updated within age threshold)
if now.Sub(issue.UpdatedAt) > ageThreshold {
abandoned = append(abandoned, issue)
}
}
if len(abandoned) == 0 {
if jsonOutput {
outputJSON(EphemeralGCResult{
CleanedIDs: []string{},
CleanedCount: 0,
DryRun: dryRun,
})
} else {
fmt.Println("No abandoned ephemeral issues found")
}
return
}
if dryRun {
if jsonOutput {
ids := make([]string, len(abandoned))
for i, o := range abandoned {
ids[i] = o.ID
}
outputJSON(EphemeralGCResult{
CleanedIDs: ids,
Candidates: len(abandoned),
CleanedCount: 0,
DryRun: true,
})
} else {
fmt.Printf("Dry run: would clean %d abandoned ephemeral issue(s):\n\n", len(abandoned))
for _, issue := range abandoned {
age := formatTimeAgo(issue.UpdatedAt)
fmt.Printf(" %s: %s (last updated: %s)\n", issue.ID, issue.Title, age)
}
fmt.Printf("\nRun without --dry-run to delete these ephemeral issues.\n")
}
return
}
// Delete abandoned ephemeral issues
var cleanedIDs []string
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
if !ok {
fmt.Fprintf(os.Stderr, "Error: ephemeral gc requires SQLite storage backend\n")
os.Exit(1)
}
for _, issue := range abandoned {
if err := sqliteStore.DeleteIssue(ctx, issue.ID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete %s: %v\n", issue.ID, err)
continue
}
cleanedIDs = append(cleanedIDs, issue.ID)
}
result := EphemeralGCResult{
CleanedIDs: cleanedIDs,
CleanedCount: len(cleanedIDs),
}
if jsonOutput {
outputJSON(result)
return
}
fmt.Printf("%s Cleaned %d abandoned ephemeral issue(s)\n", ui.RenderPass("✓"), result.CleanedCount)
for _, id := range cleanedIDs {
fmt.Printf(" - %s\n", id)
}
}
func init() {
// Ephemeral command flags (for direct create: bd mol ephemeral <proto>)
ephemeralCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
ephemeralCmd.Flags().Bool("dry-run", false, "Preview what would be created")
// Ephemeral create command flags (kept for backwards compat: bd mol ephemeral create <proto>)
ephemeralCreateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
ephemeralCreateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
ephemeralListCmd.Flags().Bool("all", false, "Include closed ephemeral issues")
ephemeralGCCmd.Flags().Bool("dry-run", false, "Preview what would be cleaned")
ephemeralGCCmd.Flags().String("age", "1h", "Age threshold for abandoned ephemeral issue detection")
ephemeralGCCmd.Flags().Bool("all", false, "Also clean closed ephemeral issues older than threshold")
ephemeralCmd.AddCommand(ephemeralCreateCmd)
ephemeralCmd.AddCommand(ephemeralListCmd)
ephemeralCmd.AddCommand(ephemeralGCCmd)
molCmd.AddCommand(ephemeralCmd)
}