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:
gastown/crew/jack
2026-01-14 21:24:57 -08:00
committed by Steve Yegge
parent 012d50b2b2
commit 2ffc8e8712
5 changed files with 592 additions and 267 deletions

View File

@@ -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
}

View 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)
}
})
}
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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