f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
487 lines
17 KiB
Go
487 lines
17 KiB
Go
// ABOUTME: session-list sort registry — maps the public --sort/?order_by keys
|
|
// ABOUTME: to dialect-aware ORDER BY expressions and keyset cursor values.
|
|
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// valueKind classifies a sort column's value so the keyset cursor can bind it
|
|
// with the right Go type and per-dialect cast. SQLite relies on this to avoid
|
|
// type-affinity surprises (an integer column compared against a bound string
|
|
// always sorts below it); PostgreSQL relies on it for ::bigint / ::timestamptz
|
|
// casts on numbered placeholders.
|
|
type valueKind int
|
|
|
|
const (
|
|
kindTimestamp valueKind = iota // ISO-8601 text / timestamptz
|
|
kindInt
|
|
kindReal
|
|
kindText
|
|
)
|
|
|
|
// Sentinels make a nullable sort column non-null inside the keyset comparison
|
|
// so the row-value predicate is always well-defined and NULLs land last in both
|
|
// directions, identically across backends. They sit far outside any real value;
|
|
// the SQL literal and the Go value must be numerically equal (string form is
|
|
// internal to the cursor token).
|
|
const (
|
|
sentinelIntDescSQL = "-9223372036854775808" // math.MinInt64
|
|
sentinelIntAscSQL = "9223372036854775807" // math.MaxInt64
|
|
sentinelRealDescSQL = "-1e18"
|
|
sentinelRealAscSQL = "1e18"
|
|
)
|
|
|
|
// SessionSort describes one orderable column for `session list`. It is exported
|
|
// so the PostgreSQL and DuckDB stores render ordering and keyset pagination from
|
|
// this single source of truth rather than duplicating ORDER BY strings.
|
|
type SessionSort struct {
|
|
key string
|
|
kind valueKind
|
|
defaultDescending bool
|
|
// nullable marks columns with no natural non-null fallback (health,
|
|
// context-pressure); their order expression is wrapped in a direction-aware
|
|
// sentinel COALESCE so NULLs sort last.
|
|
nullable bool
|
|
// expr renders the (possibly nullable) SQL expression for this sort. It
|
|
// receives the builder so the secrets sort can add bind parameters for its
|
|
// version-aware CASE, and the filter so it can mirror the active scanner
|
|
// rule versions. Timestamp sorts use the dialect's empty-string-aware
|
|
// wrapping; most sorts are a plain column name shared across backends.
|
|
expr func(b *QueryBuilder, f SessionFilter) string
|
|
// value returns the row's sort value formatted as the cursor stores it, plus
|
|
// ok=false when the underlying column is NULL. It receives the filter so the
|
|
// secrets sort encodes the same version-gated value it sorts by.
|
|
value func(s *Session, f SessionFilter) (string, bool)
|
|
}
|
|
|
|
// orderExpr renders the non-null SQL expression used in both ORDER BY and the
|
|
// keyset cursor predicate for the resolved direction. It may add bind
|
|
// parameters to b, so callers must render it at its textual position.
|
|
func (sp SessionSort) orderExpr(b *QueryBuilder, desc bool, f SessionFilter) string {
|
|
e := sp.expr(b, f)
|
|
if sp.nullable {
|
|
e = "COALESCE(" + e + ", " + sentinelLiteral(sp.kind, desc) + ")"
|
|
}
|
|
return e
|
|
}
|
|
|
|
// cursorValue returns the string-encoded comparison value for a page's last
|
|
// row, substituting the matching sentinel when the column is NULL.
|
|
func (sp SessionSort) cursorValue(s *Session, desc bool, f SessionFilter) string {
|
|
if v, ok := sp.value(s, f); ok {
|
|
return v
|
|
}
|
|
return sentinelGoString(sp.kind, desc)
|
|
}
|
|
|
|
// ResolveDescending returns the effective direction: the key's canonical default
|
|
// unless the caller supplied an explicit override.
|
|
func (sp SessionSort) ResolveDescending(descending *bool) bool {
|
|
if descending != nil {
|
|
return *descending
|
|
}
|
|
return sp.defaultDescending
|
|
}
|
|
|
|
// SortKey is one ordered sort term for a SessionFilter: a registry key with an
|
|
// optional direction override. A nil Descending means the key's canonical
|
|
// default direction applies (recent is descending; every other key ascending).
|
|
type SortKey struct {
|
|
Key string
|
|
Descending *bool
|
|
}
|
|
|
|
// ResolvedSort pairs a registered SessionSort with the concrete direction
|
|
// resolved for one term, after applying any per-key override or fallback.
|
|
type ResolvedSort struct {
|
|
Sort SessionSort
|
|
Desc bool
|
|
}
|
|
|
|
// ParseSortSpec parses the public sort specification used by --sort and
|
|
// ?order_by: a comma-separated list of terms, each "key" or "key:asc"/"key:desc".
|
|
// An empty spec yields no terms (callers apply the default). Unknown keys,
|
|
// unknown direction tokens, duplicate keys, and empty terms are reported as
|
|
// errors so the service layer can reject bad input with a clear message.
|
|
func ParseSortSpec(spec string) ([]SortKey, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return nil, nil
|
|
}
|
|
parts := strings.Split(spec, ",")
|
|
keys := make([]SortKey, 0, len(parts))
|
|
seen := make(map[string]bool, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
return nil, fmt.Errorf("empty sort term")
|
|
}
|
|
key := part
|
|
var dir *bool
|
|
if before, after, ok := strings.Cut(part, ":"); ok {
|
|
key = strings.TrimSpace(before)
|
|
switch strings.TrimSpace(after) {
|
|
case "asc":
|
|
d := false
|
|
dir = &d
|
|
case "desc":
|
|
d := true
|
|
dir = &d
|
|
default:
|
|
return nil, fmt.Errorf(
|
|
"invalid sort direction in %q: want asc or desc", part)
|
|
}
|
|
}
|
|
if _, ok := sessionSortByKey[key]; !ok {
|
|
return nil, fmt.Errorf("unknown sort key %q", key)
|
|
}
|
|
if seen[key] {
|
|
return nil, fmt.Errorf("duplicate sort key %q", key)
|
|
}
|
|
seen[key] = true
|
|
keys = append(keys, SortKey{Key: key, Descending: dir})
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// FormatSortSpec renders sort terms back into the canonical --sort/?order_by
|
|
// string. A term with an explicit direction renders "key:asc"/"key:desc"; a term
|
|
// left at its canonical default renders the bare key.
|
|
func FormatSortSpec(keys []SortKey) string {
|
|
parts := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
switch {
|
|
case k.Descending == nil:
|
|
parts[i] = k.Key
|
|
case *k.Descending:
|
|
parts[i] = k.Key + ":desc"
|
|
default:
|
|
parts[i] = k.Key + ":asc"
|
|
}
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// ApplyFallbackDirection fills in the direction of any term left unspecified
|
|
// (the legacy descending param / single-key default). Terms that carry an
|
|
// explicit per-key direction are untouched. A nil fallback leaves every
|
|
// unspecified term at its canonical default.
|
|
func ApplyFallbackDirection(keys []SortKey, descending *bool) []SortKey {
|
|
if descending == nil {
|
|
return keys
|
|
}
|
|
out := make([]SortKey, len(keys))
|
|
for i, k := range keys {
|
|
if k.Descending == nil {
|
|
d := *descending
|
|
k.Descending = &d
|
|
}
|
|
out[i] = k
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ResolveSort turns a filter's sort specification into the ordered list of
|
|
// columns the query is sorted by. The structured Sort field wins; otherwise the
|
|
// legacy OrderBy string (folded with Descending) is parsed. Unknown or duplicate
|
|
// keys are dropped defensively so a store never panics on unvalidated input, and
|
|
// an empty result falls back to the default recent sort. Callers that must
|
|
// reject bad input (the service layer) should validate via ParseSortSpec first.
|
|
func ResolveSort(f SessionFilter) []ResolvedSort {
|
|
terms := f.Sort
|
|
if len(terms) == 0 {
|
|
parsed, err := ParseSortSpec(f.OrderBy)
|
|
if err != nil {
|
|
parsed = nil
|
|
}
|
|
terms = ApplyFallbackDirection(parsed, f.Descending)
|
|
}
|
|
rs := make([]ResolvedSort, 0, len(terms))
|
|
seen := make(map[string]bool, len(terms))
|
|
for _, t := range terms {
|
|
sp, ok := sessionSortByKey[t.Key]
|
|
if !ok || seen[sp.key] {
|
|
continue
|
|
}
|
|
seen[sp.key] = true
|
|
rs = append(rs, ResolvedSort{Sort: sp, Desc: sp.ResolveDescending(t.Descending)})
|
|
}
|
|
if len(rs) == 0 {
|
|
sp := sessionSortByKey[defaultSortKey]
|
|
desc := sp.defaultDescending
|
|
// Preserve the single-key behavior where a legacy Descending override
|
|
// applies to the implicit default recent key (e.g. descending=false with
|
|
// no order_by means recent ascending).
|
|
if len(f.Sort) == 0 && f.Descending != nil {
|
|
desc = *f.Descending
|
|
}
|
|
rs = append(rs, ResolvedSort{Sort: sp, Desc: desc})
|
|
}
|
|
return rs
|
|
}
|
|
|
|
// appendIDTiebreaker ensures the ordered columns end with the unique id column
|
|
// so keyset pagination is deterministic. When id is already an explicit sort
|
|
// term the list is returned unchanged; otherwise id is appended in the last
|
|
// term's direction (the tie-breaker behavior id has always had).
|
|
func appendIDTiebreaker(rs []ResolvedSort) []ResolvedSort {
|
|
for _, r := range rs {
|
|
if r.Sort.key == "id" {
|
|
return rs
|
|
}
|
|
}
|
|
out := make([]ResolvedSort, len(rs), len(rs)+1)
|
|
copy(out, rs)
|
|
return append(out, ResolvedSort{Sort: sessionSortByKey["id"], Desc: rs[len(rs)-1].Desc})
|
|
}
|
|
|
|
// NextSessionCursor builds the pagination token for a page's last row under the
|
|
// resolved multi-key sort. Each sort term contributes a typed keyset value. For
|
|
// a single-key sort the legacy Sort/Desc/Value fields (and EndedAt for the
|
|
// default recent sort) are also populated so cursors stay decodable by readers
|
|
// from before multi-key sorting existed.
|
|
func NextSessionCursor(last *Session, rs []ResolvedSort, total int, f SessionFilter) SessionCursor {
|
|
cur := SessionCursor{ID: last.ID, Total: total}
|
|
cur.Keys = make([]SessionCursorKey, len(rs))
|
|
for i, r := range rs {
|
|
cur.Keys[i] = SessionCursorKey{
|
|
Sort: r.Sort.key,
|
|
Desc: r.Desc,
|
|
Value: r.Sort.cursorValue(last, r.Desc, f),
|
|
}
|
|
}
|
|
if len(rs) == 1 {
|
|
k := cur.Keys[0]
|
|
cur.Sort = k.Sort
|
|
cur.Desc = k.Desc
|
|
cur.Value = k.Value
|
|
if k.Sort == defaultSortKey && k.Desc {
|
|
cur.EndedAt = k.Value
|
|
}
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// CursorPredicateValues validates a decoded cursor against the resolved sort and
|
|
// returns the typed keyset values, one per term, that the predicate must bind. A
|
|
// cursor minted under a different sort (different keys, order, or directions), or
|
|
// carrying an unparseable value, is rejected as an invalid cursor rather than
|
|
// silently producing wrong pages.
|
|
func CursorPredicateValues(cur SessionCursor, rs []ResolvedSort) ([]any, error) {
|
|
keys := cur.resolvedKeys()
|
|
if len(keys) != len(rs) {
|
|
return nil, fmt.Errorf("%w: sort mismatch", ErrInvalidCursor)
|
|
}
|
|
vals := make([]any, len(rs))
|
|
for i, r := range rs {
|
|
if keys[i].Sort != r.Sort.key || keys[i].Desc != r.Desc {
|
|
return nil, fmt.Errorf("%w: sort mismatch", ErrInvalidCursor)
|
|
}
|
|
v, err := typedCursorValue(keys[i].Value, r.Sort.kind)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidCursor, err)
|
|
}
|
|
vals[i] = v
|
|
}
|
|
return vals, nil
|
|
}
|
|
|
|
func sentinelLiteral(kind valueKind, desc bool) string {
|
|
switch kind {
|
|
case kindReal:
|
|
if desc {
|
|
return sentinelRealDescSQL
|
|
}
|
|
return sentinelRealAscSQL
|
|
default:
|
|
if desc {
|
|
return sentinelIntDescSQL
|
|
}
|
|
return sentinelIntAscSQL
|
|
}
|
|
}
|
|
|
|
// sentinelGoString returns the cursor-encoded form of the sentinel, numerically
|
|
// equal to sentinelLiteral so a NULL row's cursor compares equal to a NULL row's
|
|
// COALESCE result on the next page.
|
|
func sentinelGoString(kind valueKind, desc bool) string {
|
|
switch kind {
|
|
case kindReal:
|
|
if desc {
|
|
return strconv.FormatFloat(-1e18, 'g', -1, 64)
|
|
}
|
|
return strconv.FormatFloat(1e18, 'g', -1, 64)
|
|
default:
|
|
if desc {
|
|
return strconv.FormatInt(math.MinInt64, 10)
|
|
}
|
|
return strconv.FormatInt(math.MaxInt64, 10)
|
|
}
|
|
}
|
|
|
|
// typedCursorValue parses a cursor's string value into the Go type the keyset
|
|
// comparison must bind for this kind. A malformed value is reported so callers
|
|
// can surface ErrInvalidCursor rather than a query error.
|
|
func typedCursorValue(value string, kind valueKind) (any, error) {
|
|
switch kind {
|
|
case kindInt:
|
|
return strconv.ParseInt(value, 10, 64)
|
|
case kindReal:
|
|
return strconv.ParseFloat(value, 64)
|
|
default:
|
|
return value, nil
|
|
}
|
|
}
|
|
|
|
func tsValue(s *Session, _ SessionFilter) (string, bool) {
|
|
v := s.CreatedAt
|
|
if s.StartedAt != nil && *s.StartedAt != "" {
|
|
v = *s.StartedAt
|
|
}
|
|
if s.EndedAt != nil && *s.EndedAt != "" {
|
|
v = *s.EndedAt
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func startedValue(s *Session, _ SessionFilter) (string, bool) {
|
|
if s.StartedAt != nil && *s.StartedAt != "" {
|
|
return *s.StartedAt, true
|
|
}
|
|
return s.CreatedAt, true
|
|
}
|
|
|
|
func intValue(get func(*Session) int) func(*Session, SessionFilter) (string, bool) {
|
|
return func(s *Session, _ SessionFilter) (string, bool) {
|
|
return strconv.Itoa(get(s)), true
|
|
}
|
|
}
|
|
|
|
func recentExpr(b *QueryBuilder, _ SessionFilter) string {
|
|
return "COALESCE(" + b.dialect.timestampExpr("ended_at") + ", " +
|
|
b.dialect.timestampExpr("started_at") + ", created_at)"
|
|
}
|
|
|
|
func startedExpr(b *QueryBuilder, _ SessionFilter) string {
|
|
return "COALESCE(" + b.dialect.timestampExpr("started_at") + ", created_at)"
|
|
}
|
|
|
|
func plainExpr(col string) func(*QueryBuilder, SessionFilter) string {
|
|
return func(*QueryBuilder, SessionFilter) string { return col }
|
|
}
|
|
|
|
// secretsExpr orders by the same secret count the service layer displays: when
|
|
// active scanner rule versions are known, counts from stale versions are gated
|
|
// to 0 (matching hideStaleSecretCount), so sessions shown with 0 secrets do not
|
|
// rank above sessions with current findings. With no versions (raw db callers),
|
|
// it falls back to the raw column, mirroring the HasSecret filter's convention.
|
|
func secretsExpr(b *QueryBuilder, f SessionFilter) string {
|
|
versions := nonEmpty(f.SecretsRulesVersions)
|
|
if len(versions) == 0 {
|
|
return "secret_leak_count"
|
|
}
|
|
return "CASE WHEN " + inPredicate("secrets_rules_version", versions, b) +
|
|
" THEN secret_leak_count ELSE 0 END"
|
|
}
|
|
|
|
func secretsValue(s *Session, f SessionFilter) (string, bool) {
|
|
n := s.SecretLeakCount
|
|
versions := nonEmpty(f.SecretsRulesVersions)
|
|
if len(versions) > 0 && !slices.Contains(versions, s.SecretsRulesVersion) {
|
|
n = 0
|
|
}
|
|
return strconv.Itoa(n), true
|
|
}
|
|
|
|
// sessionSorts is the allow-list of session-list sort keys, in display order.
|
|
// The keys here are kept documented on the huma order_by param by
|
|
// TestSortKeysDocumented / TestSortKeysDocOmitsStaleKeys (internal/server).
|
|
var sessionSorts = []SessionSort{
|
|
{key: "recent", kind: kindTimestamp, defaultDescending: true, expr: recentExpr, value: tsValue},
|
|
{key: "started", kind: kindTimestamp, expr: startedExpr, value: startedValue},
|
|
{key: "messages", kind: kindInt, expr: plainExpr("message_count"), value: intValue(func(s *Session) int { return s.MessageCount })},
|
|
{key: "user-messages", kind: kindInt, expr: plainExpr("user_message_count"), value: intValue(func(s *Session) int { return s.UserMessageCount })},
|
|
{key: "output-tokens", kind: kindInt, expr: plainExpr("total_output_tokens"), value: intValue(func(s *Session) int { return s.TotalOutputTokens })},
|
|
{key: "peak-context", kind: kindInt, expr: plainExpr("peak_context_tokens"), value: intValue(func(s *Session) int { return s.PeakContextTokens })},
|
|
{key: "failures", kind: kindInt, expr: plainExpr("tool_failure_signal_count"), value: intValue(func(s *Session) int { return s.ToolFailureSignalCount })},
|
|
{key: "retries", kind: kindInt, expr: plainExpr("tool_retry_count"), value: intValue(func(s *Session) int { return s.ToolRetryCount })},
|
|
{key: "edit-churn", kind: kindInt, expr: plainExpr("edit_churn_count"), value: intValue(func(s *Session) int { return s.EditChurnCount })},
|
|
{key: "compactions", kind: kindInt, expr: plainExpr("compaction_count"), value: intValue(func(s *Session) int { return s.CompactionCount })},
|
|
{key: "context-pressure", kind: kindReal, nullable: true, expr: plainExpr("context_pressure_max"), value: func(s *Session, _ SessionFilter) (string, bool) {
|
|
if s.ContextPressureMax == nil {
|
|
return "", false
|
|
}
|
|
return strconv.FormatFloat(*s.ContextPressureMax, 'g', -1, 64), true
|
|
}},
|
|
{key: "health", kind: kindInt, nullable: true, expr: plainExpr("health_score"), value: func(s *Session, _ SessionFilter) (string, bool) {
|
|
if s.HealthScore == nil {
|
|
return "", false
|
|
}
|
|
return strconv.Itoa(*s.HealthScore), true
|
|
}},
|
|
{key: "secrets", kind: kindInt, expr: secretsExpr, value: secretsValue},
|
|
{key: "id", kind: kindText, expr: plainExpr("id"), value: func(s *Session, _ SessionFilter) (string, bool) { return s.ID, true }},
|
|
}
|
|
|
|
var sessionSortByKey = func() map[string]SessionSort {
|
|
m := make(map[string]SessionSort, len(sessionSorts))
|
|
for _, s := range sessionSorts {
|
|
m[s.key] = s
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// defaultSortKey is the sort applied when OrderBy is empty; it preserves the
|
|
// historical most-recent-activity-first behavior.
|
|
const defaultSortKey = "recent"
|
|
|
|
// DefaultSortKey returns the sort key applied when no sort is requested. The CLI
|
|
// uses it to materialize the implicit default so --reverse can flip it even when
|
|
// --sort is empty.
|
|
func DefaultSortKey() string { return defaultSortKey }
|
|
|
|
// SessionSortFor resolves a sort key (empty means the default). ok is false for
|
|
// unknown keys; callers that have not pre-validated should treat that as the
|
|
// default rather than erroring, so the store never panics on bad input.
|
|
func SessionSortFor(key string) (SessionSort, bool) {
|
|
if key == "" {
|
|
return sessionSortByKey[defaultSortKey], true
|
|
}
|
|
sp, ok := sessionSortByKey[key]
|
|
if !ok {
|
|
return sessionSortByKey[defaultSortKey], false
|
|
}
|
|
return sp, true
|
|
}
|
|
|
|
// ValidSortKey reports whether key is an accepted session-list sort (empty is
|
|
// accepted and means the default).
|
|
func ValidSortKey(key string) bool {
|
|
if key == "" {
|
|
return true
|
|
}
|
|
_, ok := sessionSortByKey[key]
|
|
return ok
|
|
}
|
|
|
|
// SortDefaultDescending returns the canonical direction for a sort key, used by
|
|
// the CLI to translate --reverse (flip) into an absolute Descending value.
|
|
func SortDefaultDescending(key string) bool {
|
|
sp, _ := SessionSortFor(key)
|
|
return sp.defaultDescending
|
|
}
|
|
|
|
// SortKeys returns the accepted sort keys in display order.
|
|
func SortKeys() []string {
|
|
keys := make([]string, len(sessionSorts))
|
|
for i, s := range sessionSorts {
|
|
keys[i] = s.key
|
|
}
|
|
return keys
|
|
}
|