Skip to content

Commit 287144f

Browse files
authored
feat(session list): per-key sort direction for --sort / ?order_by (#777)
Follow-up to #739. On that PR, @cpcloud asked whether sort direction could be specified per key rather than forcing one direction for all sort keys, given the `SessionFilter` abstraction. This implements that. ## What changed `order_by` (and `--sort`) now accept a comma-separated list of sort keys, each optionally suffixed `:asc` or `:desc`: ``` GET /api/v1/sessions?order_by=messages:desc,started:asc agentsview session list --sort messages:desc,started:asc ``` Different keys can sort in different directions. A key with no suffix takes its direction from the `descending` param (HTTP) or `--reverse` (CLI), then from its own natural default; an explicit `:asc`/`:desc` always wins. The single-key forms (`order_by=messages&descending=true`, `--sort messages -r`) behave exactly as before, and existing pagination cursors keep working. ## How it works `SessionFilter` gains a structured `Sort []SortKey` (each key plus an optional direction); `OrderBy`/`Descending` remain as the single-key shorthand and parse fallback. `OrderByClause` and `CursorPredicate` now render N columns from one definition shared by SQLite, PostgreSQL, and DuckDB. The keyset pagination predicate becomes the lexicographic OR-expansion -- `(c1 OPdir1 v1) OR (c1 = v1 AND c2 OPdir2 v2) OR ...` -- because a single row-value tuple comparison is only valid when every column shares one direction; mixed per-key directions require the expansion. The `id` tie-breaker follows the last sort term's direction (appended unless `id` is already a term). Cursors carry a per-key `Keys` list; legacy and single-key cursors still decode, and a cursor reused under a different sort list (different keys, order, or any direction) is rejected as an invalid cursor rather than silently paging wrong rows. The `order_by` enum was replaced by free-form parsing with server-side validation (unknown key, bad direction token, duplicate key, empty term -> 400), shared by the list and sidebar-index routes; the valid keys are enumerated in the param doc. ## Where to look - `internal/db/sort.go` -- sort registry, `ParseSortSpec`/`ResolveSort`, cursor helpers - `internal/db/query_dialect.go` -- `OrderByClause` / `CursorPredicate` OR-expansion - the three `ListSessions` implementations (`internal/db`, `internal/postgres`, `internal/duckdb`) - `internal/server/huma_routes_sessions.go`, `internal/service/direct.go`, `cmd/agentsview/session_list.go` ## Limitations / possible follow-ups - No new indexes (same scoping as #739); at local-archive scale a scan+sort is sub-millisecond. - The CLI has no `--descending` flag mirroring HTTP's absolute `descending` fallback -- `--reverse` flips each bare key's own natural direction instead. Easy to add if transport symmetry is wanted. Co-authored-by: Matthew Jacobs <mjacobs@users.noreply.github.com>
1 parent 1ba339b commit 287144f

15 files changed

Lines changed: 1141 additions & 111 deletions

cmd/agentsview/session_list.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,32 @@ func newSessionListCommand() *cobra.Command {
6161
HasSecret: hasSecret,
6262
Cursor: cursor,
6363
Limit: limit,
64-
OrderBy: sort,
6564
}
6665
if cmd.Flags().Changed("min-tool-failures") {
6766
f.MinToolFailures = &minToolFailures
6867
}
69-
// --reverse flips the sort key's canonical direction; leave
70-
// Descending nil otherwise so the default applies.
71-
if cmd.Flags().Changed("reverse") {
72-
d := db.SortDefaultDescending(sort) != reverse
73-
f.Descending = &d
68+
// Parse the multi-key sort spec; --reverse flips the natural
69+
// direction of any term left without an explicit :asc/:desc, which
70+
// is folded into the canonical spec string so the wire form fully
71+
// captures the ordering.
72+
keys, err := db.ParseSortSpec(sort)
73+
if err != nil {
74+
return fmt.Errorf("invalid sort %q: %w", sort, err)
75+
}
76+
// An empty spec means the implicit default; materialize it so
77+
// --reverse has a term to flip instead of silently no-opping.
78+
if len(keys) == 0 {
79+
keys = []db.SortKey{{Key: db.DefaultSortKey()}}
80+
}
81+
if reverse {
82+
for i := range keys {
83+
if keys[i].Descending == nil {
84+
d := !db.SortDefaultDescending(keys[i].Key)
85+
keys[i].Descending = &d
86+
}
87+
}
7488
}
89+
f.OrderBy = db.FormatSortSpec(keys)
7590

7691
list, err := svc.List(cmd.Context(), f)
7792
if err != nil {
@@ -129,9 +144,12 @@ func newSessionListCommand() *cobra.Command {
129144
db.DefaultSessionLimit, db.MaxSessionLimit,
130145
))
131146
flags.StringVar(&sort, "sort", "recent",
132-
"Sort by: "+strings.Join(db.SortKeys(), ", "))
147+
"Sort by a comma-separated list of keys, each optionally key:asc or "+
148+
"key:desc (e.g. messages:desc,started:asc). Keys: "+
149+
strings.Join(db.SortKeys(), ", "))
133150
flags.BoolVarP(&reverse, "reverse", "r", false,
134-
"Reverse the sort direction")
151+
"Reverse the natural direction of sort keys that have no explicit "+
152+
":asc/:desc suffix")
135153

