feat: refactor mail system to use bd mail backend
- Mail commands (send/inbox/read/delete) now wrap bd mail CLI - Address translation: mayor/ → mayor, rig/polecat → rig-polecat - Beads stores messages as type=message issues - Legacy JSONL mode retained for crew workers (local mail) - Refinery notifications use new mail interface - Swarm landing notifications use new mail interface Closes gt-u1j.6, gt-u1j.12 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+100
-20
@@ -15,12 +15,12 @@ import (
|
||||
|
||||
// Mail command flags
|
||||
var (
|
||||
mailSubject string
|
||||
mailBody string
|
||||
mailPriority string
|
||||
mailNotify bool
|
||||
mailInboxJSON bool
|
||||
mailReadJSON bool
|
||||
mailSubject string
|
||||
mailBody string
|
||||
mailPriority string
|
||||
mailNotify bool
|
||||
mailInboxJSON bool
|
||||
mailReadJSON bool
|
||||
mailInboxUnread bool
|
||||
)
|
||||
|
||||
@@ -29,7 +29,8 @@ var mailCmd = &cobra.Command{
|
||||
Short: "Agent messaging system",
|
||||
Long: `Send and receive messages between agents.
|
||||
|
||||
The mail system allows Mayor, polecats, and the Refinery to communicate.`,
|
||||
The mail system allows Mayor, polecats, and the Refinery to communicate.
|
||||
Messages are stored in beads as issues with type=message.`,
|
||||
}
|
||||
|
||||
var mailSendCmd = &cobra.Command{
|
||||
@@ -76,6 +77,16 @@ The message ID can be found from 'gt mail inbox'.`,
|
||||
RunE: runMailRead,
|
||||
}
|
||||
|
||||
var mailDeleteCmd = &cobra.Command{
|
||||
Use: "delete <message-id>",
|
||||
Short: "Delete a message",
|
||||
Long: `Delete (acknowledge) a message.
|
||||
|
||||
This closes the message in beads.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMailDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Send flags
|
||||
mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)")
|
||||
@@ -95,6 +106,7 @@ func init() {
|
||||
mailCmd.AddCommand(mailSendCmd)
|
||||
mailCmd.AddCommand(mailInboxCmd)
|
||||
mailCmd.AddCommand(mailReadCmd)
|
||||
mailCmd.AddCommand(mailDeleteCmd)
|
||||
|
||||
rootCmd.AddCommand(mailCmd)
|
||||
}
|
||||
@@ -102,37 +114,43 @@ func init() {
|
||||
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||
to := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
// Find workspace - we need a directory with .beads
|
||||
workDir, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine sender
|
||||
from := detectSender(townRoot)
|
||||
from := detectSender()
|
||||
|
||||
// Create message
|
||||
msg := mail.NewMessage(from, to, mailSubject, mailBody)
|
||||
msg := &mail.Message{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: mailSubject,
|
||||
Body: mailBody,
|
||||
}
|
||||
|
||||
// Set priority
|
||||
if mailPriority == "high" || mailNotify {
|
||||
msg.Priority = mail.PriorityHigh
|
||||
}
|
||||
|
||||
// Send
|
||||
router := mail.NewRouter(townRoot)
|
||||
// Send via router
|
||||
router := mail.NewRouter(workDir)
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
||||
fmt.Printf(" ID: %s\n", style.Dim.Render(msg.ID))
|
||||
fmt.Printf(" Subject: %s\n", mailSubject)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
// Find workspace
|
||||
workDir, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
@@ -142,11 +160,11 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
address = args[0]
|
||||
} else {
|
||||
address = detectSender(townRoot)
|
||||
address = detectSender()
|
||||
}
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(townRoot)
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
@@ -204,16 +222,17 @@ func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||
func runMailRead(cmd *cobra.Command, args []string) error {
|
||||
msgID := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
// Find workspace
|
||||
workDir, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender(townRoot)
|
||||
address := detectSender()
|
||||
|
||||
// Get mailbox and message
|
||||
router := mail.NewRouter(townRoot)
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
@@ -253,8 +272,69 @@ func runMailRead(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMailDelete(cmd *cobra.Command, args []string) error {
|
||||
msgID := args[0]
|
||||
|
||||
// Find workspace
|
||||
workDir, err := findBeadsWorkDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Determine which inbox
|
||||
address := detectSender()
|
||||
|
||||
// Get mailbox
|
||||
router := mail.NewRouter(workDir)
|
||||
mailbox, err := router.GetMailbox(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mailbox: %w", err)
|
||||
}
|
||||
|
||||
if err := mailbox.Delete(msgID); err != nil {
|
||||
return fmt.Errorf("deleting message: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Message deleted\n", style.Bold.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// findBeadsWorkDir finds a directory with a .beads database.
|
||||
// Walks up from CWD looking for .beads/ directory.
|
||||
func findBeadsWorkDir() (string, error) {
|
||||
// First try workspace root
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err == nil {
|
||||
// Check if town root has .beads
|
||||
if _, err := os.Stat(filepath.Join(townRoot, ".beads")); err == nil {
|
||||
return townRoot, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up from CWD looking for .beads
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := cwd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
break // Reached root
|
||||
}
|
||||
path = parent
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no .beads directory found")
|
||||
}
|
||||
|
||||
// detectSender determines the current context's address.
|
||||
func detectSender(townRoot string) string {
|
||||
func detectSender() string {
|
||||
// Check environment variables (set by session start)
|
||||
rig := os.Getenv("GT_RIG")
|
||||
polecat := os.Getenv("GT_POLECAT")
|
||||
|
||||
+245
-86
@@ -2,11 +2,14 @@ package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
@@ -15,23 +18,97 @@ var (
|
||||
ErrEmptyInbox = errors.New("inbox is empty")
|
||||
)
|
||||
|
||||
// Mailbox manages a JSONL-based inbox.
|
||||
// Mailbox manages messages for an identity via beads.
|
||||
type Mailbox struct {
|
||||
path string
|
||||
identity string // beads identity (e.g., "gastown-Toast")
|
||||
workDir string // directory to run bd commands in
|
||||
path string // for legacy JSONL mode (crew workers)
|
||||
legacy bool // true = use JSONL files, false = use beads
|
||||
}
|
||||
|
||||
// NewMailbox creates a mailbox at the given path.
|
||||
// NewMailbox creates a mailbox for the given JSONL path (legacy mode).
|
||||
// Used by crew workers that have local JSONL inboxes.
|
||||
func NewMailbox(path string) *Mailbox {
|
||||
return &Mailbox{path: path}
|
||||
return &Mailbox{
|
||||
path: filepath.Join(path, "inbox.jsonl"),
|
||||
legacy: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the mailbox file path.
|
||||
// NewMailboxBeads creates a mailbox backed by beads.
|
||||
func NewMailboxBeads(identity, workDir string) *Mailbox {
|
||||
return &Mailbox{
|
||||
identity: identity,
|
||||
workDir: workDir,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewMailboxFromAddress creates a beads-backed mailbox from a GGT address.
|
||||
func NewMailboxFromAddress(address, workDir string) *Mailbox {
|
||||
return &Mailbox{
|
||||
identity: addressToIdentity(address),
|
||||
workDir: workDir,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Identity returns the beads identity for this mailbox.
|
||||
func (m *Mailbox) Identity() string {
|
||||
return m.identity
|
||||
}
|
||||
|
||||
// Path returns the JSONL path for legacy mailboxes.
|
||||
func (m *Mailbox) Path() string {
|
||||
return m.path
|
||||
}
|
||||
|
||||
// List returns all messages in the mailbox.
|
||||
// List returns all open messages in the mailbox.
|
||||
func (m *Mailbox) List() ([]*Message, error) {
|
||||
if m.legacy {
|
||||
return m.listLegacy()
|
||||
}
|
||||
return m.listBeads()
|
||||
}
|
||||
|
||||
func (m *Mailbox) listBeads() ([]*Message, error) {
|
||||
// bd mail inbox --json
|
||||
cmd := exec.Command("bd", "mail", "inbox", "--json")
|
||||
cmd.Dir = m.workDir
|
||||
cmd.Env = append(cmd.Environ(), "BD_IDENTITY="+m.identity)
|
||||
|
||||
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)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var beadsMsgs []BeadsMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &beadsMsgs); err != nil {
|
||||
// Empty inbox returns empty array or nothing
|
||||
if len(stdout.Bytes()) == 0 || string(stdout.Bytes()) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to GGT messages
|
||||
var messages []*Message
|
||||
for _, bm := range beadsMsgs {
|
||||
messages = append(messages, bm.ToMessage())
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) listLegacy() ([]*Message, error) {
|
||||
file, err := os.Open(m.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -68,41 +145,186 @@ func (m *Mailbox) List() ([]*Message, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// ListUnread returns unread messages.
|
||||
// ListUnread returns unread (open) messages.
|
||||
func (m *Mailbox) ListUnread() ([]*Message, error) {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unread []*Message
|
||||
for _, msg := range all {
|
||||
if !msg.Read {
|
||||
unread = append(unread, msg)
|
||||
if m.legacy {
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unread []*Message
|
||||
for _, msg := range all {
|
||||
if !msg.Read {
|
||||
unread = append(unread, msg)
|
||||
}
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
return unread, nil
|
||||
// For beads, inbox only returns open (unread) messages
|
||||
return m.List()
|
||||
}
|
||||
|
||||
// Get returns a message by ID.
|
||||
func (m *Mailbox) Get(id string) (*Message, error) {
|
||||
if m.legacy {
|
||||
return m.getLegacy(id)
|
||||
}
|
||||
return m.getBeads(id)
|
||||
}
|
||||
|
||||
func (m *Mailbox) getBeads(id string) (*Message, error) {
|
||||
cmd := exec.Command("bd", "mail", "read", id, "--json")
|
||||
cmd.Dir = m.workDir
|
||||
|
||||
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") {
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bm BeadsMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &bm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bm.ToMessage(), nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) getLegacy(id string) (*Message, error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
|
||||
// Append adds a message to the mailbox.
|
||||
// MarkRead marks a message as read.
|
||||
func (m *Mailbox) MarkRead(id string) error {
|
||||
if m.legacy {
|
||||
return m.markReadLegacy(id)
|
||||
}
|
||||
return m.markReadBeads(id)
|
||||
}
|
||||
|
||||
func (m *Mailbox) markReadBeads(id string) error {
|
||||
cmd := exec.Command("bd", "mail", "ack", id)
|
||||
cmd.Dir = m.workDir
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if errMsg != "" {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailbox) markReadLegacy(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
msg.Read = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewriteLegacy(messages)
|
||||
}
|
||||
|
||||
// Delete removes a message.
|
||||
func (m *Mailbox) Delete(id string) error {
|
||||
if m.legacy {
|
||||
return m.deleteLegacy(id)
|
||||
}
|
||||
return m.MarkRead(id) // beads: just acknowledge/close
|
||||
}
|
||||
|
||||
func (m *Mailbox) deleteLegacy(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []*Message
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewriteLegacy(filtered)
|
||||
}
|
||||
|
||||
// Count returns the total and unread message counts.
|
||||
func (m *Mailbox) Count() (total, unread int, err error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
total = len(messages)
|
||||
if m.legacy {
|
||||
for _, msg := range messages {
|
||||
if !msg.Read {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For beads, inbox only returns unread
|
||||
unread = total
|
||||
}
|
||||
|
||||
return total, unread, nil
|
||||
}
|
||||
|
||||
// Append adds a message to the mailbox (legacy mode only).
|
||||
// For beads mode, use Router.Send() instead.
|
||||
func (m *Mailbox) Append(msg *Message) error {
|
||||
if !m.legacy {
|
||||
return errors.New("use Router.Send() to send messages via beads")
|
||||
}
|
||||
return m.appendLegacy(msg)
|
||||
}
|
||||
|
||||
func (m *Mailbox) appendLegacy(msg *Message) error {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(m.path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -125,71 +347,8 @@ func (m *Mailbox) Append(msg *Message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkRead marks a message as read.
|
||||
func (m *Mailbox) MarkRead(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
msg.Read = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewrite(messages)
|
||||
}
|
||||
|
||||
// Delete removes a message from the mailbox.
|
||||
func (m *Mailbox) Delete(id string) error {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []*Message
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
found = true
|
||||
} else {
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
|
||||
return m.rewrite(filtered)
|
||||
}
|
||||
|
||||
// Count returns the total and unread message counts.
|
||||
func (m *Mailbox) Count() (total, unread int, err error) {
|
||||
messages, err := m.List()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
total = len(messages)
|
||||
for _, msg := range messages {
|
||||
if !msg.Read {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
|
||||
return total, unread, nil
|
||||
}
|
||||
|
||||
// rewrite rewrites the mailbox with the given messages.
|
||||
func (m *Mailbox) rewrite(messages []*Message) error {
|
||||
// rewriteLegacy rewrites the mailbox with the given messages.
|
||||
func (m *Mailbox) rewriteLegacy(messages []*Message) error {
|
||||
// Sort by timestamp (oldest first for JSONL)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Timestamp.Before(messages[j].Timestamp)
|
||||
|
||||
+38
-62
@@ -1,39 +1,60 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// Router handles message delivery and address resolution.
|
||||
// Router handles message delivery via beads.
|
||||
type Router struct {
|
||||
townRoot string
|
||||
tmux *tmux.Tmux
|
||||
workDir string // directory to run bd commands in
|
||||
tmux *tmux.Tmux
|
||||
}
|
||||
|
||||
// NewRouter creates a new mail router.
|
||||
func NewRouter(townRoot string) *Router {
|
||||
// workDir should be a directory containing a .beads database.
|
||||
func NewRouter(workDir string) *Router {
|
||||
return &Router{
|
||||
townRoot: townRoot,
|
||||
tmux: tmux.NewTmux(),
|
||||
workDir: workDir,
|
||||
tmux: tmux.NewTmux(),
|
||||
}
|
||||
}
|
||||
|
||||
// Send delivers a message to its recipient.
|
||||
// Send delivers a message via beads mail.
|
||||
func (r *Router) Send(msg *Message) error {
|
||||
// Resolve recipient mailbox path
|
||||
mailboxPath, err := r.ResolveMailbox(msg.To)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving address '%s': %w", msg.To, err)
|
||||
// Convert addresses to beads identities
|
||||
toIdentity := addressToIdentity(msg.To)
|
||||
fromIdentity := addressToIdentity(msg.From)
|
||||
|
||||
// Build command: bd mail send <recipient> -s <subject> -m <body> --identity <sender>
|
||||
args := []string{"mail", "send", toIdentity,
|
||||
"-s", msg.Subject,
|
||||
"-m", msg.Body,
|
||||
"--identity", fromIdentity,
|
||||
}
|
||||
|
||||
// Append to mailbox
|
||||
mailbox := NewMailbox(mailboxPath)
|
||||
if err := mailbox.Append(msg); err != nil {
|
||||
return fmt.Errorf("delivering message: %w", err)
|
||||
// Add --urgent flag for high priority
|
||||
if msg.Priority == PriorityHigh {
|
||||
args = append(args, "--urgent")
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
cmd.Dir = r.workDir
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
return fmt.Errorf("sending message: %w", err)
|
||||
}
|
||||
|
||||
// Optionally notify if recipient is a polecat with active session
|
||||
@@ -44,54 +65,9 @@ func (r *Router) Send(msg *Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveMailbox converts an address to a mailbox file path.
|
||||
//
|
||||
// Address formats:
|
||||
// - mayor/ → <town>/mayor/mail/inbox.jsonl
|
||||
// - <rig>/refinery → <town>/<rig>/refinery/mail/inbox.jsonl
|
||||
// - <rig>/<polecat> → <town>/<rig>/polecats/<polecat>/mail/inbox.jsonl
|
||||
// - <rig>/ → <town>/<rig>/mail/inbox.jsonl (rig broadcast)
|
||||
func (r *Router) ResolveMailbox(address string) (string, error) {
|
||||
address = strings.TrimSpace(address)
|
||||
if address == "" {
|
||||
return "", fmt.Errorf("empty address")
|
||||
}
|
||||
|
||||
// Mayor
|
||||
if address == "mayor/" || address == "mayor" {
|
||||
return filepath.Join(r.townRoot, "mayor", "mail", "inbox.jsonl"), nil
|
||||
}
|
||||
|
||||
// Parse rig/target
|
||||
parts := strings.SplitN(address, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid address format: %s", address)
|
||||
}
|
||||
|
||||
rig := parts[0]
|
||||
target := parts[1]
|
||||
|
||||
// Rig broadcast (empty target or just /)
|
||||
if target == "" {
|
||||
return filepath.Join(r.townRoot, rig, "mail", "inbox.jsonl"), nil
|
||||
}
|
||||
|
||||
// Refinery
|
||||
if target == "refinery" {
|
||||
return filepath.Join(r.townRoot, rig, "refinery", "mail", "inbox.jsonl"), nil
|
||||
}
|
||||
|
||||
// Polecat
|
||||
return filepath.Join(r.townRoot, rig, "polecats", target, "mail", "inbox.jsonl"), nil
|
||||
}
|
||||
|
||||
// GetMailbox returns a Mailbox for the given address.
|
||||
func (r *Router) GetMailbox(address string) (*Mailbox, error) {
|
||||
path, err := r.ResolveMailbox(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMailbox(path), nil
|
||||
return NewMailboxFromAddress(address, r.workDir), nil
|
||||
}
|
||||
|
||||
// notifyPolecat sends a notification to a polecat's tmux session.
|
||||
|
||||
+90
-4
@@ -1,4 +1,4 @@
|
||||
// Package mail provides JSONL-based messaging for agent communication.
|
||||
// Package mail provides messaging for agent communication via beads.
|
||||
package mail
|
||||
|
||||
import (
|
||||
@@ -19,8 +19,9 @@ const (
|
||||
)
|
||||
|
||||
// Message represents a mail message between agents.
|
||||
// This is the GGT-side representation; it gets translated to/from beads messages.
|
||||
type Message struct {
|
||||
// ID is a unique message identifier.
|
||||
// ID is a unique message identifier (beads issue ID like "bd-abc123").
|
||||
ID string `json:"id"`
|
||||
|
||||
// From is the sender address (e.g., "gastown/Toast" or "mayor/").
|
||||
@@ -38,14 +39,14 @@ type Message struct {
|
||||
// Timestamp is when the message was sent.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Read indicates if the message has been read.
|
||||
// Read indicates if the message has been read (closed in beads).
|
||||
Read bool `json:"read"`
|
||||
|
||||
// Priority is the message priority.
|
||||
Priority Priority `json:"priority"`
|
||||
}
|
||||
|
||||
// NewMessage creates a new message with a generated ID.
|
||||
// NewMessage creates a new message with a generated ID (for legacy JSONL mode).
|
||||
func NewMessage(from, to, subject, body string) *Message {
|
||||
return &Message{
|
||||
ID: generateID(),
|
||||
@@ -65,3 +66,88 @@ func generateID() string {
|
||||
rand.Read(b)
|
||||
return "msg-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// BeadsMessage represents a message as returned by bd mail commands.
|
||||
type BeadsMessage struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"` // Subject
|
||||
Description string `json:"description"` // Body
|
||||
Sender string `json:"sender"` // From identity
|
||||
Assignee string `json:"assignee"` // To identity
|
||||
Priority int `json:"priority"` // 0=urgent, 2=normal
|
||||
Status string `json:"status"` // open=unread, closed=read
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ToMessage converts a BeadsMessage to a GGT Message.
|
||||
func (bm *BeadsMessage) ToMessage() *Message {
|
||||
priority := PriorityNormal
|
||||
if bm.Priority == 0 {
|
||||
priority = PriorityHigh
|
||||
}
|
||||
|
||||
return &Message{
|
||||
ID: bm.ID,
|
||||
From: identityToAddress(bm.Sender),
|
||||
To: identityToAddress(bm.Assignee),
|
||||
Subject: bm.Title,
|
||||
Body: bm.Description,
|
||||
Timestamp: bm.CreatedAt,
|
||||
Read: bm.Status == "closed",
|
||||
Priority: priority,
|
||||
}
|
||||
}
|
||||
|
||||
// addressToIdentity converts a GGT address to a beads identity.
|
||||
//
|
||||
// Examples:
|
||||
// - "mayor/" → "mayor"
|
||||
// - "gastown/Toast" → "gastown-Toast"
|
||||
// - "gastown/refinery" → "gastown-refinery"
|
||||
// - "gastown/" → "gastown" (rig broadcast)
|
||||
func addressToIdentity(address string) string {
|
||||
// Trim trailing slash
|
||||
if len(address) > 0 && address[len(address)-1] == '/' {
|
||||
address = address[:len(address)-1]
|
||||
}
|
||||
|
||||
// Mayor special case
|
||||
if address == "mayor" {
|
||||
return "mayor"
|
||||
}
|
||||
|
||||
// Replace / with - for beads identity
|
||||
// gastown/Toast → gastown-Toast
|
||||
result := ""
|
||||
for _, c := range address {
|
||||
if c == '/' {
|
||||
result += "-"
|
||||
} else {
|
||||
result = result + string(c)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// identityToAddress converts a beads identity back to a GGT address.
|
||||
//
|
||||
// Examples:
|
||||
// - "mayor" → "mayor/"
|
||||
// - "gastown-Toast" → "gastown/Toast"
|
||||
// - "gastown-refinery" → "gastown/refinery"
|
||||
func identityToAddress(identity string) string {
|
||||
if identity == "mayor" {
|
||||
return "mayor/"
|
||||
}
|
||||
|
||||
// Find first dash and replace with /
|
||||
// gastown-Toast → gastown/Toast
|
||||
for i, c := range identity {
|
||||
if c == '-' {
|
||||
return identity[:i] + "/" + identity[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// No dash found, return as-is with trailing slash
|
||||
return identity + "/"
|
||||
}
|
||||
|
||||
@@ -497,18 +497,12 @@ func formatAge(t time.Time) string {
|
||||
|
||||
// notifyWorkerConflict sends a conflict notification to a polecat.
|
||||
func (m *Manager) notifyWorkerConflict(mr *MergeRequest) {
|
||||
// Find town root by walking up from rig path
|
||||
townRoot := findTownRoot(m.workDir)
|
||||
if townRoot == "" {
|
||||
return
|
||||
}
|
||||
|
||||
router := mail.NewRouter(townRoot)
|
||||
msg := mail.NewMessage(
|
||||
fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
|
||||
"Merge conflict - rebase required",
|
||||
fmt.Sprintf(`Your branch %s has conflicts with %s.
|
||||
router := mail.NewRouter(m.workDir)
|
||||
msg := &mail.Message{
|
||||
From: fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
To: fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
|
||||
Subject: "Merge conflict - rebase required",
|
||||
Body: fmt.Sprintf(`Your branch %s has conflicts with %s.
|
||||
|
||||
Please rebase your changes:
|
||||
git fetch origin
|
||||
@@ -517,29 +511,24 @@ Please rebase your changes:
|
||||
|
||||
Then the Refinery will retry the merge.`,
|
||||
mr.Branch, mr.TargetBranch, mr.TargetBranch),
|
||||
)
|
||||
msg.Priority = mail.PriorityHigh
|
||||
Priority: mail.PriorityHigh,
|
||||
}
|
||||
router.Send(msg)
|
||||
}
|
||||
|
||||
// notifyWorkerMerged sends a success notification to a polecat.
|
||||
func (m *Manager) notifyWorkerMerged(mr *MergeRequest) {
|
||||
townRoot := findTownRoot(m.workDir)
|
||||
if townRoot == "" {
|
||||
return
|
||||
}
|
||||
|
||||
router := mail.NewRouter(townRoot)
|
||||
msg := mail.NewMessage(
|
||||
fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
|
||||
"Work merged successfully",
|
||||
fmt.Sprintf(`Your branch %s has been merged to %s.
|
||||
router := mail.NewRouter(m.workDir)
|
||||
msg := &mail.Message{
|
||||
From: fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
To: fmt.Sprintf("%s/%s", m.rig.Name, mr.Worker),
|
||||
Subject: "Work merged successfully",
|
||||
Body: fmt.Sprintf(`Your branch %s has been merged to %s.
|
||||
|
||||
Issue: %s
|
||||
Thank you for your contribution!`,
|
||||
mr.Branch, mr.TargetBranch, mr.IssueID),
|
||||
)
|
||||
}
|
||||
router.Send(msg)
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -196,31 +196,31 @@ func (m *Manager) gitRunOutput(dir string, args ...string) (string, error) {
|
||||
|
||||
// notifyMayorCodeAtRisk sends an alert to Mayor about code at risk.
|
||||
func (m *Manager) notifyMayorCodeAtRisk(townRoot, swarmID string, workers []string) {
|
||||
router := mail.NewRouter(townRoot)
|
||||
msg := mail.NewMessage(
|
||||
fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
"mayor/",
|
||||
fmt.Sprintf("⚠️ Code at risk in swarm %s", swarmID),
|
||||
fmt.Sprintf(`Landing blocked for swarm %s.
|
||||
router := mail.NewRouter(m.workDir)
|
||||
msg := &mail.Message{
|
||||
From: fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
To: "mayor/",
|
||||
Subject: fmt.Sprintf("Code at risk in swarm %s", swarmID),
|
||||
Body: fmt.Sprintf(`Landing blocked for swarm %s.
|
||||
|
||||
The following workers have uncommitted or unpushed code:
|
||||
%s
|
||||
|
||||
Manual intervention required.`,
|
||||
swarmID, strings.Join(workers, "\n- ")),
|
||||
)
|
||||
msg.Priority = mail.PriorityHigh
|
||||
Priority: mail.PriorityHigh,
|
||||
}
|
||||
router.Send(msg)
|
||||
}
|
||||
|
||||
// notifyMayorLanded sends a landing report to Mayor.
|
||||
func (m *Manager) notifyMayorLanded(townRoot string, swarm *Swarm, result *LandingResult) {
|
||||
router := mail.NewRouter(townRoot)
|
||||
msg := mail.NewMessage(
|
||||
fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
"mayor/",
|
||||
fmt.Sprintf("✓ Swarm %s landed", swarm.ID),
|
||||
fmt.Sprintf(`Swarm landing complete.
|
||||
router := mail.NewRouter(m.workDir)
|
||||
msg := &mail.Message{
|
||||
From: fmt.Sprintf("%s/refinery", m.rig.Name),
|
||||
To: "mayor/",
|
||||
Subject: fmt.Sprintf("Swarm %s landed", swarm.ID),
|
||||
Body: fmt.Sprintf(`Swarm landing complete.
|
||||
|
||||
Swarm: %s
|
||||
Target: %s
|
||||
@@ -232,6 +232,6 @@ Tasks merged: %d`,
|
||||
result.SessionsStopped,
|
||||
result.BranchesCleaned,
|
||||
len(swarm.Tasks)),
|
||||
)
|
||||
}
|
||||
router.Send(msg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user