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
@@ -14,6 +14,7 @@ import (
|
|||||||
// These are stored as "key: value" lines in the description.
|
// These are stored as "key: value" lines in the description.
|
||||||
type QueueFields struct {
|
type QueueFields struct {
|
||||||
Name string // Queue name (human-readable identifier)
|
Name string // Queue name (human-readable identifier)
|
||||||
|
ClaimPattern string // Pattern for who can claim from queue (e.g., "gastown/polecats/*")
|
||||||
Status string // active, paused, closed
|
Status string // active, paused, closed
|
||||||
MaxConcurrency int // Maximum number of concurrent workers (0 = unlimited)
|
MaxConcurrency int // Maximum number of concurrent workers (0 = unlimited)
|
||||||
ProcessingOrder string // fifo, priority (default: fifo)
|
ProcessingOrder string // fifo, priority (default: fifo)
|
||||||
@@ -52,6 +53,12 @@ func FormatQueueDescription(title string, fields *QueueFields) string {
|
|||||||
lines = append(lines, "name: null")
|
lines = append(lines, "name: null")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fields.ClaimPattern != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("claim_pattern: %s", fields.ClaimPattern))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "claim_pattern: *") // Default: anyone can claim
|
||||||
|
}
|
||||||
|
|
||||||
if fields.Status != "" {
|
if fields.Status != "" {
|
||||||
lines = append(lines, fmt.Sprintf("status: %s", fields.Status))
|
lines = append(lines, fmt.Sprintf("status: %s", fields.Status))
|
||||||
} else {
|
} else {
|
||||||
@@ -79,6 +86,7 @@ func ParseQueueFields(description string) *QueueFields {
|
|||||||
fields := &QueueFields{
|
fields := &QueueFields{
|
||||||
Status: QueueStatusActive,
|
Status: QueueStatusActive,
|
||||||
ProcessingOrder: QueueOrderFIFO,
|
ProcessingOrder: QueueOrderFIFO,
|
||||||
|
ClaimPattern: "*", // Default: anyone can claim
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range strings.Split(description, "\n") {
|
for _, line := range strings.Split(description, "\n") {
|
||||||
@@ -101,6 +109,10 @@ func ParseQueueFields(description string) *QueueFields {
|
|||||||
switch strings.ToLower(key) {
|
switch strings.ToLower(key) {
|
||||||
case "name":
|
case "name":
|
||||||
fields.Name = value
|
fields.Name = value
|
||||||
|
case "claim_pattern":
|
||||||
|
if value != "" {
|
||||||
|
fields.ClaimPattern = value
|
||||||
|
}
|
||||||
case "status":
|
case "status":
|
||||||
fields.Status = value
|
fields.Status = value
|
||||||
case "max_concurrency":
|
case "max_concurrency":
|
||||||
@@ -266,3 +278,72 @@ func (b *Beads) DeleteQueueBead(id string) error {
|
|||||||
_, err := b.run("delete", id, "--hard", "--force")
|
_, err := b.run("delete", id, "--hard", "--force")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchClaimPattern checks if an identity matches a claim pattern.
|
||||||
|
// Patterns support:
|
||||||
|
// - "*" matches anyone
|
||||||
|
// - "gastown/polecats/*" matches any polecat in gastown rig
|
||||||
|
// - "*/witness" matches any witness role across rigs
|
||||||
|
// - Exact match for specific identities
|
||||||
|
func MatchClaimPattern(pattern, identity string) bool {
|
||||||
|
// Wildcard matches anyone
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if pattern == identity {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard pattern matching
|
||||||
|
if strings.Contains(pattern, "*") {
|
||||||
|
// Convert to simple glob matching
|
||||||
|
// "gastown/polecats/*" should match "gastown/polecats/capable"
|
||||||
|
// "*/witness" should match "gastown/witness"
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
prefix := parts[0]
|
||||||
|
suffix := parts[1]
|
||||||
|
if strings.HasPrefix(identity, prefix) && strings.HasSuffix(identity, suffix) {
|
||||||
|
// Check that the middle part doesn't contain path separators
|
||||||
|
// unless the pattern allows it (e.g., "*/" at start)
|
||||||
|
middle := identity[len(prefix) : len(identity)-len(suffix)]
|
||||||
|
// Only allow single segment match (no extra slashes)
|
||||||
|
if !strings.Contains(middle, "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindEligibleQueues returns all queue beads that the given identity can claim from.
|
||||||
|
func (b *Beads) FindEligibleQueues(identity string) ([]*Issue, []*QueueFields, error) {
|
||||||
|
queues, err := b.ListQueueBeads()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var eligibleIssues []*Issue
|
||||||
|
var eligibleFields []*QueueFields
|
||||||
|
|
||||||
|
for _, issue := range queues {
|
||||||
|
fields := ParseQueueFields(issue.Description)
|
||||||
|
|
||||||
|
// Skip inactive queues
|
||||||
|
if fields.Status != QueueStatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if identity matches claim pattern
|
||||||
|
if MatchClaimPattern(fields.ClaimPattern, identity) {
|
||||||
|
eligibleIssues = append(eligibleIssues, issue)
|
||||||
|
eligibleFields = append(eligibleFields, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return eligibleIssues, eligibleFields, nil
|
||||||
|
}
|
||||||
|
|||||||
301
internal/beads/beads_queue_test.go
Normal file
301
internal/beads/beads_queue_test.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatchClaimPattern(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
identity string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Wildcard matches anyone
|
||||||
|
{
|
||||||
|
name: "wildcard matches anyone",
|
||||||
|
pattern: "*",
|
||||||
|
identity: "gastown/crew/max",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard matches town-level agent",
|
||||||
|
pattern: "*",
|
||||||
|
identity: "mayor/",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
{
|
||||||
|
name: "exact match",
|
||||||
|
pattern: "gastown/crew/max",
|
||||||
|
identity: "gastown/crew/max",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match fails on different identity",
|
||||||
|
pattern: "gastown/crew/max",
|
||||||
|
identity: "gastown/crew/nux",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Suffix wildcard
|
||||||
|
{
|
||||||
|
name: "suffix wildcard matches",
|
||||||
|
pattern: "gastown/polecats/*",
|
||||||
|
identity: "gastown/polecats/capable",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix wildcard matches different name",
|
||||||
|
pattern: "gastown/polecats/*",
|
||||||
|
identity: "gastown/polecats/nux",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix wildcard doesn't match nested path",
|
||||||
|
pattern: "gastown/polecats/*",
|
||||||
|
identity: "gastown/polecats/sub/capable",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suffix wildcard doesn't match different rig",
|
||||||
|
pattern: "gastown/polecats/*",
|
||||||
|
identity: "bartertown/polecats/capable",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Prefix wildcard
|
||||||
|
{
|
||||||
|
name: "prefix wildcard matches",
|
||||||
|
pattern: "*/witness",
|
||||||
|
identity: "gastown/witness",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix wildcard matches different rig",
|
||||||
|
pattern: "*/witness",
|
||||||
|
identity: "bartertown/witness",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefix wildcard doesn't match different role",
|
||||||
|
pattern: "*/witness",
|
||||||
|
identity: "gastown/refinery",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crew patterns
|
||||||
|
{
|
||||||
|
name: "crew wildcard",
|
||||||
|
pattern: "gastown/crew/*",
|
||||||
|
identity: "gastown/crew/max",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "crew wildcard matches any crew member",
|
||||||
|
pattern: "gastown/crew/*",
|
||||||
|
identity: "gastown/crew/jack",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "empty identity doesn't match",
|
||||||
|
pattern: "*",
|
||||||
|
identity: "",
|
||||||
|
want: true, // * matches anything
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty pattern doesn't match",
|
||||||
|
pattern: "",
|
||||||
|
identity: "gastown/crew/max",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := MatchClaimPattern(tt.pattern, tt.identity)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("MatchClaimPattern(%q, %q) = %v, want %v",
|
||||||
|
tt.pattern, tt.identity, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatQueueDescription(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
title string
|
||||||
|
fields *QueueFields
|
||||||
|
want []string // Lines that should be present
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic queue",
|
||||||
|
title: "Queue: work-requests",
|
||||||
|
fields: &QueueFields{
|
||||||
|
Name: "work-requests",
|
||||||
|
ClaimPattern: "gastown/crew/*",
|
||||||
|
Status: QueueStatusActive,
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
"Queue: work-requests",
|
||||||
|
"name: work-requests",
|
||||||
|
"claim_pattern: gastown/crew/*",
|
||||||
|
"status: active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue with default claim pattern",
|
||||||
|
title: "Queue: public",
|
||||||
|
fields: &QueueFields{
|
||||||
|
Name: "public",
|
||||||
|
Status: QueueStatusActive,
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
"name: public",
|
||||||
|
"claim_pattern: *", // Default
|
||||||
|
"status: active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue with counts",
|
||||||
|
title: "Queue: processing",
|
||||||
|
fields: &QueueFields{
|
||||||
|
Name: "processing",
|
||||||
|
ClaimPattern: "*/refinery",
|
||||||
|
Status: QueueStatusActive,
|
||||||
|
AvailableCount: 5,
|
||||||
|
ProcessingCount: 2,
|
||||||
|
CompletedCount: 10,
|
||||||
|
FailedCount: 1,
|
||||||
|
},
|
||||||
|
want: []string{
|
||||||
|
"name: processing",
|
||||||
|
"claim_pattern: */refinery",
|
||||||
|
"available_count: 5",
|
||||||
|
"processing_count: 2",
|
||||||
|
"completed_count: 10",
|
||||||
|
"failed_count: 1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil fields",
|
||||||
|
title: "Just Title",
|
||||||
|
fields: nil,
|
||||||
|
want: []string{"Just Title"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := FormatQueueDescription(tt.title, tt.fields)
|
||||||
|
for _, line := range tt.want {
|
||||||
|
if !strings.Contains(got, line) {
|
||||||
|
t.Errorf("FormatQueueDescription() missing line %q in:\n%s", line, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseQueueFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
wantName string
|
||||||
|
wantPattern string
|
||||||
|
wantStatus string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic queue",
|
||||||
|
description: `Queue: work-requests
|
||||||
|
|
||||||
|
name: work-requests
|
||||||
|
claim_pattern: gastown/crew/*
|
||||||
|
status: active`,
|
||||||
|
wantName: "work-requests",
|
||||||
|
wantPattern: "gastown/crew/*",
|
||||||
|
wantStatus: QueueStatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue with defaults",
|
||||||
|
description: `Queue: minimal
|
||||||
|
|
||||||
|
name: minimal`,
|
||||||
|
wantName: "minimal",
|
||||||
|
wantPattern: "*", // Default
|
||||||
|
wantStatus: QueueStatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty description",
|
||||||
|
description: "",
|
||||||
|
wantName: "",
|
||||||
|
wantPattern: "*", // Default
|
||||||
|
wantStatus: QueueStatusActive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "queue with counts",
|
||||||
|
description: `Queue: processing
|
||||||
|
|
||||||
|
name: processing
|
||||||
|
claim_pattern: */refinery
|
||||||
|
status: paused
|
||||||
|
available_count: 5
|
||||||
|
processing_count: 2`,
|
||||||
|
wantName: "processing",
|
||||||
|
wantPattern: "*/refinery",
|
||||||
|
wantStatus: QueueStatusPaused,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ParseQueueFields(tt.description)
|
||||||
|
if got.Name != tt.wantName {
|
||||||
|
t.Errorf("Name = %q, want %q", got.Name, tt.wantName)
|
||||||
|
}
|
||||||
|
if got.ClaimPattern != tt.wantPattern {
|
||||||
|
t.Errorf("ClaimPattern = %q, want %q", got.ClaimPattern, tt.wantPattern)
|
||||||
|
}
|
||||||
|
if got.Status != tt.wantStatus {
|
||||||
|
t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueBeadID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
queueName string
|
||||||
|
isTownLevel bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "town-level queue",
|
||||||
|
queueName: "dispatch",
|
||||||
|
isTownLevel: true,
|
||||||
|
want: "hq-q-dispatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rig-level queue",
|
||||||
|
queueName: "merge",
|
||||||
|
isTownLevel: false,
|
||||||
|
want: "gt-q-merge",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := QueueBeadID(tt.queueName, tt.isTownLevel)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("QueueBeadID(%q, %v) = %q, want %q",
|
||||||
|
tt.queueName, tt.isTownLevel, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,27 +277,27 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var mailClaimCmd = &cobra.Command{
|
var mailClaimCmd = &cobra.Command{
|
||||||
Use: "claim <queue-name>",
|
Use: "claim [queue-name]",
|
||||||
Short: "Claim a message from a queue",
|
Short: "Claim a message from a queue",
|
||||||
Long: `Claim the oldest unclaimed message from a work queue.
|
Long: `Claim the oldest unclaimed message from a work queue.
|
||||||
|
|
||||||
SYNTAX:
|
SYNTAX:
|
||||||
gt mail claim <queue-name>
|
gt mail claim [queue-name]
|
||||||
|
|
||||||
BEHAVIOR:
|
BEHAVIOR:
|
||||||
1. List unclaimed messages in the queue
|
1. If queue specified, claim from that queue
|
||||||
2. Pick the oldest unclaimed message
|
2. If no queue specified, claim from any eligible queue
|
||||||
3. Set assignee to caller identity
|
3. Add claimed-by and claimed-at labels to the message
|
||||||
4. Set status to in_progress
|
4. Print claimed message details
|
||||||
5. Print claimed message details
|
|
||||||
|
|
||||||
ELIGIBILITY:
|
ELIGIBILITY:
|
||||||
The caller must match a pattern in the queue's workers list
|
The caller must match the queue's claim_pattern (stored in the queue bead).
|
||||||
(defined in ~/gt/config/messaging.json).
|
Pattern examples: "*" (anyone), "gastown/polecats/*" (specific rig crew).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt mail claim work/gastown # Claim from gastown work queue`,
|
gt mail claim work-requests # Claim from specific queue
|
||||||
Args: cobra.ExactArgs(1),
|
gt mail claim # Claim from any eligible queue`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runMailClaim,
|
RunE: runMailClaim,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,14 +311,14 @@ SYNTAX:
|
|||||||
|
|
||||||
BEHAVIOR:
|
BEHAVIOR:
|
||||||
1. Find the message by ID
|
1. Find the message by ID
|
||||||
2. Verify caller is the one who claimed it (assignee matches)
|
2. Verify caller is the one who claimed it (claimed-by label matches)
|
||||||
3. Set assignee back to queue:<name> (from message labels)
|
3. Remove claimed-by and claimed-at labels
|
||||||
4. Set status back to open
|
4. Message returns to queue for others to claim
|
||||||
5. Message returns to queue for others to claim
|
|
||||||
|
|
||||||
ERROR CASES:
|
ERROR CASES:
|
||||||
- Message not found
|
- Message not found
|
||||||
- Message not claimed (still assigned to queue)
|
- Message is not a queue message
|
||||||
|
- Message not claimed
|
||||||
- Caller did not claim this message
|
- Caller did not claim this message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|||||||
@@ -6,52 +6,86 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runMailClaim claims the oldest unclaimed message from a work queue.
|
// runMailClaim claims the oldest unclaimed message from a work queue.
|
||||||
|
// If a queue name is provided, claims from that specific queue.
|
||||||
|
// If no queue name is provided, claims from any queue the caller is eligible for.
|
||||||
func runMailClaim(cmd *cobra.Command, args []string) error {
|
func runMailClaim(cmd *cobra.Command, args []string) error {
|
||||||
queueName := args[0]
|
|
||||||
|
|
||||||
// Find workspace
|
// Find workspace
|
||||||
townRoot, err := workspace.FindFromCwdOrError()
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load queue config from messaging.json
|
|
||||||
configPath := config.MessagingConfigPath(townRoot)
|
|
||||||
cfg, err := config.LoadMessagingConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("loading messaging config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
queueCfg, ok := cfg.Queues[queueName]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unknown queue: %s", queueName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get caller identity
|
// Get caller identity
|
||||||
caller := detectSender()
|
caller := detectSender()
|
||||||
|
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||||
|
bd := beads.NewWithBeadsDir(townRoot, beadsDir)
|
||||||
|
|
||||||
// Check if caller is eligible (matches any pattern in workers list)
|
var queueName string
|
||||||
if !isEligibleWorker(caller, queueCfg.Workers) {
|
var queueFields *beads.QueueFields
|
||||||
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, workers: %v)",
|
|
||||||
queueName, caller, queueCfg.Workers)
|
if len(args) > 0 {
|
||||||
|
// Specific queue requested
|
||||||
|
queueName = args[0]
|
||||||
|
|
||||||
|
// Look up the queue bead
|
||||||
|
queueID := beads.QueueBeadID(queueName, true) // Try town-level first
|
||||||
|
issue, fields, err := bd.GetQueueBead(queueID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("looking up queue: %w", err)
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
// Try rig-level
|
||||||
|
queueID = beads.QueueBeadID(queueName, false)
|
||||||
|
issue, fields, err = bd.GetQueueBead(queueID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("looking up queue: %w", err)
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
return fmt.Errorf("unknown queue: %s", queueName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queueFields = fields
|
||||||
|
|
||||||
|
// Check if caller is eligible
|
||||||
|
if !beads.MatchClaimPattern(queueFields.ClaimPattern, caller) {
|
||||||
|
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, pattern: %s)",
|
||||||
|
queueName, caller, queueFields.ClaimPattern)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No queue specified - find any queue the caller can claim from
|
||||||
|
eligibleIssues, eligibleFields, err := bd.FindEligibleQueues(caller)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding eligible queues: %w", err)
|
||||||
|
}
|
||||||
|
if len(eligibleIssues) == 0 {
|
||||||
|
fmt.Printf("%s No queues available for claiming (caller: %s)\n",
|
||||||
|
style.Dim.Render("○"), caller)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first eligible queue
|
||||||
|
queueFields = eligibleFields[0]
|
||||||
|
queueName = queueFields.Name
|
||||||
|
if queueName == "" {
|
||||||
|
// Fallback to ID-based name
|
||||||
|
queueName = eligibleIssues[0].ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List unclaimed messages in the queue
|
// List unclaimed messages in the queue
|
||||||
// Queue messages have assignee=queue:<name> and status=open
|
// Queue messages have queue:<name> label and no claimed-by label
|
||||||
queueAssignee := "queue:" + queueName
|
messages, err := listUnclaimedQueueMessages(beadsDir, queueName)
|
||||||
messages, err := listQueueMessages(townRoot, queueAssignee)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing queue messages: %w", err)
|
return fmt.Errorf("listing queue messages: %w", err)
|
||||||
}
|
}
|
||||||
@@ -64,8 +98,8 @@ func runMailClaim(cmd *cobra.Command, args []string) error {
|
|||||||
// Pick the oldest unclaimed message (first in list, sorted by created)
|
// Pick the oldest unclaimed message (first in list, sorted by created)
|
||||||
oldest := messages[0]
|
oldest := messages[0]
|
||||||
|
|
||||||
// Claim the message: set assignee to caller and status to in_progress
|
// Claim the message: add claimed-by and claimed-at labels
|
||||||
if err := claimMessage(townRoot, oldest.ID, caller); err != nil {
|
if err := claimQueueMessage(beadsDir, oldest.ID, caller); err != nil {
|
||||||
return fmt.Errorf("claiming message: %w", err)
|
return fmt.Errorf("claiming message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,60 +130,18 @@ type queueMessage struct {
|
|||||||
From string
|
From string
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Priority int
|
Priority int
|
||||||
|
ClaimedBy string
|
||||||
|
ClaimedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// isEligibleWorker checks if the caller matches any pattern in the workers list.
|
// listUnclaimedQueueMessages lists unclaimed messages in a queue.
|
||||||
// Patterns support wildcards: "gastown/polecats/*" matches "gastown/polecats/capable".
|
// Unclaimed messages have queue:<name> label but no claimed-by label.
|
||||||
func isEligibleWorker(caller string, patterns []string) bool {
|
func listUnclaimedQueueMessages(beadsDir, queueName string) ([]queueMessage, error) {
|
||||||
for _, pattern := range patterns {
|
// Use bd list to find messages with queue:<name> label and status=open
|
||||||
if matchWorkerPattern(pattern, caller) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchWorkerPattern checks if caller matches the pattern.
|
|
||||||
// Supports simple wildcards: * matches a single path segment (no slashes).
|
|
||||||
func matchWorkerPattern(pattern, caller string) bool {
|
|
||||||
// Handle exact match
|
|
||||||
if pattern == caller {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle wildcard patterns
|
|
||||||
if strings.Contains(pattern, "*") {
|
|
||||||
// Convert to simple glob matching
|
|
||||||
// "gastown/polecats/*" should match "gastown/polecats/capable"
|
|
||||||
// but NOT "gastown/polecats/sub/capable"
|
|
||||||
parts := strings.Split(pattern, "*")
|
|
||||||
if len(parts) == 2 {
|
|
||||||
prefix := parts[0]
|
|
||||||
suffix := parts[1]
|
|
||||||
if strings.HasPrefix(caller, prefix) && strings.HasSuffix(caller, suffix) {
|
|
||||||
// Check that the middle part doesn't contain path separators
|
|
||||||
middle := caller[len(prefix) : len(caller)-len(suffix)]
|
|
||||||
if !strings.Contains(middle, "/") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// listQueueMessages lists unclaimed messages in a queue.
|
|
||||||
func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
|
||||||
// Use bd list to find messages with assignee=queue:<name> and status=open
|
|
||||||
beadsDir := filepath.Join(townRoot, ".beads")
|
|
||||||
|
|
||||||
args := []string{"list",
|
args := []string{"list",
|
||||||
"--assignee", queueAssignee,
|
"--label", "queue:" + queueName,
|
||||||
"--status", "open",
|
"--status", "open",
|
||||||
"--type", "message",
|
"--type", "message",
|
||||||
"--sort", "created",
|
|
||||||
"--limit", "0", // No limit
|
|
||||||
"--json",
|
"--json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +178,7 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
|||||||
return nil, fmt.Errorf("parsing bd output: %w", err)
|
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to queueMessage, extracting 'from' from labels
|
// Convert to queueMessage, filtering out already claimed messages
|
||||||
var messages []queueMessage
|
var messages []queueMessage
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
msg := queueMessage{
|
msg := queueMessage{
|
||||||
@@ -197,18 +189,27 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
|||||||
Priority: issue.Priority,
|
Priority: issue.Priority,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract 'from' from labels (format: "from:address")
|
// Extract labels
|
||||||
for _, label := range issue.Labels {
|
for _, label := range issue.Labels {
|
||||||
if strings.HasPrefix(label, "from:") {
|
if strings.HasPrefix(label, "from:") {
|
||||||
msg.From = strings.TrimPrefix(label, "from:")
|
msg.From = strings.TrimPrefix(label, "from:")
|
||||||
break
|
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||||
|
msg.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||||
|
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||||
|
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||||
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||||
|
msg.ClaimedAt = &t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, msg)
|
// Only include unclaimed messages
|
||||||
|
if msg.ClaimedBy == "" {
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by created time (oldest first)
|
// Sort by created time (oldest first) for FIFO ordering
|
||||||
sort.Slice(messages, func(i, j int) bool {
|
sort.Slice(messages, func(i, j int) bool {
|
||||||
return messages[i].Created.Before(messages[j].Created)
|
return messages[i].Created.Before(messages[j].Created)
|
||||||
})
|
})
|
||||||
@@ -216,13 +217,13 @@ func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
|||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimMessage claims a message by setting assignee and status.
|
// claimQueueMessage claims a message by adding claimed-by and claimed-at labels.
|
||||||
func claimMessage(townRoot, messageID, claimant string) error {
|
func claimQueueMessage(beadsDir, messageID, claimant string) error {
|
||||||
beadsDir := filepath.Join(townRoot, ".beads")
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
args := []string{"update", messageID,
|
args := []string{"label", "add", messageID,
|
||||||
"--assignee", claimant,
|
"claimed-by:" + claimant,
|
||||||
"--status", "in_progress",
|
"claimed-at:" + now,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("bd", args...)
|
cmd := exec.Command("bd", args...)
|
||||||
@@ -255,11 +256,13 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beadsDir := beads.ResolveBeadsDir(townRoot)
|
||||||
|
|
||||||
// Get caller identity
|
// Get caller identity
|
||||||
caller := detectSender()
|
caller := detectSender()
|
||||||
|
|
||||||
// Get message details to verify ownership and find queue
|
// Get message details to verify ownership and find queue
|
||||||
msgInfo, err := getMessageInfo(townRoot, messageID)
|
msgInfo, err := getQueueMessageInfo(beadsDir, messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting message: %w", err)
|
return fmt.Errorf("getting message: %w", err)
|
||||||
}
|
}
|
||||||
@@ -270,16 +273,15 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify caller is the one who claimed it
|
// Verify caller is the one who claimed it
|
||||||
if msgInfo.Assignee != caller {
|
if msgInfo.ClaimedBy == "" {
|
||||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
return fmt.Errorf("message %s is not claimed", messageID)
|
||||||
return fmt.Errorf("message %s is not claimed (still in queue)", messageID)
|
}
|
||||||
}
|
if msgInfo.ClaimedBy != caller {
|
||||||
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.Assignee, caller)
|
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.ClaimedBy, caller)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release the message: set assignee back to queue and status to open
|
// Release the message: remove claimed-by and claimed-at labels
|
||||||
queueAssignee := "queue:" + msgInfo.QueueName
|
if err := releaseQueueMessage(beadsDir, messageID, caller); err != nil {
|
||||||
if err := releaseMessage(townRoot, messageID, queueAssignee, caller); err != nil {
|
|
||||||
return fmt.Errorf("releasing message: %w", err)
|
return fmt.Errorf("releasing message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,19 +292,18 @@ func runMailRelease(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// messageInfo holds details about a queue message.
|
// queueMessageInfo holds details about a queue message.
|
||||||
type messageInfo struct {
|
type queueMessageInfo struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Assignee string
|
|
||||||
QueueName string
|
QueueName string
|
||||||
|
ClaimedBy string
|
||||||
|
ClaimedAt *time.Time
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMessageInfo retrieves information about a message.
|
// getQueueMessageInfo retrieves information about a queue message.
|
||||||
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
func getQueueMessageInfo(beadsDir, messageID string) (*queueMessageInfo, error) {
|
||||||
beadsDir := filepath.Join(townRoot, ".beads")
|
|
||||||
|
|
||||||
args := []string{"show", messageID, "--json"}
|
args := []string{"show", messageID, "--json"}
|
||||||
|
|
||||||
cmd := exec.Command("bd", args...)
|
cmd := exec.Command("bd", args...)
|
||||||
@@ -327,7 +328,6 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
|||||||
var issues []struct {
|
var issues []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Assignee string `json:"assignee"`
|
|
||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
@@ -341,48 +341,76 @@ func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
issue := issues[0]
|
issue := issues[0]
|
||||||
info := &messageInfo{
|
info := &queueMessageInfo{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Assignee: issue.Assignee,
|
Status: issue.Status,
|
||||||
Status: issue.Status,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract queue name from labels (format: "queue:<name>")
|
// Extract fields from labels
|
||||||
for _, label := range issue.Labels {
|
for _, label := range issue.Labels {
|
||||||
if strings.HasPrefix(label, "queue:") {
|
if strings.HasPrefix(label, "queue:") {
|
||||||
info.QueueName = strings.TrimPrefix(label, "queue:")
|
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||||
break
|
} else if strings.HasPrefix(label, "claimed-by:") {
|
||||||
|
info.ClaimedBy = strings.TrimPrefix(label, "claimed-by:")
|
||||||
|
} else if strings.HasPrefix(label, "claimed-at:") {
|
||||||
|
ts := strings.TrimPrefix(label, "claimed-at:")
|
||||||
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||||
|
info.ClaimedAt = &t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseMessage releases a claimed message back to its queue.
|
// releaseQueueMessage releases a claimed message by removing claim labels.
|
||||||
func releaseMessage(townRoot, messageID, queueAssignee, actor string) error {
|
func releaseQueueMessage(beadsDir, messageID, actor string) error {
|
||||||
beadsDir := filepath.Join(townRoot, ".beads")
|
// Get current message info to find the exact claim labels
|
||||||
|
info, err := getQueueMessageInfo(beadsDir, messageID)
|
||||||
args := []string{"update", messageID,
|
if err != nil {
|
||||||
"--assignee", queueAssignee,
|
return err
|
||||||
"--status", "open",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("bd", args...)
|
// Remove claimed-by label
|
||||||
cmd.Env = append(os.Environ(),
|
if info.ClaimedBy != "" {
|
||||||
"BEADS_DIR="+beadsDir,
|
args := []string{"label", "remove", messageID, "claimed-by:" + info.ClaimedBy}
|
||||||
"BD_ACTOR="+actor,
|
cmd := exec.Command("bd", args...)
|
||||||
)
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BEADS_DIR="+beadsDir,
|
||||||
|
"BD_ACTOR="+actor,
|
||||||
|
)
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
errMsg := strings.TrimSpace(stderr.String())
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
if errMsg != "" {
|
if errMsg != "" && !strings.Contains(errMsg, "does not have label") {
|
||||||
return fmt.Errorf("%s", errMsg)
|
return fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove claimed-at label if present
|
||||||
|
if info.ClaimedAt != nil {
|
||||||
|
claimedAtStr := info.ClaimedAt.Format(time.RFC3339)
|
||||||
|
args := []string{"label", "remove", messageID, "claimed-at:" + claimedAtStr}
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BEADS_DIR="+beadsDir,
|
||||||
|
"BD_ACTOR="+actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg != "" && !strings.Contains(errMsg, "does not have label") {
|
||||||
|
return fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
pattern string
|
pattern string
|
||||||
@@ -55,43 +58,9 @@ func TestMatchWorkerPattern(t *testing.T) {
|
|||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Crew patterns
|
// Universal wildcard
|
||||||
{
|
{
|
||||||
name: "crew wildcard matches",
|
name: "universal wildcard matches anything",
|
||||||
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",
|
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
caller: "anything",
|
caller: "anything",
|
||||||
want: true,
|
want: true,
|
||||||
@@ -100,103 +69,47 @@ func TestMatchWorkerPattern(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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 {
|
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)
|
tt.pattern, tt.caller, got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsEligibleWorker(t *testing.T) {
|
// TestQueueMessageReleaseValidation tests the validation logic for the release command.
|
||||||
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.
|
|
||||||
// This tests that release correctly identifies:
|
// 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 claimed by a different worker
|
||||||
// - Messages without queue labels (non-queue messages)
|
// - Messages without queue labels (non-queue messages)
|
||||||
func TestMailReleaseValidation(t *testing.T) {
|
func TestQueueMessageReleaseValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
msgInfo *messageInfo
|
msgInfo *queueMessageInfo
|
||||||
caller string
|
caller string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
errContains string
|
errContains string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "caller matches assignee - valid release",
|
name: "caller matches claimed-by - valid release",
|
||||||
msgInfo: &messageInfo{
|
msgInfo: &queueMessageInfo{
|
||||||
ID: "hq-test1",
|
ID: "hq-test1",
|
||||||
Title: "Test Message",
|
Title: "Test Message",
|
||||||
Assignee: "gastown/polecats/nux",
|
ClaimedBy: "gastown/polecats/nux",
|
||||||
QueueName: "work/gastown",
|
QueueName: "work-requests",
|
||||||
Status: "in_progress",
|
Status: "open",
|
||||||
},
|
},
|
||||||
caller: "gastown/polecats/nux",
|
caller: "gastown/polecats/nux",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "message still in queue - not claimed",
|
name: "message not claimed",
|
||||||
msgInfo: &messageInfo{
|
msgInfo: &queueMessageInfo{
|
||||||
ID: "hq-test2",
|
ID: "hq-test2",
|
||||||
Title: "Test Message",
|
Title: "Test Message",
|
||||||
Assignee: "queue:work/gastown",
|
ClaimedBy: "", // Not claimed
|
||||||
QueueName: "work/gastown",
|
QueueName: "work-requests",
|
||||||
Status: "open",
|
Status: "open",
|
||||||
},
|
},
|
||||||
caller: "gastown/polecats/nux",
|
caller: "gastown/polecats/nux",
|
||||||
@@ -205,12 +118,12 @@ func TestMailReleaseValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "claimed by different worker",
|
name: "claimed by different worker",
|
||||||
msgInfo: &messageInfo{
|
msgInfo: &queueMessageInfo{
|
||||||
ID: "hq-test3",
|
ID: "hq-test3",
|
||||||
Title: "Test Message",
|
Title: "Test Message",
|
||||||
Assignee: "gastown/polecats/other",
|
ClaimedBy: "gastown/polecats/other",
|
||||||
QueueName: "work/gastown",
|
QueueName: "work-requests",
|
||||||
Status: "in_progress",
|
Status: "open",
|
||||||
},
|
},
|
||||||
caller: "gastown/polecats/nux",
|
caller: "gastown/polecats/nux",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
@@ -218,10 +131,10 @@ func TestMailReleaseValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "not a queue message",
|
name: "not a queue message",
|
||||||
msgInfo: &messageInfo{
|
msgInfo: &queueMessageInfo{
|
||||||
ID: "hq-test4",
|
ID: "hq-test4",
|
||||||
Title: "Test Message",
|
Title: "Test Message",
|
||||||
Assignee: "gastown/polecats/nux",
|
ClaimedBy: "gastown/polecats/nux",
|
||||||
QueueName: "", // No queue label
|
QueueName: "", // No queue label
|
||||||
Status: "open",
|
Status: "open",
|
||||||
},
|
},
|
||||||
@@ -233,7 +146,7 @@ func TestMailReleaseValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateRelease(tt.msgInfo, tt.caller)
|
err := validateQueueRelease(tt.msgInfo, tt.caller)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error, got 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.
|
// validateQueueRelease checks if a queue message can be released by the caller.
|
||||||
// This is extracted for testing; the actual release command uses this logic inline.
|
// This mirrors the validation logic in runMailRelease.
|
||||||
func validateRelease(msgInfo *messageInfo, caller string) error {
|
func validateQueueRelease(msgInfo *queueMessageInfo, caller string) error {
|
||||||
// Verify message is a queue message
|
// Verify message is a queue message
|
||||||
if msgInfo.QueueName == "" {
|
if msgInfo.QueueName == "" {
|
||||||
return fmt.Errorf("message %s is not a queue message (no queue label)", msgInfo.ID)
|
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
|
// Verify caller is the one who claimed it
|
||||||
if msgInfo.Assignee != caller {
|
if msgInfo.ClaimedBy != caller {
|
||||||
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
return fmt.Errorf("message %s was claimed by %s, not %s", msgInfo.ID, msgInfo.ClaimedBy, caller)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user