Files
gastown/internal/protocol/messages.go
Steve Yegge 96ffbf0188 Implement Witness-Refinery protocol handlers (gt-m5w4g.2)
Add internal/protocol/ package with handlers for:
- MERGE_READY (witness→refinery): worker done, branch ready
- MERGED (refinery→witness): merge succeeded, cleanup ok
- MERGE_FAILED (refinery→witness): merge failed, needs rework
- REWORK_REQUEST (refinery→witness): rebase needed due to conflicts

Package structure:
- types.go: Protocol message types and payload structs
- messages.go: Message builders and body parsers
- handlers.go: Handler interface and registry for dispatch
- witness_handlers.go: DefaultWitnessHandler implementation
- refinery_handlers.go: DefaultRefineryHandler implementation
- protocol_test.go: Comprehensive test coverage

Also updated docs/mail-protocol.md with:
- MERGE_FAILED and REWORK_REQUEST message type documentation
- Merge failure and rebase required flow diagrams
- Reference to internal/protocol/ package

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 10:48:00 -08:00

289 lines
8.5 KiB
Go

package protocol
import (
"fmt"
"strings"
"time"
"github.com/steveyegge/gastown/internal/mail"
)
// NewMergeReadyMessage creates a MERGE_READY protocol message.
// Sent by Witness to Refinery when a polecat's work is verified and ready.
func NewMergeReadyMessage(rig, polecat, branch, issue string) *mail.Message {
payload := MergeReadyPayload{
Branch: branch,
Issue: issue,
Polecat: polecat,
Rig: rig,
Verified: "clean git state, issue closed",
Timestamp: time.Now(),
}
body := formatMergeReadyBody(payload)
msg := mail.NewMessage(
fmt.Sprintf("%s/witness", rig),
fmt.Sprintf("%s/refinery", rig),
fmt.Sprintf("MERGE_READY %s", polecat),
body,
)
msg.Priority = mail.PriorityHigh
msg.Type = mail.TypeTask
return msg
}
// formatMergeReadyBody formats the body of a MERGE_READY message.
func formatMergeReadyBody(p MergeReadyPayload) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
if p.Verified != "" {
sb.WriteString(fmt.Sprintf("Verified: %s\n", p.Verified))
}
return sb.String()
}
// NewMergedMessage creates a MERGED protocol message.
// Sent by Refinery to Witness when a branch is successfully merged.
func NewMergedMessage(rig, polecat, branch, issue, targetBranch, mergeCommit string) *mail.Message {
payload := MergedPayload{
Branch: branch,
Issue: issue,
Polecat: polecat,
Rig: rig,
MergedAt: time.Now(),
MergeCommit: mergeCommit,
TargetBranch: targetBranch,
}
body := formatMergedBody(payload)
msg := mail.NewMessage(
fmt.Sprintf("%s/refinery", rig),
fmt.Sprintf("%s/witness", rig),
fmt.Sprintf("MERGED %s", polecat),
body,
)
msg.Priority = mail.PriorityHigh
msg.Type = mail.TypeNotification
return msg
}
// formatMergedBody formats the body of a MERGED message.
func formatMergedBody(p MergedPayload) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
sb.WriteString(fmt.Sprintf("Merged-At: %s\n", p.MergedAt.Format(time.RFC3339)))
if p.MergeCommit != "" {
sb.WriteString(fmt.Sprintf("Merge-Commit: %s\n", p.MergeCommit))
}
return sb.String()
}
// NewMergeFailedMessage creates a MERGE_FAILED protocol message.
// Sent by Refinery to Witness when merge fails (tests, build, etc.).
func NewMergeFailedMessage(rig, polecat, branch, issue, targetBranch, failureType, errorMsg string) *mail.Message {
payload := MergeFailedPayload{
Branch: branch,
Issue: issue,
Polecat: polecat,
Rig: rig,
FailedAt: time.Now(),
FailureType: failureType,
Error: errorMsg,
TargetBranch: targetBranch,
}
body := formatMergeFailedBody(payload)
msg := mail.NewMessage(
fmt.Sprintf("%s/refinery", rig),
fmt.Sprintf("%s/witness", rig),
fmt.Sprintf("MERGE_FAILED %s", polecat),
body,
)
msg.Priority = mail.PriorityHigh
msg.Type = mail.TypeTask
return msg
}
// formatMergeFailedBody formats the body of a MERGE_FAILED message.
func formatMergeFailedBody(p MergeFailedPayload) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
sb.WriteString(fmt.Sprintf("Failed-At: %s\n", p.FailedAt.Format(time.RFC3339)))
sb.WriteString(fmt.Sprintf("Failure-Type: %s\n", p.FailureType))
sb.WriteString(fmt.Sprintf("Error: %s\n", p.Error))
return sb.String()
}
// NewReworkRequestMessage creates a REWORK_REQUEST protocol message.
// Sent by Refinery to Witness when a branch needs rebasing due to conflicts.
func NewReworkRequestMessage(rig, polecat, branch, issue, targetBranch string, conflictFiles []string) *mail.Message {
payload := ReworkRequestPayload{
Branch: branch,
Issue: issue,
Polecat: polecat,
Rig: rig,
RequestedAt: time.Now(),
TargetBranch: targetBranch,
ConflictFiles: conflictFiles,
Instructions: formatRebaseInstructions(targetBranch),
}
body := formatReworkRequestBody(payload)
msg := mail.NewMessage(
fmt.Sprintf("%s/refinery", rig),
fmt.Sprintf("%s/witness", rig),
fmt.Sprintf("REWORK_REQUEST %s", polecat),
body,
)
msg.Priority = mail.PriorityHigh
msg.Type = mail.TypeTask
return msg
}
// formatReworkRequestBody formats the body of a REWORK_REQUEST message.
func formatReworkRequestBody(p ReworkRequestPayload) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Branch: %s\n", p.Branch))
sb.WriteString(fmt.Sprintf("Issue: %s\n", p.Issue))
sb.WriteString(fmt.Sprintf("Polecat: %s\n", p.Polecat))
sb.WriteString(fmt.Sprintf("Rig: %s\n", p.Rig))
sb.WriteString(fmt.Sprintf("Target: %s\n", p.TargetBranch))
sb.WriteString(fmt.Sprintf("Requested-At: %s\n", p.RequestedAt.Format(time.RFC3339)))
if len(p.ConflictFiles) > 0 {
sb.WriteString(fmt.Sprintf("Conflict-Files: %s\n", strings.Join(p.ConflictFiles, ", ")))
}
sb.WriteString("\n")
sb.WriteString(p.Instructions)
return sb.String()
}
// formatRebaseInstructions returns standard rebase instructions.
func formatRebaseInstructions(targetBranch string) string {
return fmt.Sprintf(`Please rebase your changes onto %s:
git fetch origin
git rebase origin/%s
# Resolve any conflicts
git push -f
The Refinery will retry the merge after rebase is complete.`, targetBranch, targetBranch)
}
// ParseMergeReadyPayload parses a MERGE_READY message body into a payload.
func ParseMergeReadyPayload(body string) *MergeReadyPayload {
return &MergeReadyPayload{
Branch: parseField(body, "Branch"),
Issue: parseField(body, "Issue"),
Polecat: parseField(body, "Polecat"),
Rig: parseField(body, "Rig"),
Verified: parseField(body, "Verified"),
Timestamp: time.Now(), // Use current time if not parseable
}
}
// ParseMergedPayload parses a MERGED message body into a payload.
func ParseMergedPayload(body string) *MergedPayload {
payload := &MergedPayload{
Branch: parseField(body, "Branch"),
Issue: parseField(body, "Issue"),
Polecat: parseField(body, "Polecat"),
Rig: parseField(body, "Rig"),
TargetBranch: parseField(body, "Target"),
MergeCommit: parseField(body, "Merge-Commit"),
}
// Parse timestamp
if ts := parseField(body, "Merged-At"); ts != "" {
if t, err := time.Parse(time.RFC3339, ts); err == nil {
payload.MergedAt = t
}
}
return payload
}
// ParseMergeFailedPayload parses a MERGE_FAILED message body into a payload.
func ParseMergeFailedPayload(body string) *MergeFailedPayload {
payload := &MergeFailedPayload{
Branch: parseField(body, "Branch"),
Issue: parseField(body, "Issue"),
Polecat: parseField(body, "Polecat"),
Rig: parseField(body, "Rig"),
TargetBranch: parseField(body, "Target"),
FailureType: parseField(body, "Failure-Type"),
Error: parseField(body, "Error"),
}
// Parse timestamp
if ts := parseField(body, "Failed-At"); ts != "" {
if t, err := time.Parse(time.RFC3339, ts); err == nil {
payload.FailedAt = t
}
}
return payload
}
// ParseReworkRequestPayload parses a REWORK_REQUEST message body into a payload.
func ParseReworkRequestPayload(body string) *ReworkRequestPayload {
payload := &ReworkRequestPayload{
Branch: parseField(body, "Branch"),
Issue: parseField(body, "Issue"),
Polecat: parseField(body, "Polecat"),
Rig: parseField(body, "Rig"),
TargetBranch: parseField(body, "Target"),
}
// Parse timestamp
if ts := parseField(body, "Requested-At"); ts != "" {
if t, err := time.Parse(time.RFC3339, ts); err == nil {
payload.RequestedAt = t
}
}
// Parse conflict files
if files := parseField(body, "Conflict-Files"); files != "" {
payload.ConflictFiles = strings.Split(files, ", ")
}
return payload
}
// parseField extracts a field value from a key-value body format.
// Format: "Key: value"
func parseField(body, key string) string {
lines := strings.Split(body, "\n")
prefix := key + ": "
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimPrefix(line, prefix)
}
}
return ""
}