Apply PR #76 from dannomayernotabot: - Add golangci exclusions for internal package false positives - Tighten file permissions (0644 -> 0600) for sensitive files - Add ReadHeaderTimeout to HTTP server (slowloris prevention) - Explicit error ignoring with _ = for intentional cases - Add //nolint comments with justifications - Spelling: cancelled -> canceled (US locale) Co-Authored-By: dannomayernotabot <noreply@github.com> 🤖 Generated with Claude Code
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/mail"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/townlog"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Callback message subject patterns for routing.
|
|
var (
|
|
// POLECAT_DONE <name> - polecat signaled completion
|
|
patternPolecatDone = regexp.MustCompile(`^POLECAT_DONE\s+(\S+)`)
|
|
|
|
// Merge Request Rejected: <branch> - refinery rejected MR
|
|
patternMergeRejected = regexp.MustCompile(`^Merge Request Rejected:\s+(.+)`)
|
|
|
|
// Merge Request Completed: <branch> - refinery completed MR
|
|
patternMergeCompleted = regexp.MustCompile(`^Merge Request Completed:\s+(.+)`)
|
|
|
|
// HELP: <topic> - polecat requesting help
|
|
patternHelp = regexp.MustCompile(`^HELP:\s+(.+)`)
|
|
|
|
// ESCALATION: <topic> - witness escalating issue
|
|
patternEscalation = regexp.MustCompile(`^ESCALATION:\s+(.+)`)
|
|
|
|
// SLING_REQUEST: <bead-id> - request to sling work
|
|
patternSling = regexp.MustCompile(`^SLING_REQUEST:\s+(\S+)`)
|
|
|
|
// NOTE: WITNESS_REPORT and REFINERY_REPORT removed.
|
|
// Witnesses and Refineries handle their duties autonomously.
|
|
// They only escalate genuine problems, not routine status updates.
|
|
)
|
|
|
|
// CallbackType identifies the type of callback message.
|
|
type CallbackType string
|
|
|
|
const (
|
|
CallbackPolecatDone CallbackType = "polecat_done"
|
|
CallbackMergeRejected CallbackType = "merge_rejected"
|
|
CallbackMergeCompleted CallbackType = "merge_completed"
|
|
CallbackHelp CallbackType = "help"
|
|
CallbackEscalation CallbackType = "escalation"
|
|
CallbackSling CallbackType = "sling"
|
|
CallbackUnknown CallbackType = "unknown"
|
|
// NOTE: CallbackWitnessReport and CallbackRefineryReport removed.
|
|
// Routine status reports are no longer sent to Mayor.
|
|
)
|
|
|
|
// CallbackResult tracks the result of processing a callback.
|
|
type CallbackResult struct {
|
|
MessageID string
|
|
CallbackType CallbackType
|
|
From string
|
|
Subject string
|
|
Handled bool
|
|
Action string
|
|
Error error
|
|
}
|
|
|
|
var callbacksCmd = &cobra.Command{
|
|
Use: "callbacks",
|
|
GroupID: GroupAgents,
|
|
Short: "Handle agent callbacks",
|
|
Long: `Handle callbacks from agents during Deacon patrol.
|
|
|
|
Callbacks are messages sent to the Mayor from:
|
|
- Witnesses reporting polecat status
|
|
- Refineries reporting merge results
|
|
- Polecats requesting help or escalation
|
|
- External triggers (webhooks, timers)
|
|
|
|
This command processes the Mayor's inbox and handles each message
|
|
appropriately, routing to other agents or updating state as needed.`,
|
|
}
|
|
|
|
var callbacksProcessCmd = &cobra.Command{
|
|
Use: "process",
|
|
Short: "Process pending callbacks",
|
|
Long: `Process all pending callbacks in the Mayor's inbox.
|
|
|
|
Reads unread messages from the Mayor's inbox and handles each based on
|
|
its type:
|
|
|
|
POLECAT_DONE - Log completion, update stats
|
|
MERGE_COMPLETED - Notify worker, close source issue
|
|
MERGE_REJECTED - Notify worker of rejection reason
|
|
HELP: - Route to human or handle if possible
|
|
ESCALATION: - Log and route to human
|
|
SLING_REQUEST: - Spawn polecat for the work
|
|
|
|
Note: Witnesses and Refineries handle routine operations autonomously.
|
|
They only send escalations for genuine problems, not status reports.
|
|
|
|
Unknown message types are logged but left unprocessed.`,
|
|
RunE: runCallbacksProcess,
|
|
}
|
|
|
|
var (
|
|
callbacksDryRun bool
|
|
callbacksVerbose bool
|
|
)
|
|
|
|
func init() {
|
|
callbacksProcessCmd.Flags().BoolVar(&callbacksDryRun, "dry-run", false, "Show what would be processed without taking action")
|
|
callbacksProcessCmd.Flags().BoolVarP(&callbacksVerbose, "verbose", "v", false, "Show detailed processing info")
|
|
|
|
callbacksCmd.AddCommand(callbacksProcessCmd)
|
|
rootCmd.AddCommand(callbacksCmd)
|
|
}
|
|
|
|
func runCallbacksProcess(cmd *cobra.Command, args []string) error {
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
// Get Mayor's mailbox
|
|
router := mail.NewRouter(townRoot)
|
|
mailbox, err := router.GetMailbox("mayor/")
|
|
if err != nil {
|
|
return fmt.Errorf("getting mayor mailbox: %w", err)
|
|
}
|
|
|
|
// Get unread messages
|
|
messages, err := mailbox.ListUnread()
|
|
if err != nil {
|
|
return fmt.Errorf("listing unread messages: %w", err)
|
|
}
|
|
|
|
if len(messages) == 0 {
|
|
fmt.Printf("%s No pending callbacks\n", style.Dim.Render("○"))
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%s Processing %d callback(s)\n", style.Bold.Render("●"), len(messages))
|
|
|
|
var results []CallbackResult
|
|
for _, msg := range messages {
|
|
result := processCallback(townRoot, msg, callbacksDryRun)
|
|
results = append(results, result)
|
|
|
|
// Print result
|
|
if result.Error != nil {
|
|
fmt.Printf(" %s %s: %v\n",
|
|
style.Error.Render("✗"),
|
|
msg.Subject,
|
|
result.Error)
|
|
} else if result.Handled {
|
|
fmt.Printf(" %s [%s] %s\n",
|
|
style.Bold.Render("✓"),
|
|
result.CallbackType,
|
|
result.Action)
|
|
} else {
|
|
fmt.Printf(" %s [%s] %s\n",
|
|
style.Dim.Render("○"),
|
|
result.CallbackType,
|
|
result.Action)
|
|
}
|
|
|
|
if callbacksVerbose {
|
|
fmt.Printf(" From: %s\n", msg.From)
|
|
fmt.Printf(" Subject: %s\n", msg.Subject)
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
handled := 0
|
|
errors := 0
|
|
for _, r := range results {
|
|
if r.Handled {
|
|
handled++
|
|
}
|
|
if r.Error != nil {
|
|
errors++
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
if callbacksDryRun {
|
|
fmt.Printf("%s Dry run: would process %d/%d callbacks\n",
|
|
style.Dim.Render("○"), handled, len(results))
|
|
} else {
|
|
fmt.Printf("%s Processed %d/%d callbacks",
|
|
style.Bold.Render("✓"), handled, len(results))
|
|
if errors > 0 {
|
|
fmt.Printf(" (%d errors)", errors)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processCallback handles a single callback message and returns the result.
|
|
func processCallback(townRoot string, msg *mail.Message, dryRun bool) CallbackResult {
|
|
result := CallbackResult{
|
|
MessageID: msg.ID,
|
|
From: msg.From,
|
|
Subject: msg.Subject,
|
|
}
|
|
|
|
// Classify the callback
|
|
result.CallbackType = classifyCallback(msg.Subject)
|
|
|
|
// Handle based on type
|
|
switch result.CallbackType {
|
|
case CallbackPolecatDone:
|
|
result.Action, result.Error = handlePolecatDone(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
case CallbackMergeCompleted:
|
|
result.Action, result.Error = handleMergeCompleted(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
case CallbackMergeRejected:
|
|
result.Action, result.Error = handleMergeRejected(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
case CallbackHelp:
|
|
result.Action, result.Error = handleHelp(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
case CallbackEscalation:
|
|
result.Action, result.Error = handleEscalation(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
case CallbackSling:
|
|
result.Action, result.Error = handleSling(townRoot, msg, dryRun)
|
|
result.Handled = result.Error == nil
|
|
|
|
default:
|
|
result.Action = "unknown message type, skipped"
|
|
result.Handled = false
|
|
}
|
|
|
|
// Archive handled messages (unless dry-run)
|
|
if result.Handled && !dryRun {
|
|
router := mail.NewRouter(townRoot)
|
|
if mailbox, err := router.GetMailbox("mayor/"); err == nil {
|
|
_ = mailbox.Delete(msg.ID)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// classifyCallback determines the type of callback from the subject line.
|
|
func classifyCallback(subject string) CallbackType {
|
|
switch {
|
|
case patternPolecatDone.MatchString(subject):
|
|
return CallbackPolecatDone
|
|
case patternMergeRejected.MatchString(subject):
|
|
return CallbackMergeRejected
|
|
case patternMergeCompleted.MatchString(subject):
|
|
return CallbackMergeCompleted
|
|
case patternHelp.MatchString(subject):
|
|
return CallbackHelp
|
|
case patternEscalation.MatchString(subject):
|
|
return CallbackEscalation
|
|
case patternSling.MatchString(subject):
|
|
return CallbackSling
|
|
default:
|
|
return CallbackUnknown
|
|
}
|
|
}
|
|
|
|
// handlePolecatDone processes a POLECAT_DONE callback.
|
|
// These come from Witnesses forwarding polecat completion notices.
|
|
func handlePolecatDone(townRoot string, msg *mail.Message, dryRun bool) (string, error) { //nolint:unparam // error return kept for consistency with callback interface
|
|
matches := patternPolecatDone.FindStringSubmatch(msg.Subject)
|
|
polecatName := ""
|
|
if len(matches) > 1 {
|
|
polecatName = matches[1]
|
|
}
|
|
|
|
// Extract info from body
|
|
var exitType, issueID string
|
|
for _, line := range strings.Split(msg.Body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Exit:") {
|
|
exitType = strings.TrimSpace(strings.TrimPrefix(line, "Exit:"))
|
|
}
|
|
if strings.HasPrefix(line, "Issue:") {
|
|
issueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:"))
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would log completion for %s (exit=%s, issue=%s)",
|
|
polecatName, exitType, issueID), nil
|
|
}
|
|
|
|
// Log the completion
|
|
logCallback(townRoot, fmt.Sprintf("polecat_done: %s completed with %s (issue: %s)",
|
|
msg.From, exitType, issueID))
|
|
|
|
return fmt.Sprintf("logged completion for %s", polecatName), nil
|
|
}
|
|
|
|
// handleMergeCompleted processes a merge completion callback from Refinery.
|
|
func handleMergeCompleted(townRoot string, msg *mail.Message, dryRun bool) (string, error) { //nolint:unparam // error return kept for consistency with callback interface
|
|
matches := patternMergeCompleted.FindStringSubmatch(msg.Subject)
|
|
branch := ""
|
|
if len(matches) > 1 {
|
|
branch = matches[1]
|
|
}
|
|
|
|
// Extract MR ID and source issue from body
|
|
var mrID, sourceIssue, mergeCommit string
|
|
for _, line := range strings.Split(msg.Body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "MR:") {
|
|
mrID = strings.TrimSpace(strings.TrimPrefix(line, "MR:"))
|
|
}
|
|
if strings.HasPrefix(line, "Source:") {
|
|
sourceIssue = strings.TrimSpace(strings.TrimPrefix(line, "Source:"))
|
|
}
|
|
if strings.HasPrefix(line, "Commit:") {
|
|
mergeCommit = strings.TrimSpace(strings.TrimPrefix(line, "Commit:"))
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would close source issue %s (mr=%s, commit=%s)",
|
|
sourceIssue, mrID, mergeCommit), nil
|
|
}
|
|
|
|
// Log the merge
|
|
logCallback(townRoot, fmt.Sprintf("merge_completed: branch %s merged (mr=%s, source=%s, commit=%s)",
|
|
branch, mrID, sourceIssue, mergeCommit))
|
|
|
|
// Close the source issue if we have it
|
|
if sourceIssue != "" {
|
|
cwd, _ := os.Getwd()
|
|
bd := beads.New(cwd)
|
|
reason := fmt.Sprintf("Merged in %s", mergeCommit)
|
|
if err := bd.Close(sourceIssue, reason); err != nil {
|
|
// Non-fatal: issue might already be closed or not exist
|
|
return fmt.Sprintf("logged merge for %s (could not close %s: %v)",
|
|
branch, sourceIssue, err), nil
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("logged merge for %s, closed %s", branch, sourceIssue), nil
|
|
}
|
|
|
|
// handleMergeRejected processes a merge rejection callback from Refinery.
|
|
func handleMergeRejected(townRoot string, msg *mail.Message, dryRun bool) (string, error) { //nolint:unparam // error return kept for consistency with callback interface
|
|
matches := patternMergeRejected.FindStringSubmatch(msg.Subject)
|
|
branch := ""
|
|
if len(matches) > 1 {
|
|
branch = matches[1]
|
|
}
|
|
|
|
// Extract reason from body
|
|
var reason string
|
|
if strings.Contains(msg.Body, "Reason:") {
|
|
parts := strings.SplitN(msg.Body, "Reason:", 2)
|
|
if len(parts) > 1 {
|
|
reason = strings.TrimSpace(parts[1])
|
|
// Take just the first line of the reason
|
|
if idx := strings.Index(reason, "\n"); idx > 0 {
|
|
reason = reason[:idx]
|
|
}
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would log rejection for %s (reason: %s)", branch, reason), nil
|
|
}
|
|
|
|
// Log the rejection
|
|
logCallback(townRoot, fmt.Sprintf("merge_rejected: branch %s rejected: %s", branch, reason))
|
|
|
|
return fmt.Sprintf("logged rejection for %s", branch), nil
|
|
}
|
|
|
|
// handleHelp processes a HELP: request from a polecat.
|
|
func handleHelp(townRoot string, msg *mail.Message, dryRun bool) (string, error) {
|
|
matches := patternHelp.FindStringSubmatch(msg.Subject)
|
|
topic := ""
|
|
if len(matches) > 1 {
|
|
topic = matches[1]
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would forward help request to overseer: %s", topic), nil
|
|
}
|
|
|
|
// Forward to overseer (human)
|
|
router := mail.NewRouter(townRoot)
|
|
fwd := &mail.Message{
|
|
From: "mayor/",
|
|
To: "overseer",
|
|
Subject: fmt.Sprintf("[FWD] HELP: %s", topic),
|
|
Body: fmt.Sprintf("Forwarded from: %s\n\n%s", msg.From, msg.Body),
|
|
Priority: mail.PriorityHigh,
|
|
}
|
|
if err := router.Send(fwd); err != nil {
|
|
return "", fmt.Errorf("forwarding to overseer: %w", err)
|
|
}
|
|
|
|
// Log the help request
|
|
logCallback(townRoot, fmt.Sprintf("help_request: from %s: %s", msg.From, topic))
|
|
|
|
return fmt.Sprintf("forwarded help request to overseer: %s", topic), nil
|
|
}
|
|
|
|
// handleEscalation processes an ESCALATION: from a Witness.
|
|
func handleEscalation(townRoot string, msg *mail.Message, dryRun bool) (string, error) {
|
|
matches := patternEscalation.FindStringSubmatch(msg.Subject)
|
|
topic := ""
|
|
if len(matches) > 1 {
|
|
topic = matches[1]
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would forward escalation to overseer: %s", topic), nil
|
|
}
|
|
|
|
// Forward to overseer with urgent priority
|
|
router := mail.NewRouter(townRoot)
|
|
fwd := &mail.Message{
|
|
From: "mayor/",
|
|
To: "overseer",
|
|
Subject: fmt.Sprintf("[ESCALATION] %s", topic),
|
|
Body: fmt.Sprintf("Escalated by: %s\n\n%s", msg.From, msg.Body),
|
|
Priority: mail.PriorityUrgent,
|
|
}
|
|
if err := router.Send(fwd); err != nil {
|
|
return "", fmt.Errorf("forwarding escalation: %w", err)
|
|
}
|
|
|
|
// Log the escalation
|
|
logCallback(townRoot, fmt.Sprintf("escalation: from %s: %s", msg.From, topic))
|
|
|
|
return fmt.Sprintf("forwarded escalation to overseer: %s", topic), nil
|
|
}
|
|
|
|
// handleSling processes a SLING_REQUEST to spawn work on a polecat.
|
|
func handleSling(townRoot string, msg *mail.Message, dryRun bool) (string, error) {
|
|
matches := patternSling.FindStringSubmatch(msg.Subject)
|
|
beadID := ""
|
|
if len(matches) > 1 {
|
|
beadID = matches[1]
|
|
}
|
|
|
|
// Extract rig from body
|
|
var targetRig string
|
|
for _, line := range strings.Split(msg.Body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Rig:") {
|
|
targetRig = strings.TrimSpace(strings.TrimPrefix(line, "Rig:"))
|
|
}
|
|
}
|
|
|
|
if targetRig == "" {
|
|
return "", fmt.Errorf("no target rig specified in sling request")
|
|
}
|
|
|
|
if dryRun {
|
|
return fmt.Sprintf("would sling %s to %s", beadID, targetRig), nil
|
|
}
|
|
|
|
// Log the sling (actual spawn happens via gt sling command)
|
|
logCallback(townRoot, fmt.Sprintf("sling_request: bead %s to rig %s", beadID, targetRig))
|
|
|
|
// Note: We don't actually spawn here - that would be done by the Deacon
|
|
// executing the sling command based on this request.
|
|
return fmt.Sprintf("logged sling request: %s to %s (execute with: gt sling %s %s)",
|
|
beadID, targetRig, beadID, targetRig), nil
|
|
}
|
|
|
|
// logCallback logs a callback processing event to the town log.
|
|
func logCallback(townRoot, context string) {
|
|
logger := townlog.NewLogger(townRoot)
|
|
_ = logger.Log(townlog.EventCallback, "mayor/", context)
|
|
}
|