136154
return cmd
137155
}

cmd/agentsview/sort_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,62 @@ func TestSessionList_SortAndReverse(t *testing.T) {
5353
assert.Equal(t, []string{"hi", "mid", "lo"}, sessionListIDs(t, out))
5454
}
5555

56+
func TestSessionList_MultiKeySort(t *testing.T) {
57+
dataDir := t.TempDir()
58+
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
59+
seedSessionWithOpts(t, dataDir, "a", "p", func(s *db.Session) {
60+
s.MessageCount = 1
61+
s.StartedAt = new("2024-03-01T00:00:00Z")
62+
})
63+
seedSessionWithOpts(t, dataDir, "b", "p", func(s *db.Session) {
64+
s.MessageCount = 1
65+
s.StartedAt = new("2024-01-01T00:00:00Z")
66+
})
67+
seedSessionWithOpts(t, dataDir, "c", "p", func(s *db.Session) {
68+
s.MessageCount = 2
69+
s.StartedAt = new("2024-02-01T00:00:00Z")
70+
})
71+
72+
// Per-key directions: messages asc, then started desc.
73+
out, err := executeCommand(newRootCommand(),
74+
"session", "list", "--sort", "messages:asc,started:desc", "--format", "json")
75+
require.NoError(t, err)
76+
assert.Equal(t, []string{"a", "b", "c"}, sessionListIDs(t, out))
77+
78+
// --reverse flips only the unsuffixed key (messages -> desc); the explicit
79+
// started:asc is left untouched.
80+
out, err = executeCommand(newRootCommand(),
81+
"session", "list", "--sort", "messages,started:asc", "-r", "--format", "json")
82+
require.NoError(t, err)
83+
assert.Equal(t, []string{"c", "b", "a"}, sessionListIDs(t, out))
84+
}
85+
86+
// TestSessionList_EmptySortReverse guards the edge where --sort is explicitly
87+
// cleared: --reverse must still flip the implicit default recent sort (to
88+
// ascending) rather than silently no-opping.
89+
func TestSessionList_EmptySortReverse(t *testing.T) {
90+
dataDir := t.TempDir()
91+
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
92+
seedSessionWithOpts(t, dataDir, "old", "p", func(s *db.Session) {
93+
s.EndedAt = new("2024-01-01T00:00:00Z")
94+
})
95+
seedSessionWithOpts(t, dataDir, "new", "p", func(s *db.Session) {
96+
s.EndedAt = new("2024-03-01T00:00:00Z")
97+
})
98+
99+
// Default recent is newest-first.
100+
out, err := executeCommand(newRootCommand(),
101+
"session", "list", "--sort", "", "--format", "json")
102+
require.NoError(t, err)
103+
assert.Equal(t, []string{"new", "old"}, sessionListIDs(t, out))
104+
105+
// --reverse on the empty (default) sort flips recent to oldest-first.
106+
out, err = executeCommand(newRootCommand(),
107+
"session", "list", "--sort", "", "--reverse", "--format", "json")
108+
require.NoError(t, err)
109+
assert.Equal(t, []string{"old", "new"}, sessionListIDs(t, out))
110+
}
111+
56112
func TestSessionList_InvalidSort(t *testing.T) {
57113
dataDir := t.TempDir()
58114
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)

internal/db/query_dialect.go

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -253,40 +253,65 @@ func (d QueryDialect) timestampExpr(col string) string {
253253
return col
254254
}
255255

