Implement claiming for queue messages using beads-native approach: - Add claim_pattern field to QueueFields for eligibility checking - Add MatchClaimPattern function for pattern matching (wildcards supported) - Add FindEligibleQueues to find all queues an agent can claim from - Rewrite runMailClaim to use beads-native queue lookup - Support optional queue argument (claim from any eligible if not specified) - Use claimed-by/claimed-at labels instead of changing assignee - Update runMailRelease to work with new claiming approach - Add comprehensive tests for pattern matching and validation Queue messages are now claimed via labels: - claimed-by: <agent-identity> - claimed-at: <RFC3339 timestamp> Messages with queue:<name> label but no claimed-by are unclaimed. Closes gt-xfqh1e.11 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
299 lines
7.6 KiB
Go
299 lines
7.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
)
|
|
|
|
// TestClaimPatternMatching tests claim pattern matching via the beads package.
|
|
// This verifies that the pattern matching used for queue eligibility works correctly.
|
|
func TestClaimPatternMatching(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
caller string
|
|
want bool
|
|
}{
|
|
// Exact matches
|
|
{
|
|
name: "exact match",
|
|
pattern: "gastown/polecats/capable",
|
|
caller: "gastown/polecats/capable",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "exact match with different name",
|
|
pattern: "gastown/polecats/toast",
|
|
caller: "gastown/polecats/capable",
|
|
want: false,
|
|
},
|
|
|
|
// Wildcard at end
|
|
{
|
|
name: "wildcard matches polecat",
|
|
pattern: "gastown/polecats/*",
|
|
caller: "gastown/polecats/capable",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "wildcard matches different polecat",
|
|
pattern: "gastown/polecats/*",
|
|
caller: "gastown/polecats/toast",
|
|
want: true,
|
|
},
|
|
{
|
|
name: "wildcard doesn't match wrong rig",
|
|
pattern: "gastown/polecats/*",
|
|
caller: "beads/polecats/capable",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "wildcard doesn't match nested path",
|
|
pattern: "gastown/polecats/*",
|
|
caller: "gastown/polecats/sub/capable",
|
|
want: false,
|
|
},
|
|
|
|
// Universal wildcard
|
|
{
|
|
name: "universal wildcard matches anything",
|
|
pattern: "*",
|
|
caller: "anything",
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := beads.MatchClaimPattern(tt.pattern, tt.caller)
|
|
if got != tt.want {
|
|
t.Errorf("MatchClaimPattern(%q, %q) = %v, want %v",
|
|
tt.pattern, tt.caller, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQueueMessageReleaseValidation tests the validation logic for the release command.
|
|
// This tests that release correctly identifies:
|
|
// - Messages not claimed (no claimed-by label)
|
|
// - Messages claimed by a different worker
|
|
// - Messages without queue labels (non-queue messages)
|
|
func TestQueueMessageReleaseValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msgInfo *queueMessageInfo
|
|
caller string
|
|
wantErr bool
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "caller matches claimed-by - valid release",
|
|
msgInfo: &queueMessageInfo{
|
|
ID: "hq-test1",
|
|
Title: "Test Message",
|
|
ClaimedBy: "gastown/polecats/nux",
|
|
QueueName: "work-requests",
|
|
Status: "open",
|
|
},
|
|
caller: "gastown/polecats/nux",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "message not claimed",
|
|
msgInfo: &queueMessageInfo{
|
|
ID: "hq-test2",
|
|
Title: "Test Message",
|
|
ClaimedBy: "", // Not claimed
|
|
QueueName: "work-requests",
|
|
Status: "open",
|
|
},
|
|
caller: "gastown/polecats/nux",
|
|
wantErr: true,
|
|
errContains: "not claimed",
|
|
},
|
|
{
|
|
name: "claimed by different worker",
|
|
msgInfo: &queueMessageInfo{
|
|
ID: "hq-test3",
|
|
Title: "Test Message",
|
|
ClaimedBy: "gastown/polecats/other",
|
|
QueueName: "work-requests",
|
|
Status: "open",
|
|
},
|
|
caller: "gastown/polecats/nux",
|
|
wantErr: true,
|
|
errContains: "was claimed by",
|
|
},
|
|
{
|
|
name: "not a queue message",
|
|
msgInfo: &queueMessageInfo{
|
|
ID: "hq-test4",
|
|
Title: "Test Message",
|
|
ClaimedBy: "gastown/polecats/nux",
|
|
QueueName: "", // No queue label
|
|
Status: "open",
|
|
},
|
|
caller: "gastown/polecats/nux",
|
|
wantErr: true,
|
|
errContains: "not a queue message",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateQueueRelease(tt.msgInfo, tt.caller)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
return
|
|
}
|
|
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
|
t.Errorf("error %q should contain %q", err.Error(), tt.errContains)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// validateQueueRelease checks if a queue message can be released by the caller.
|
|
// This mirrors the validation logic in runMailRelease.
|
|
func validateQueueRelease(msgInfo *queueMessageInfo, caller string) error {
|
|
// Verify message is a queue message
|
|
if msgInfo.QueueName == "" {
|
|
return fmt.Errorf("message %s is not a queue message (no queue label)", msgInfo.ID)
|
|
}
|
|
|
|
// Verify message is claimed
|
|
if msgInfo.ClaimedBy == "" {
|
|
return fmt.Errorf("message %s is not claimed", msgInfo.ID)
|
|
}
|
|
|
|
// Verify caller is the one who claimed it
|
|
if msgInfo.ClaimedBy != caller {
|
|
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.ClaimedBy, caller)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestMailAnnounces tests the announces command functionality.
|
|
func TestMailAnnounces(t *testing.T) {
|
|
t.Run("listAnnounceChannels with nil config", func(t *testing.T) {
|
|
// Test with nil announces map
|
|
cfg := &config.MessagingConfig{
|
|
Announces: nil,
|
|
}
|
|
|
|
// Reset flag to default
|
|
mailAnnouncesJSON = false
|
|
|
|
// This should not panic and should handle nil gracefully
|
|
// We can't easily capture stdout in unit tests, but we can verify no panic
|
|
err := listAnnounceChannels(cfg)
|
|
if err != nil {
|
|
t.Errorf("listAnnounceChannels with nil announces should not error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("listAnnounceChannels with empty config", func(t *testing.T) {
|
|
cfg := &config.MessagingConfig{
|
|
Announces: make(map[string]config.AnnounceConfig),
|
|
}
|
|
|
|
mailAnnouncesJSON = false
|
|
err := listAnnounceChannels(cfg)
|
|
if err != nil {
|
|
t.Errorf("listAnnounceChannels with empty announces should not error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("readAnnounceChannel validates channel exists", func(t *testing.T) {
|
|
cfg := &config.MessagingConfig{
|
|
Announces: map[string]config.AnnounceConfig{
|
|
"alerts": {
|
|
Readers: []string{"@town"},
|
|
RetainCount: 100,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Test with unknown channel
|
|
err := readAnnounceChannel("/tmp", cfg, "nonexistent")
|
|
if err == nil {
|
|
t.Error("readAnnounceChannel should error for unknown channel")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown announce channel") {
|
|
t.Errorf("error should mention 'unknown announce channel', got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("readAnnounceChannel errors on nil announces", func(t *testing.T) {
|
|
cfg := &config.MessagingConfig{
|
|
Announces: nil,
|
|
}
|
|
|
|
err := readAnnounceChannel("/tmp", cfg, "alerts")
|
|
if err == nil {
|
|
t.Error("readAnnounceChannel should error for nil announces")
|
|
}
|
|
if !strings.Contains(err.Error(), "no announce channels configured") {
|
|
t.Errorf("error should mention 'no announce channels configured', got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestAnnounceMessageParsing tests parsing of announce messages from beads output.
|
|
func TestAnnounceMessageParsing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
labels []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "extracts from label",
|
|
labels: []string{"from:mayor/", "announce_channel:alerts"},
|
|
want: "mayor/",
|
|
},
|
|
{
|
|
name: "extracts from with rig path",
|
|
labels: []string{"announce_channel:alerts", "from:gastown/witness"},
|
|
want: "gastown/witness",
|
|
},
|
|
{
|
|
name: "no from label",
|
|
labels: []string{"announce_channel:alerts"},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty labels",
|
|
labels: []string{},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Simulate the label extraction logic from listAnnounceMessages
|
|
var from string
|
|
for _, label := range tt.labels {
|
|
if strings.HasPrefix(label, "from:") {
|
|
from = strings.TrimPrefix(label, "from:")
|
|
break
|
|
}
|
|
}
|
|
if from != tt.want {
|
|
t.Errorf("extracting from label: got %q, want %q", from, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|