feat(mail): implement beads-native gt mail claim command
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>
This commit is contained in:
committed by
Steve Yegge
parent
012d50b2b2
commit
2ffc8e8712
@@ -5,10 +5,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
func TestMatchWorkerPattern(t *testing.T) {
|
||||
// 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
|
||||
@@ -55,43 +58,9 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Crew patterns
|
||||
// Universal wildcard
|
||||
{
|
||||
name: "crew wildcard matches",
|
||||
pattern: "gastown/crew/*",
|
||||
caller: "gastown/crew/max",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "crew wildcard doesn't match polecats",
|
||||
pattern: "gastown/crew/*",
|
||||
caller: "gastown/polecats/capable",
|
||||
want: false,
|
||||
},
|
||||
|
||||
// Different rigs
|
||||
{
|
||||
name: "different rig wildcard",
|
||||
pattern: "beads/polecats/*",
|
||||
caller: "beads/polecats/capable",
|
||||
want: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty pattern",
|
||||
pattern: "",
|
||||
caller: "gastown/polecats/capable",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty caller",
|
||||
pattern: "gastown/polecats/*",
|
||||
caller: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "pattern is just wildcard",
|
||||
name: "universal wildcard matches anything",
|
||||
pattern: "*",
|
||||
caller: "anything",
|
||||
want: true,
|
||||
@@ -100,103 +69,47 @@ func TestMatchWorkerPattern(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchWorkerPattern(tt.pattern, tt.caller)
|
||||
got := beads.MatchClaimPattern(tt.pattern, tt.caller)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchWorkerPattern(%q, %q) = %v, want %v",
|
||||
t.Errorf("MatchClaimPattern(%q, %q) = %v, want %v",
|
||||
tt.pattern, tt.caller, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEligibleWorker(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
caller string
|
||||
patterns []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matches first pattern",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches second pattern",
|
||||
caller: "gastown/crew/max",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches none",
|
||||
caller: "beads/polecats/capable",
|
||||
patterns: []string{"gastown/polecats/*", "gastown/crew/*"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty patterns list",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: []string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil patterns",
|
||||
caller: "gastown/polecats/capable",
|
||||
patterns: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exact match in list",
|
||||
caller: "mayor/",
|
||||
patterns: []string{"mayor/", "gastown/witness"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isEligibleWorker(tt.caller, tt.patterns)
|
||||
if got != tt.want {
|
||||
t.Errorf("isEligibleWorker(%q, %v) = %v, want %v",
|
||||
tt.caller, tt.patterns, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReleaseValidation tests the validation logic for the release command.
|
||||
// TestQueueMessageReleaseValidation tests the validation logic for the release command.
|
||||
// This tests that release correctly identifies:
|
||||
// - Messages not claimed (still in queue)
|
||||
// - Messages not claimed (no claimed-by label)
|
||||
// - Messages claimed by a different worker
|
||||
// - Messages without queue labels (non-queue messages)
|
||||
func TestMailReleaseValidation(t *testing.T) {
|
||||
func TestQueueMessageReleaseValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msgInfo *messageInfo
|
||||
msgInfo *queueMessageInfo
|
||||
caller string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "caller matches assignee - valid release",
|
||||
msgInfo: &messageInfo{
|
||||
name: "caller matches claimed-by - valid release",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test1",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "message still in queue - not claimed",
|
||||
msgInfo: &messageInfo{
|
||||
name: "message not claimed",
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test2",
|
||||
Title: "Test Message",
|
||||
Assignee: "queue:work/gastown",
|
||||
QueueName: "work/gastown",
|
||||
ClaimedBy: "", // Not claimed
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
@@ -205,12 +118,12 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "claimed by different worker",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test3",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/other",
|
||||
QueueName: "work/gastown",
|
||||
Status: "in_progress",
|
||||
ClaimedBy: "gastown/polecats/other",
|
||||
QueueName: "work-requests",
|
||||
Status: "open",
|
||||
},
|
||||
caller: "gastown/polecats/nux",
|
||||
wantErr: true,
|
||||
@@ -218,10 +131,10 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "not a queue message",
|
||||
msgInfo: &messageInfo{
|
||||
msgInfo: &queueMessageInfo{
|
||||
ID: "hq-test4",
|
||||
Title: "Test Message",
|
||||
Assignee: "gastown/polecats/nux",
|
||||
ClaimedBy: "gastown/polecats/nux",
|
||||
QueueName: "", // No queue label
|
||||
Status: "open",
|
||||
},
|
||||
@@ -233,7 +146,7 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRelease(tt.msgInfo, tt.caller)
|
||||
err := validateQueueRelease(tt.msgInfo, tt.caller)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
@@ -251,20 +164,22 @@ func TestMailReleaseValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateRelease checks if a message can be released by the caller.
|
||||
// This is extracted for testing; the actual release command uses this logic inline.
|
||||
func validateRelease(msgInfo *messageInfo, caller string) error {
|
||||
// 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.Assignee != caller {
|
||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
||||
return fmt.Errorf("message %s is not claimed (still in queue)", msgInfo.ID)
|
||||
}
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.Assignee, caller)
|
||||
if msgInfo.ClaimedBy != caller {
|
||||
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.ClaimedBy, caller)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user