Merge remote-tracking branch 'origin/polecat/road-warrior-mk0vt2ef'

This commit is contained in:
mayor
2026-01-05 19:38:59 -08:00
committed by beads/crew/dave
3 changed files with 98 additions and 153 deletions

62
internal/mail/bd.go Normal file
View File

@@ -0,0 +1,62 @@
package mail
import (
"bytes"
"os/exec"
"strings"
)
// bdError represents an error from running a bd command.
// It wraps the underlying error and includes the stderr output for inspection.
type bdError struct {
Err error
Stderr string
}
// Error implements the error interface.
func (e *bdError) Error() string {
if e.Stderr != "" {
return e.Stderr
}
if e.Err != nil {
return e.Err.Error()
}
return "unknown bd error"
}
// Unwrap returns the underlying error for errors.Is/As compatibility.
func (e *bdError) Unwrap() error {
return e.Err
}
// ContainsError checks if the stderr message contains the given substring.
func (e *bdError) ContainsError(substr string) bool {
return strings.Contains(e.Stderr, substr)
}
// runBdCommand executes a bd command with proper environment setup.
// workDir is the directory to run the command in.
// beadsDir is the BEADS_DIR environment variable value.
// extraEnv contains additional environment variables to set (e.g., "BD_IDENTITY=...").
// Returns stdout bytes on success, or a *bdError on failure.
func runBdCommand(args []string, workDir, beadsDir string, extraEnv ...string) ([]byte, error) {
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
cmd.Dir = workDir
env := append(cmd.Environ(), "BEADS_DIR="+beadsDir)
env = append(env, extraEnv...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, &bdError{
Err: err,
Stderr: strings.TrimSpace(stderr.String()),
}
}
return stdout.Bytes(), nil
}

View File

