Skip to content

Commit d4d3fbd

Browse files
committed
fix(session): paginate list beyond 100 rows; pin merged go-flashduty
The /safari/session/list handler binds limit with "lte=100": a single request with limit>100 is a hard 400 bind failure, not a clamp. Honor --limit above 100 by paginating server-side (fetchSessionsPaged) — each page requests min(remaining, 100) and advances p until the limit is met or the server is exhausted (short page / accumulated >= total). Adds two regression guards: paginate-beyond-100 and stop-when-exhausted. Also bumps the go-flashduty pin to the squash-merged main commit (7583ebae, go-flashduty#8) so the dependency is reachable from main rather than the soon-stale PR-branch pseudo-version.
1 parent efa295c commit d4d3fbd

4 files changed

Lines changed: 207 additions & 10 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7
6+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07
77
github.com/mattn/go-runewidth v0.0.23
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 h1:ZW8Y7p6JYh+M+saQPq0ScVqRTsxFCrGV59K9TuLxHRA=
5-
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
4+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07 h1:bi1rOjR2OY+TovBGabtVOTcEQWlgzU9RfEwlJxU+3n8=
5+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602051355-7583ebae5b07/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=

internal/cli/session.go

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"io"
@@ -37,6 +38,13 @@ const (
3738
sessionFormatTOON = "toon"
3839
)
3940

41+
// sessionPageLimit is the largest per-page Limit the /safari/session/list
42+
// handler accepts. The server validates limit with binding "lte=100": a
43+
// limit > 100 is a hard 400 bind failure, NOT a clamp, so every page request
44+
// must carry Limit <= 100. To honor a --limit above this, `session list`
45+
// paginates server-side (see fetchSessionsPaged).
46+
const sessionPageLimit = 100
47+
4048
func newSessionListCmd() *cobra.Command {
4149
var (
4250
app string
@@ -86,23 +94,20 @@ func newSessionListCmd() *cobra.Command {
8694
Status: status,
8795
Orderby: "updated_at",
8896
}
89-
req.Limit = limit
90-
req.Page = page
9197
if teamID > 0 {
9298
req.TeamIDs = []int64{teamID}
9399
}
94100

95-
resp, _, err := ctx.Client.Sessions.List(cmdContext(ctx.Cmd), req)
101+
sessions, total, err := fetchSessionsPaged(cmdContext(ctx.Cmd), ctx.Client, req, page, limit)
96102
if err != nil {
97103
return err
98104
}
99105

100-
sessions := resp.Sessions
101106
if sinceUnix > 0 {
102107
sessions = filterSessionsSince(sessions, sinceUnix)
103108
}
104109

105-
return writeSessionList(ctx.Writer, format, sessions, resp.Total)
110+
return writeSessionList(ctx.Writer, format, sessions, total)
106111
})
107112
},
108113
}
@@ -114,14 +119,78 @@ func newSessionListCmd() *cobra.Command {
114119
registerEnumFlag(cmd, "status", "active", "archived", "all")
115120
cmd.Flags().StringVar(&since, "since", "", "Keep only sessions updated within this window (client-side), e.g. 30d, 24h, 2026-05-01")
116121
cmd.Flags().Int64Var(&teamID, "team-id", 0, "Restrict to one team ID")
117-
cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch (server caps at 100/page)")
118-
cmd.Flags().IntVar(&page, "page", 1, "Page number")
122+
cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch; fetched across multiple 100-row server pages as needed")
123+
cmd.Flags().IntVar(&page, "page", 1, "1-based page to start paginating from")
119124
cmd.Flags().StringVar(&format, "format", sessionFormatJSONL, "Output format: jsonl (default), json, or toon")
120125
registerEnumFlag(cmd, "format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON)
121126

122127
return cmd
123128
}
124129

130+
// fetchSessionsPaged collects up to `limit` sessions across as many server pages
131+
// as needed, starting at page `startPage`. The /safari/session/list handler
132+
// rejects any single request with Limit > 100 (binding "lte=100" → HTTP 400, not
133+
// a clamp), so a --limit above 100 must be satisfied by paginating: each page
134+
// requests min(remaining, 100) rows and advances the 1-based page number P. The
135+
// loop stops once it has `limit` rows, the server reports it has returned every
136+
// matching row (accumulated >= Total), or a page comes back short (fewer rows
137+
// than requested means the server is exhausted). The Total from the last
138+
// response is returned so the caller can report the full match count even when
139+
// the rows were truncated to --limit.
140+
func fetchSessionsPaged(
141+
ctx context.Context,
142+
client *flashduty.Client,
143+
base *flashduty.SessionListRequest,
144+
startPage, limit int,
145+
) ([]flashduty.SessionItem, int64, error) {
146+
if startPage < 1 {
147+
startPage = 1
148+
}
149+
if limit < 1 {
150+
limit = 1
151+
}
152+
153+
// Hint the slice at one page; it grows naturally across pages. Sizing it to
154+
// `limit` would over-allocate when a huge --limit far exceeds what the server
155+
// actually has (e.g. --limit 1000000 on an account with a few hundred rows).
156+
capHint := limit
157+
if capHint > sessionPageLimit {
158+
capHint = sessionPageLimit
159+
}
160+
collected := make([]flashduty.SessionItem, 0, capHint)
161+
var total int64
162+
for page := startPage; len(collected) < limit; page++ {
163+
pageLimit := limit - len(collected)
164+
if pageLimit > sessionPageLimit {
165+
pageLimit = sessionPageLimit
166+
}
167+
168+
// Copy the filter so each page reuses the same scope/app/team but
169+
// carries its own pagination cursor.
170+
req := *base
171+
req.Page = page
172+
req.Limit = pageLimit
173+
174+
resp, _, err := client.Sessions.List(ctx, &req)
175+
if err != nil {
176+
return nil, 0, err
177+
}
178+
total = resp.Total
179+
collected = append(collected, resp.Sessions...)
180+
181+
// Server exhausted: a short page (fewer rows than asked for) or we have
182+
// already gathered every matching row. Either ends the loop.
183+
if len(resp.Sessions) < pageLimit || int64(len(collected)) >= total {
184+
break
185+
}
186+
}
187+
188+
if len(collected) > limit {
189+
collected = collected[:limit]
190+
}
191+
return collected, total, nil
192+
}
193+
125194
// filterSessionsSince keeps sessions whose updated_at is at or after sinceUnix
126195
// (unix seconds). The API exposes no time-window filter, so this is the only
127196
// place a --since window is honored.

internal/cli/session_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,134 @@ func TestCommandSessionListJSONL(t *testing.T) {
8989
}
9090
}
9191

