From 59921d52c84dee893905d638723421acf6ec1942 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 21:45:42 -0800 Subject: [PATCH] feat: refactor mail system to use bd mail backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/cmd/mail.go | 120 ++++++++++--- internal/mail/mailbox.go | 331 ++++++++++++++++++++++++++--------- internal/mail/router.go | 100 ++++------- internal/mail/types.go | 94 +++++++++- internal/refinery/manager.go | 41 ++--- internal/swarm/landing.go | 30 ++-- 6 files changed, 503 insertions(+), 213 deletions(-) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 1fc6568d..26525e18 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -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 ", + 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") diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index ec4f91ac..14dd7b77 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -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) diff --git a/internal/mail/router.go b/internal/mail/router.go index b6c9ccf8..08268ada 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -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 -s -m --identity + 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/ → /mayor/mail/inbox.jsonl -// - /refinery → //refinery/mail/inbox.jsonl -// - ///polecats//mail/inbox.jsonl -// - / → //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. diff --git a/internal/mail/types.go b/internal/mail/types.go index af15fa20..e602d072 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -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 + "/" +} diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index ae3127e9..77387651 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -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) } diff --git a/internal/swarm/landing.go b/internal/swarm/landing.go index e02b7cbe..ff46443d 100644 --- a/internal/swarm/landing.go +++ b/internal/swarm/landing.go @@ -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) }