@@ -2,16 +2,13 @@ package mail
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/steveyegge/gastown/internal/beads"
@@ -182,34 +179,23 @@ func (m *Mailbox) identityVariants() []string {
// queryMessages runs a bd list query with the given filter flag and value.
func (m *Mailbox) queryMessages(beadsDir, filterFlag, filterValue, status string) ([]*Message, error) {
cmd := exec.Command("bd", "list",
args := []string{"list",
"--type", "message",
filterFlag, filterValue,
"--status", status,
"--json",
)
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(),
"BEADS_DIR="+beadsDir,
)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, errors.New(errMsg)
}
stdout, err := runBdCommand(args, m.workDir, beadsDir)
if err != nil {
return nil, err
}
// Parse JSON output
var beadsMsgs []BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
if err := json.Unmarshal(stdout, &beadsMsgs); err != nil {
// Empty inbox returns empty array or nothing
if len(stdout.Bytes()) == 0 || stdout.String() == "null" {
if len(stdout) == 0 || string(stdout) == "null" {
return nil, nil
}
return nil, err
@@ -295,28 +281,19 @@ func (m *Mailbox) getBeads(id string) (*Message, error) {
// getFromDir retrieves a message from a beads directory.
func (m *Mailbox) getFromDir(id, beadsDir string) (*Message, error) {
cmd := exec.Command("bd", "show", id, "--json")
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
args := []string{"show", id, "--json"}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if strings.Contains(errMsg, "not found") {
stdout, err := runBdCommand(args, m.workDir, beadsDir)
if err != nil {
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
return nil, ErrMessageNotFound
}
if errMsg != "" {
return nil, errors.New(errMsg)
}
return nil, err
}
// bd show --json returns an array
var bms []BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &bms); err != nil {
if err := json.Unmarshal(stdout, &bms); err != nil {
return nil, err
}
if len(bms) == 0 {
@@ -360,21 +337,12 @@ func (m *Mailbox) closeInDir(id, beadsDir string) error {
if sessionID := os.Getenv("CLAUDE_SESSION_ID"); sessionID != "" {
args = append(args, "--session="+sessionID)
}
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if strings.Contains(errMsg, "not found") {
_, err := runBdCommand(args, m.workDir, beadsDir)
if err != nil {
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
return ErrMessageNotFound
}
if errMsg != "" {
return errors.New(errMsg)
}
return err
}
@@ -411,21 +379,13 @@ func (m *Mailbox) MarkUnread(id string) error {
}
func (m *Mailbox) markUnreadBeads(id string) error {
cmd := exec.Command("bd", "reopen", id)
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+m.beadsDir)
args := []string{"reopen", id}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if strings.Contains(errMsg, "not found") {
_, err := runBdCommand(args, m.workDir, m.beadsDir)
if err != nil {
if bdErr, ok := err.(*bdError); ok && bdErr.ContainsError("not found") {
return ErrMessageNotFound
}
if errMsg != "" {
return errors.New(errMsg)
}
return err
}
@@ -813,29 +773,16 @@ func (m *Mailbox) ListByThread(threadID string) ([]*Message, error) {
}
func (m *Mailbox) listByThreadBeads(threadID string) ([]*Message, error) {
// bd message thread <thread-id> --json
cmd := exec.Command("bd", "message", "thread", threadID, "--json")
cmd.Dir = m.workDir
cmd.Env = append(cmd.Environ(),
"BD_IDENTITY="+m.identity,
"BEADS_DIR="+m.beadsDir,
)
args := []string{"message", "thread", threadID, "--json"}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, errors.New(errMsg)
}
stdout, err := runBdCommand(args, m.workDir, m.beadsDir, "BD_IDENTITY="+m.identity)
if err != nil {
return nil, err
}
var beadsMsgs []BeadsMessage
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
if len(stdout.Bytes()) == 0 || stdout.String() == "null" {
if err := json.Unmarshal(stdout, &beadsMsgs); err != nil {
if len(stdout) == 0 || string(stdout) == "null" {
return nil, nil
}
return nil, err

View File

@@ -1,12 +1,10 @@
package mail
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -448,24 +446,13 @@ func (r *Router) queryAgents(descContains string) ([]*agentBead, error) {
args = append(args, "--desc-contains="+descContains)
}
cmd := exec.Command("bd", args...)
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
cmd.Dir = filepath.Dir(beadsDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return nil, errors.New(errMsg)
}
stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
if err != nil {
return nil, fmt.Errorf("querying agents: %w", err)
}
var agents []*agentBead
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil {
if err := json.Unmarshal(stdout, &agents); err != nil {
return nil, fmt.Errorf("parsing agent query result: %w", err)
}
@@ -617,20 +604,8 @@ func (r *Router) sendToSingle(msg *Message) error {
}
beadsDir := r.resolveBeadsDir(msg.To)
cmd := exec.Command("bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
cmd.Env = append(cmd.Environ(),
"BEADS_DIR="+beadsDir,
)
cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return errors.New(errMsg)
}
_, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
if err != nil {
return fmt.Errorf("sending message: %w", err)
}
@@ -739,20 +714,8 @@ func (r *Router) sendToQueue(msg *Message) error {
// Queue messages go to town-level beads (shared location)
beadsDir := r.resolveBeadsDir("")
cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally, not from user input
cmd.Env = append(cmd.Environ(),
"BEADS_DIR="+beadsDir,
)
cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return errors.New(errMsg)
}
_, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
if err != nil {
return fmt.Errorf("sending to queue %s: %w", queueName, err)
}
@@ -822,20 +785,8 @@ func (r *Router) sendToAnnounce(msg *Message) error {
// Announce messages go to town-level beads (shared location)
beadsDir := r.resolveBeadsDir("")
cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally, not from user input
cmd.Env = append(cmd.Environ(),
"BEADS_DIR="+beadsDir,
)
cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return errors.New(errMsg)
}
_, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
if err != nil {
return fmt.Errorf("sending to announce %s: %w", announceName, err)
}
@@ -864,19 +815,8 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error {
"--asc", // Oldest first
}
cmd := exec.Command("bd", args...) //nolint:gosec // G204: args are constructed internally
cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir)
cmd.Dir = filepath.Dir(beadsDir)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return errors.New(errMsg)
}
stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
if err != nil {
return fmt.Errorf("querying announce messages: %w", err)
}
@@ -884,7 +824,7 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error {
var messages []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(stdout.Bytes(), &messages); err != nil {
if err := json.Unmarshal(stdout, &messages); err != nil {
return fmt.Errorf("parsing announce messages: %w", err)
}
@@ -899,12 +839,8 @@ func (r *Router) pruneAnnounce(announceName string, retainCount int) error {
// Delete oldest messages
for i := 0; i < toDelete && i < len(messages); i++ {
deleteArgs := []string{"close", messages[i].ID, "--reason=retention pruning"}
deleteCmd := exec.Command("bd", deleteArgs...) //nolint:gosec // G204: args are constructed internally
deleteCmd.Env = append(deleteCmd.Environ(), "BEADS_DIR="+beadsDir)
deleteCmd.Dir = filepath.Dir(beadsDir)
// Best-effort deletion - don't fail if one delete fails
_ = deleteCmd.Run()
_, _ = runBdCommand(deleteArgs, filepath.Dir(beadsDir), beadsDir)
}
return nil