92+
// TestCommandSessionListPaginatesBeyond100 is the regression guard for the
93+
// limit>100 bug: the /safari/session/list handler binds limit with "lte=100",
94+
// so a single request with limit 200 is a hard 400 bind failure, not a clamp.
95+
// `session list --limit 200` must therefore satisfy the request by paginating —
96+
// issuing MULTIPLE page requests each with limit<=100 and advancing p — then
97+
// concatenating the rows. This test serves 250 matching sessions in pages of at
98+
// most 100 and asserts the command (a) never asks for more than 100 in any page,
99+
// (b) advances p across pages, and (c) returns exactly the requested 200 rows.
100+
func TestCommandSessionListPaginatesBeyond100(t *testing.T) {
101+
saveAndResetGlobals(t)
102+
stub := newGFStub(t)
103+
104+
const totalAvailable = 250
105+
// Serve a page computed from the request's p/limit so we exercise the real
106+
// loop: each page returns min(limit, remaining) sessions, never more than
107+
// the server-accepted ceiling.
108+
stub.dataFor = func(body map[string]any) any {
109+
p := int(asFloat(body["p"]))
110+
limit := int(asFloat(body["limit"]))
111+
if p < 1 {
112+
p = 1
113+
}
114+
if limit > 100 {
115+
// Mirror the real handler: limit>100 is a bind FAILURE, never a
116+
// clamp. If the CLI ever sends this, the test must fail loudly.
117+
t.Fatalf("page request used limit=%d (>100) — server would 400, CLI must paginate", limit)
118+
}
119+
offset := (p - 1) * limit
120+
sessions := make([]map[string]any, 0, limit)
121+
for i := offset; i < offset+limit && i < totalAvailable; i++ {
122+
sessions = append(sessions, map[string]any{
123+
"session_id": fmt.Sprintf("sess-%03d", i),
124+
"app_name": "ai-sre",
125+
"updated_at": 1779432894000,
126+
"session_name": fmt.Sprintf("row %d", i),
127+
})
128+
}
129+
return map[string]any{"sessions": sessions, "total": totalAvailable}
130+
}
131+
132+
out, err := execCommand("session", "list", "--app", "ai-sre", "--limit", "200", "--format", "jsonl")
133+
if err != nil {
134+
t.Fatalf("[session-paginate] unexpected error: %v", err)
135+
}
136+
137+
// (a) Multiple page requests were issued, and (b) p advanced across them.
138+
if stub.requests < 2 {
139+
t.Fatalf("[session-paginate] expected >=2 page requests for limit 200, got %d", stub.requests)
140+
}
141+
seenPages := make(map[int]bool)
142+
for i, b := range stub.bodies {
143+
limit := int(asFloat(b["limit"]))
144+
if limit > 100 {
145+
t.Errorf("[session-paginate] request %d used limit=%d, want <=100", i, limit)
146+
}
147+
seenPages[int(asFloat(b["p"]))] = true
148+
}
149+
if !seenPages[1] || !seenPages[2] {
150+
t.Errorf("[session-paginate] expected requests for p=1 and p=2, saw pages %v", seenPages)
151+
}
152+
153+
// (c) Exactly 200 rows came back, concatenated and in order across pages.
154+
lines := nonEmptyLines(out)
155+
if len(lines) != 200 {
156+
t.Fatalf("[session-paginate] expected 200 concatenated rows, got %d", len(lines))
157+
}
158+
var first, last flashduty.SessionItem
159+
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
160+
t.Fatalf("[session-paginate] line 0 not a SessionItem: %v", err)
161+
}
162+
if err := json.Unmarshal([]byte(lines[199]), &last); err != nil {
163+
t.Fatalf("[session-paginate] line 199 not a SessionItem: %v", err)
164+
}
165+
if first.SessionID != "sess-000" {
166+
t.Errorf("[session-paginate] first row = %q, want sess-000", first.SessionID)
167+
}
168+
if last.SessionID != "sess-199" {
169+
t.Errorf("[session-paginate] last row = %q, want sess-199", last.SessionID)
170+
}
171+
}
172+
173+
// TestCommandSessionListStopsWhenServerExhausted proves the loop terminates when
174+
// the server returns fewer rows than requested (a short page) even though
175+
// --limit asks for more, rather than spinning forever.
176+
func TestCommandSessionListStopsWhenServerExhausted(t *testing.T) {
177+
saveAndResetGlobals(t)
178+
stub := newGFStub(t)
179+
180+
const totalAvailable = 130 // exhausts mid-way through page 2
181+
stub.dataFor = func(body map[string]any) any {
182+
p := int(asFloat(body["p"]))
183+
limit := int(asFloat(body["limit"]))
184+
if limit > 100 {
185+
t.Fatalf("page request used limit=%d (>100)", limit)
186+
}
187+
offset := (p - 1) * limit
188+
sessions := make([]map[string]any, 0, limit)
189+
for i := offset; i < offset+limit && i < totalAvailable; i++ {
190+
sessions = append(sessions, map[string]any{
191+
"session_id": fmt.Sprintf("sess-%03d", i),
192+
"app_name": "ai-sre",
193+
"updated_at": 1779432894000,
194+
})
195+
}
196+
return map[string]any{"sessions": sessions, "total": totalAvailable}
197+
}
198+
199+
out, err := execCommand("session", "list", "--app", "ai-sre", "--limit", "200", "--format", "jsonl")
200+
if err != nil {
201+
t.Fatalf("[session-exhaust] unexpected error: %v", err)
202+
}
203+
lines := nonEmptyLines(out)
204+
if len(lines) != totalAvailable {
205+
t.Fatalf("[session-exhaust] expected %d rows (server exhausted), got %d", totalAvailable, len(lines))
206+
}
207+
// Page 1 (100) + page 2 (30, short) → exactly 2 requests, no extra spin.
208+
if stub.requests != 2 {
209+
t.Errorf("[session-exhaust] expected exactly 2 requests, got %d", stub.requests)
210+
}
211+
}
212+
213+
// asFloat coerces a decoded JSON number (always float64) to float64, tolerating
214+
// a missing key (returns 0).
215+
func asFloat(v any) float64 {
216+
f, _ := v.(float64)
217+
return f
218+
}
219+
92220
// TestCommandSessionListSinceFiltersClientSide proves --since drops rows older
93221
// than the window using the response's updated_at (the API has no time filter).
94222
func TestCommandSessionListSinceFiltersClientSide(t *testing.T) {

0 commit comments

Comments
 (0)