256-
// OrderByClause renders the session-list ordering for a resolved sort and
257-
// direction, with id as a same-direction tie-breaker so keyset pagination is
258-
// deterministic. The sort expression may add bind parameters (secrets sort), so
256+
// OrderByClause renders the session-list ordering for the resolved sort terms,
257+
// each in its own direction, with id appended as a unique same-direction
258+
// tie-breaker (unless id is already a sort term) so keyset pagination is
259+
// deterministic. Sort expressions may add bind parameters (the secrets sort), so
259260
// callers must render this at its textual position.
260-
func (b *QueryBuilder) OrderByClause(sp SessionSort, desc bool, f SessionFilter) string {
261-
orderExpr := sp.orderExpr(b, desc, f)
262-
dir := "ASC"
263-
if desc {
264-
dir = "DESC"
265-
}
266-
if orderExpr == "id" {
267-
return "ORDER BY id " + dir
261+
func (b *QueryBuilder) OrderByClause(rs []ResolvedSort, f SessionFilter) string {
262+
cols := appendIDTiebreaker(rs)
263+
parts := make([]string, len(cols))
264+
for i, c := range cols {
265+
parts[i] = c.Sort.orderExpr(b, c.Desc, f) + " " + orderDirSQL(c.Desc)
268266
}
269-
return "ORDER BY " + orderExpr + " " + dir + ", id " + dir
267+
return "ORDER BY " + strings.Join(parts, ", ")
270268
}
271269

272270
// CursorPredicate renders the keyset pagination predicate matching an
273-
// OrderByClause built from the same sort and direction. The bound value is cast
274-
// per dialect for the column's kind; it must already be the Go type produced by
275-
// SessionSort.CursorPredicateValue.
271+
// OrderByClause built from the same sort terms. Because per-key directions may
272+
// differ, the predicate is the lexicographic expansion
273+
//
274+
// (c1 OP1 v1) OR (c1 = v1 AND c2 OP2 v2) OR ...
275+
//
276+
// rather than a single row-value comparison (which is only valid when every
277+
// column shares one direction). Each value is bound and cast per dialect for its
278+
// column kind, and must already be the Go type produced by CursorPredicateValues
279+
// (one value per resolved term, in order). Sort expressions are re-rendered for
280+
// each clause they appear in so any bind parameters they add (the secrets sort)
281+
// stay positionally aligned across dialects.
276282
func (b *QueryBuilder) CursorPredicate(
277-
sp SessionSort, desc bool, f SessionFilter, value any, id string,
283+
rs []ResolvedSort, f SessionFilter, values []any, id string,
278284
) string {
279-
orderExpr := sp.orderExpr(b, desc, f)
280-
op := ">"
281-
if desc {
282-
op = "<"
285+
cols := appendIDTiebreaker(rs)
286+
vals := values
287+
if len(cols) > len(rs) {
288+
vals = append(append(make([]any, 0, len(cols)), values...), id)
289+
}
290+
clauses := make([]string, 0, len(cols))
291+
for j := range cols {
292+
parts := make([]string, 0, j+1)
293+
for i := range j {
294+
e := cols[i].Sort.orderExpr(b, cols[i].Desc, f)
295+
vp := b.dialect.castCursor(b.Add(vals[i]), cols[i].Sort.kind)
296+
parts = append(parts, e+" = "+vp)
297+
}
298+
op := ">"
299+
if cols[j].Desc {
300+
op = "<"
301+
}
302+
e := cols[j].Sort.orderExpr(b, cols[j].Desc, f)
303+
vp := b.dialect.castCursor(b.Add(vals[j]), cols[j].Sort.kind)
304+
parts = append(parts, e+" "+op+" "+vp)
305+
clauses = append(clauses, "("+strings.Join(parts, " AND ")+")")
283306
}
284-
if orderExpr == "id" {
285-
return "id " + op + " " + b.dialect.castCursor(b.Add(id), kindText)
307+
return "(" + strings.Join(clauses, " OR ") + ")"
308+
}
309+
310+
func orderDirSQL(desc bool) string {
311+
if desc {
312+
return "DESC"
286313
}
287-
vp := b.dialect.castCursor(b.Add(value), sp.kind)
288-
ip := b.Add(id)
289-
return "(" + orderExpr + ", id) " + op + " (" + vp + ", " + ip + ")"
314+
return "ASC"
290315
}
291316

292317
// LimitOffset renders a parameterized LIMIT/OFFSET clause.

internal/db/sessions.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,33 @@ type SessionCursor struct {
341341
// Value is the sort column's value for the page's last row, encoded as a
342342
// string and re-typed per the sort's kind when comparing.
343343
Value string `json:"v,omitempty"`
344+
// Keys carries one keyset term per column for multi-key sorts. When present
345+
// it is authoritative; the single-key Sort/Desc/Value (and EndedAt) fields
346+
// are only populated for single-key sorts so older readers still decode.
347+
Keys []SessionCursorKey `json:"ks,omitempty"`
348+
}
349+
350+
// SessionCursorKey is one column's keyset term inside a multi-key cursor: the
351+
// sort key it was minted under, its direction, and the page's last-row value
352+
// (re-typed per the sort's kind when comparing).
353+
type SessionCursorKey struct {
354+
Sort string `json:"k"`
355+
Desc bool `json:"d,omitempty"`
356+
Value string `json:"v,omitempty"`
357+
}
358+
359+
// resolvedKeys returns the cursor's keyset terms, synthesizing the single-key
360+
// list from the legacy fields when the multi-key Keys slice is absent. A cursor
361+
// with neither Keys nor Sort is a pre-sort legacy token, valid only for the
362+
// default recent-descending order it was always minted under.
363+
func (cur SessionCursor) resolvedKeys() []SessionCursorKey {
364+
if len(cur.Keys) > 0 {
365+
return cur.Keys
366+
}
367+
if cur.Sort != "" {
368+
return []SessionCursorKey{{Sort: cur.Sort, Desc: cur.Desc, Value: cur.Value}}
369+
}
370+
return []SessionCursorKey{{Sort: defaultSortKey, Desc: true, Value: cur.EndedAt}}
344371
}
345372

346373
// EncodeCursor returns a base64-encoded, HMAC-signed cursor string.
@@ -443,10 +470,17 @@ type SessionFilter struct {
443470
// "unclean" → only sessions with status IN
444471
// ('tool_call_pending', 'truncated')
445472
Termination string
446-
// OrderBy selects the sort column ("" = recent activity, the default).
447-
// Valid keys are enumerated by SortKeys / ValidSortKey.
473+
// Sort is the ordered, structured sort specification: each term is a sort
474+
// key with an optional per-key direction. When non-empty it is the canonical
475+
// source of ordering and takes precedence over OrderBy/Descending. This is
476+
// the field new callers should set to express per-key sort direction.
477+
Sort []SortKey
478+
// OrderBy is the legacy single-key shorthand, kept for existing callers. It
479+
// accepts the same comma-separated "key:dir" spec ParseSortSpec parses and is
480+
// used only when Sort is empty. "" means recent activity, the default.
448481
OrderBy string
449-
// Descending overrides the sort key's canonical direction when non-nil.
482+
// Descending is the legacy fallback direction applied to OrderBy terms that
483+
// carry no explicit direction. Used only when Sort is empty.
450484
Descending *bool
451485
}
452486

@@ -560,8 +594,7 @@ func (db *DB) ListSessions(
560594
where, args := buildSessionFilter(f)
561595

562596
dialect := SQLiteQueryDialect()
563-
sp, _ := SessionSortFor(f.OrderBy)
564-
desc := sp.ResolveDescending(f.Descending)
597+
rs := ResolveSort(f)
565598

566599
var total int
567600
var cur SessionCursor
@@ -591,18 +624,18 @@ func (db *DB) ListSessions(
591624
pageBuilder := NewQueryBuilder(dialect, len(args))
592625
cursorWhere := where
593626
if f.Cursor != "" {
594-
val, err := sp.CursorPredicateValue(cur, desc)
627+
vals, err := CursorPredicateValues(cur, rs)
595628
if err != nil {
596629
return SessionPage{}, err
597630
}
598631
cursorWhere += " AND " + pageBuilder.CursorPredicate(
599-
sp, desc, f, val, cur.ID,
632+
rs, f, vals, cur.ID,
600633
)
601634
}
602635

603636
query := "SELECT " + sessionBaseCols +
604637
" FROM sessions WHERE " + cursorWhere + " " +
605-
pageBuilder.OrderByClause(sp, desc, f) + " " +
638+
pageBuilder.OrderByClause(rs, f) + " " +
606639
pageBuilder.Limit(f.Limit+1)
607640
cursorArgs = append(cursorArgs, pageBuilder.Args()...)
608641

@@ -623,7 +656,7 @@ func (db *DB) ListSessions(
623656
page.Sessions = sessions[:f.Limit]
624657
last := page.Sessions[f.Limit-1]
625658
page.NextCursor = db.EncodeCursor(
626-
sp.NextCursor(&last, desc, total, f),
659+
NextSessionCursor(&last, rs, total, f),
627660
)
628661
}
629662

0 commit comments

Comments
 (0)