Skip to content

Commit 0d9ba67

Browse files
committed
Add wait-ready command with SSE-driven polling
Implements `bs wait-ready --timeout <secs>` which blocks until at least one ready (unblocked) bead exists. Opens the SSE stream before the initial list check to close the race window where a bead becomes ready between check and subscription. Supports --tag (repeatable), --assignee, --priority, --type filters. Returns errTimeout (exit non-zero, no output) on timeout; prints to stderr and exits non-zero on connectivity failure; exits 0 silently when a ready bead is found. Built with Raymond (Agent Orchestrator)
1 parent ff11f75 commit 0d9ba67

3 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var errTimeout = errors.New("timed out")
16+
17+
func newWaitReadyCmd() *cobra.Command {
18+
var timeout int
19+
var tags []string
20+
var assignee string
21+
var priority string
22+
var beadType string
23+
24+
cmd := &cobra.Command{
25+
Use: "wait-ready",
26+
Short: "Wait until a ready bead exists",
27+
SilenceErrors: true,
28+
SilenceUsage: true,
29+
RunE: func(cmd *cobra.Command, args []string) error {
30+
c, err := NewClientFromEnv()
31+
if err != nil {
32+
fmt.Fprintln(cmd.ErrOrStderr(), "error:", err)
33+
return err
34+
}
35+
36+
var ctx context.Context
37+
var cancel context.CancelFunc
38+
if timeout > 0 {
39+
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
40+
} else {
41+
ctx, cancel = context.WithCancel(context.Background())
42+
}
43+
defer cancel()
44+
45+
signals, errChan := c.StreamSSE(ctx)
46+
47+
// Build the query path once; flags are immutable after parsing.
48+
params := url.Values{}
49+
params.Set("ready", "true")
50+
params.Set("per_page", "1")
51+
if len(tags) > 0 {
52+
params.Set("tag", strings.Join(tags, ","))
53+
}
54+
if assignee != "" {
55+
params.Set("assignee", assignee)
56+
}
57+
if priority != "" {
58+
params.Set("priority", priority)
59+
}
60+
if beadType != "" {
61+
params.Set("type", beadType)
62+
}
63+
path := "/api/v1/beads?" + params.Encode()
64+
65+
checkReady := func() (bool, error) {
66+
data, err := c.Do("GET", path, nil)
67+
if err != nil {
68+
return false, err
69+
}
70+
var result struct {
71+
Total int `json:"total"`
72+
}
73+
if err := json.Unmarshal(data, &result); err != nil {
74+
return false, fmt.Errorf("parsing response: %w", err)
75+
}
76+
return result.Total >= 1, nil
77+
}
78+
79+
ready, err := checkReady()
80+
if err != nil {
81+
fmt.Fprintln(cmd.ErrOrStderr(), "error:", err)
82+
return err
83+
}
84+
if ready {
85+
return nil
86+
}
87+
88+
for {
89+
select {
90+
case _, ok := <-signals:
91+
if !ok {
92+
// signals closed; errChan will deliver the outcome — stop selecting on signals.
93+
signals = nil
94+
continue
95+
}
96+
ready, err := checkReady()
97+
if err != nil {
98+
fmt.Fprintln(cmd.ErrOrStderr(), "error:", err)
99+
return err
100+
}
101+
if ready {
102+
return nil
103+
}
104+
case err, ok := <-errChan:
105+
if ok && err != nil {
106+
fmt.Fprintln(cmd.ErrOrStderr(), "error:", err)
107+
return err
108+
}
109+
// nil error or channel closed: context was cancelled (deadline or explicit).
110+
return errTimeout
111+
case <-ctx.Done():
112+
return errTimeout
113+
}
114+
}
115+
},
116+
}
117+
118+
cmd.Flags().IntVar(&timeout, "timeout", 0, "seconds to wait (0 = indefinite)")
119+
_ = cmd.MarkFlagRequired("timeout")
120+
cmd.Flags().StringArrayVar(&tags, "tag", nil, "filter by tag (repeatable)")
121+
cmd.Flags().StringVar(&assignee, "assignee", "", "filter by assignee")
122+
cmd.Flags().StringVar(&priority, "priority", "", "filter by priority")
123+
cmd.Flags().StringVar(&beadType, "type", "", "filter by bead type")
124+
125+
return cmd
126+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"net/http"
7+
"testing"
8+
"time"
9+
)
10+
11+
// runWaitReadyCmd executes the wait-ready command and returns (stdout, stderr, error).
12+
func runWaitReadyCmd(t *testing.T, args ...string) (string, string, error) {
13+
t.Helper()
14+
cmd := NewRootCmd()
15+
outBuf := new(bytes.Buffer)
16+
errBuf := new(bytes.Buffer)
17+
cmd.SetOut(outBuf)
18+
cmd.SetErr(errBuf)
19+
cmd.SetArgs(append([]string{"wait-ready"}, args...))
20+
err := cmd.Execute()
21+
return outBuf.String(), errBuf.String(), err
22+
}
23+
24+
// TestWaitReady_ImmediatelyReady verifies that the command exits 0 when a ready bead
25+
// already exists before the SSE connection is established.
26+
func TestWaitReady_ImmediatelyReady(t *testing.T) {
27+
ts := startTestServer(t)
28+
setClientEnv(t, ts.URL)
29+
30+
// Create a bead (no dependencies → immediately ready)
31+
runCmd(t, "add", "Ready bead")
32+
33+
_, _, err := runWaitReadyCmd(t, "--timeout", "5")
34+
if err != nil {
35+
t.Fatalf("expected nil error, got %v", err)
36+
}
37+
}
38+
39+
// TestWaitReady_Timeout verifies that the command returns errTimeout when no ready bead
40+
// appears within the timeout window.
41+
func TestWaitReady_Timeout(t *testing.T) {
42+
ts := startTestServer(t)
43+
setClientEnv(t, ts.URL)
44+
45+
// No beads → nothing is ready.
46+
_, _, err := runWaitReadyCmd(t, "--timeout", "1")
47+
if err == nil {
48+
t.Fatal("expected timeout error, got nil")
49+
}
50+
if !errors.Is(err, errTimeout) {
51+
t.Fatalf("expected errTimeout, got %v", err)
52+
}
53+
}
54+
55+
// TestWaitReady_BecomesReadyViaSSE verifies that the command exits 0 once a bead becomes
56+
// ready after the initial check (simulated via a second goroutine adding a bead).
57+
func TestWaitReady_BecomesReadyViaSSE(t *testing.T) {
58+
ts := startTestServer(t)
59+
setClientEnv(t, ts.URL)
60+
61+
// Add a bead with a blocking dependency so it's not initially ready.
62+
out := runCmd(t, "add", "Blocker bead")
63+
blocker := parseBeadFromOutput(t, out)
64+
65+
out = runCmd(t, "add", "Blocked bead")
66+
blocked := parseBeadFromOutput(t, out)
67+
runCmd(t, "link", blocked.ID, "--blocked-by", blocker.ID)
68+
69+
// Unblock the bead after a short delay in a goroutine.
70+
// Use a direct HTTP call instead of runCmd so t.Fatalf is not called from a goroutine
71+
// after the test may have completed.
72+
serverURL := ts.URL
73+
go func() {
74+
time.Sleep(200 * time.Millisecond)
75+
req, _ := http.NewRequest(http.MethodPost, serverURL+"/api/v1/beads/"+blocker.ID+"/status", bytes.NewBufferString(`{"status":"closed"}`))
76+
req.Header.Set("Authorization", "Bearer "+testToken)
77+
req.Header.Set("Content-Type", "application/json")
78+
http.DefaultClient.Do(req) //nolint:errcheck
79+
}()
80+
81+
_, _, err := runWaitReadyCmd(t, "--timeout", "5")
82+
if err != nil {
83+
t.Fatalf("expected nil error after bead became ready, got %v", err)
84+
}
85+
}
86+
87+
// TestWaitReady_MissingTimeout verifies that --timeout is required.
88+
func TestWaitReady_MissingTimeout(t *testing.T) {
89+
ts := startTestServer(t)
90+
setClientEnv(t, ts.URL)
91+
92+
_, _, err := runWaitReadyCmd(t)
93+
if err == nil {
94+
t.Fatal("expected error when --timeout is missing")
95+
}
96+
}
97+
98+
// TestWaitReady_TagFilter verifies that --tag filters beads by tag.
99+
func TestWaitReady_TagFilter(t *testing.T) {
100+
ts := startTestServer(t)
101+
setClientEnv(t, ts.URL)
102+
103+
// Create a bead without the required tag — should NOT match.
104+
runCmd(t, "add", "Untagged bead")
105+
106+
// With a tag filter for a tag no bead has, it should time out.
107+
_, _, err := runWaitReadyCmd(t, "--timeout", "1", "--tag", "mytag")
108+
if err == nil {
109+
t.Fatal("expected timeout when no bead matches tag filter")
110+
}
111+
if !errors.Is(err, errTimeout) {
112+
t.Fatalf("expected errTimeout, got %v", err)
113+
}
114+
115+
// Now create a bead with the matching tag.
116+
runCmd(t, "add", "Tagged bead", "--tags", "mytag")
117+
118+
_, _, err = runWaitReadyCmd(t, "--timeout", "5", "--tag", "mytag")
119+
if err != nil {
120+
t.Fatalf("expected nil error with matching tagged bead, got %v", err)
121+
}
122+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ func NewRootCmd() *cobra.Command {
8282
newLinkCmd(),
8383
newUnlinkCmd(),
8484
newDepsCmd(),
85+
newWaitReadyCmd(),
8586
} {
8687
cmd.GroupID = "client"
8788
root.AddCommand(cmd)

0 commit comments

Comments
 (0)