cctally 1.43.3 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.44.0] - 2026-06-14
9
+
10
+ ### Changed
11
+ - Conversation viewer: the dashboard conversation browse-rail is now served from a `conversation_sessions` rollup (a small per-session COUNT/MIN/MAX table maintained inside `sync_cache` under the cache lock, indexed for the recent ordering) instead of a full-table `GROUP BY` over every message on each page load, so the rail stays fast as your history grows and when a backgrounded tab refocuses after queuing SSE ticks; the rollup is re-derivable like the rest of `cache.db` (a one-time backfill arms automatically on the next sync via cache migration `013`, with a live `GROUP BY` fallback until it lands, so the rail's output is byte-identical either way), and the rail now pauses its per-tick page-1 refetch while the tab is hidden, firing exactly one refetch on the hidden→visible transition so a freshly-revealed reader is current without re-querying every five seconds while idle.
12
+
8
13
  ## [1.43.3] - 2026-06-14
9
14
 
10
15
  ### Added
@@ -714,6 +714,15 @@ def sync_cache(
714
714
  "DELETE FROM cache_meta WHERE key IN "
715
715
  "('conversation_promote_command_args_pending',"
716
716
  " 'conversation_promote_command_args_cursor')")
717
+ # Browse-rail rollup: a rebuild re-derives conversation_messages from
718
+ # offset 0, so wipe the rollup here (in the same destructive txn,
719
+ # alongside clear_conversation_messages, so a crash-recovery read
720
+ # can't surface ghost rows) and arm the durable backfill flag. The
721
+ # post-walk recompute (after the per-file loop, still under the
722
+ # flock) consumes the flag and rebuilds the rollup from the freshly
723
+ # re-ingested messages, then drops it last (crash-safe).
724
+ conn.execute("DELETE FROM conversation_sessions")
725
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
717
726
  conn.commit()
718
727
  eprint("[cache-sync] rebuild: cleared Claude cached entries")
719
728
 
@@ -747,6 +756,12 @@ def sync_cache(
747
756
  "DELETE FROM cache_meta "
748
757
  "WHERE key='conversation_backfill_pending'"
749
758
  )
759
+ # Browse-rail rollup: a #139 offset-0 backfill bulk-inserts
760
+ # history into conversation_messages, so arm the durable
761
+ # recompute flag (idempotent; covers a partial-migration state
762
+ # where the rollup is empty but messages just landed). The
763
+ # post-walk recompute rebuilds it and drops the flag last.
764
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
750
765
  conn.commit()
751
766
 
752
767
  # #193: consume the deferred ai-title backfill. Cache migration 012 is
@@ -825,6 +840,13 @@ def sync_cache(
825
840
  # three flags + cursor + gen atomically on completion. Never on the rebuild
826
841
  # path (which already wipes + repopulates id-aware via the normal walk).
827
842
  _resumable_reingest_conversation_messages(conn)
843
+ # Browse-rail rollup: a #179 reingest DELETEs + re-inserts every
844
+ # file's conversation_messages rows (bumping autoincrement ids and
845
+ # potentially MIN/MAX), so arm the durable recompute flag
846
+ # (idempotent; covers a partial-migration state). The post-walk
847
+ # recompute rebuilds the rollup and drops the flag last.
848
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
849
+ conn.commit()
828
850
 
829
851
  # #177 S6: consume the migration-010 search-column split under the
830
852
  # SAME held flock, AFTER the reingest so any just-re-ingested rows
@@ -975,6 +997,13 @@ def sync_cache(
975
997
  conn.execute(
976
998
  "UPDATE session_files SET size_bytes = 0, last_byte_offset = 0"
977
999
  )
1000
+ # Browse-rail rollup: truncation escalates to a full offset-0
1001
+ # re-ingest of conversation_messages, so wipe the rollup here (in the
1002
+ # same destructive txn, alongside clear_conversation_messages) and
1003
+ # arm the durable backfill flag. The post-walk recompute rebuilds it
1004
+ # from the re-ingested messages and drops the flag last (crash-safe).
1005
+ conn.execute("DELETE FROM conversation_sessions")
1006
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
978
1007
  conn.commit()
979
1008
  stats.files_reset_truncated += len(truncated_paths)
980
1009
  # Force every file to re-ingest from offset 0: clearing the
@@ -984,6 +1013,14 @@ def sync_cache(
984
1013
  # avoids a redundant per-file DELETE that would be a no-op).
985
1014
  existing = {}
986
1015
 
1016
+ # Browse-rail rollup: accumulate the session_ids whose
1017
+ # conversation_messages this walk touched, so the post-walk recompute can
1018
+ # scope its DELETE+INSERT re-derive to just those sessions (steady
1019
+ # state). Pure Python —
1020
+ # updated only AFTER each per-file conn.commit() below, never inside the
1021
+ # zero-write-lock read/parse region, so it adds no DML there.
1022
+ touched_sessions: set = set()
1023
+
987
1024
  for jp in paths:
988
1025
  path_str = str(jp)
989
1026
  # Backfill session_id/project_path for A2 `session` subcommand.
@@ -1193,6 +1230,12 @@ def sync_cache(
1193
1230
  )
1194
1231
  conn.commit()
1195
1232
  stats.files_processed += 1
1233
+ # Browse-rail rollup: record the session_ids this file just
1234
+ # committed so the post-walk recompute can scope its DELETE+INSERT
1235
+ # re-derive to them. cr[0] is session_id per _conv_row_tuple's
1236
+ # column order. Lands
1237
+ # AFTER the commit (pure Python; no DML, no extra write lock).
1238
+ touched_sessions.update(cr[0] for cr in conv_rows if cr[0] is not None)
1196
1239
  except sqlite3.DatabaseError as exc:
1197
1240
  eprint(f"[cache] db error on {jp}: {exc}")
1198
1241
  conn.rollback()
@@ -1205,6 +1248,31 @@ def sync_cache(
1205
1248
  if progress is not None:
1206
1249
  progress(stats)
1207
1250
 
1251
+ # Browse-rail rollup maintenance (single post-walk recompute, under the
1252
+ # still-held flock, after every per-file commit and before the
1253
+ # walk-complete marker). Keyed on the DURABLE flag, not an in-memory
1254
+ # bool: a crash between a destructive path's commit (rebuild /
1255
+ # truncation / #139 backfill / #179 reingest, each of which armed the
1256
+ # flag in its own committed txn) and this recompute leaves the flag set,
1257
+ # so the next sync full-recomputes — never strands stale rollup rows
1258
+ # (Codex gate BLOCKER 1). Flag set -> full GROUP BY over all sessions
1259
+ # (rare, ~90ms), then drop the flag LAST (drop-it-last contract). Else ->
1260
+ # scoped re-derive (DELETE+INSERT, not a SQL UPSERT) over just the
1261
+ # sessions this walk touched (steady state,
1262
+ # ~1 session/tick). Both recomputes derive COUNT/MIN/MAX from the same
1263
+ # rows the rail's old live aggregate read, so the rollup stays
1264
+ # byte-identical to that aggregate.
1265
+ if _conversation_sessions_backfill_pending(conn):
1266
+ _recompute_conversation_sessions(conn)
1267
+ conn.execute(
1268
+ "DELETE FROM cache_meta "
1269
+ "WHERE key='conversation_sessions_backfill_pending'"
1270
+ )
1271
+ conn.commit()
1272
+ elif touched_sessions:
1273
+ _recompute_conversation_sessions(conn, touched_sessions)
1274
+ conn.commit()
1275
+
1208
1276
  # Walk-complete sentinel write (cctally-dev#93, D5a). Still inside the
1209
1277
  # held fcntl lock, before the finally-unlock. Only when the entire walk
1210
1278
  # was clean AND cache 001 was already applied at the start of this run
@@ -1416,6 +1484,88 @@ def _resumable_reingest_conversation_messages(conn):
1416
1484
  conn.commit()
1417
1485
 
1418
1486
 
1487
+ # === Browse-rail rollup (conversation_sessions) maintenance =================
1488
+ # Keeps conversation_sessions — the four structural aggregates the old live
1489
+ # GROUP BY produced — in lockstep with conversation_messages so
1490
+ # GET /api/conversations renders a page without scanning the whole message
1491
+ # table. Maintained entirely inside sync_cache under the cache.db.lock flock:
1492
+ # the steady-state per-file loop is insert-only, so a scoped re-derive
1493
+ # (DELETE+INSERT, not a SQL UPSERT) over the touched sessions suffices; the
1494
+ # rare heavy/destructive paths (rebuild,
1495
+ # truncation-escalation, #139 backfill, #179 reingest) set the durable
1496
+ # ``conversation_sessions_backfill_pending`` cache_meta flag (migration 013
1497
+ # arms it too) which forces one full recompute, crash-safe across a
1498
+ # destructive-commit/recompute crash window. The CALLER owns the commit.
1499
+
1500
+ # All non-null sessions, recomputed from conversation_messages. Shared by the
1501
+ # full and scoped recompute so both paths derive byte-identical aggregates.
1502
+ _CONV_SESSIONS_SELECT = (
1503
+ "SELECT session_id, COUNT(*), MIN(timestamp_utc), MAX(timestamp_utc) "
1504
+ "FROM conversation_messages WHERE session_id IS NOT NULL"
1505
+ )
1506
+
1507
+
1508
+ def _conversation_sessions_backfill_pending(conn) -> bool:
1509
+ """True while the durable ``conversation_sessions_backfill_pending`` flag is
1510
+ set — the signal that the rollup needs one full GROUP BY recompute (armed by
1511
+ migration 013 and by every heavy/destructive conversation_messages path).
1512
+ Tolerates a missing cache_meta table (path-less / schema-not-applied conn) by
1513
+ degrading to False, like the sibling reingest/backfill predicates."""
1514
+ try:
1515
+ return conn.execute(
1516
+ "SELECT 1 FROM cache_meta "
1517
+ "WHERE key='conversation_sessions_backfill_pending'"
1518
+ ).fetchone() is not None
1519
+ except sqlite3.OperationalError:
1520
+ return False
1521
+
1522
+
1523
+ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
1524
+ """Recompute the ``conversation_sessions`` browse-rail rollup from
1525
+ ``conversation_messages``. The caller holds the cache.db.lock flock and owns
1526
+ the commit (this helper never commits).
1527
+
1528
+ ``session_ids is None`` -> FULL: wipe the whole rollup and rebuild it from a
1529
+ single GROUP BY over every non-null session — the rare, flag-gated path
1530
+ (rebuild / truncation / backfill / reingest / migration-013 history).
1531
+
1532
+ ``session_ids={...}`` -> SCOPED: for each <=400-id chunk, DELETE those rows
1533
+ then re-INSERT the GROUP BY restricted to the chunk — the steady-state path
1534
+ keyed on the per-file loop's touched set. DELETE+INSERT (NOT
1535
+ INSERT…SELECT…ON CONFLICT, which trips SQLite's upsert-on-SELECT parse
1536
+ ambiguity) also correctly drops a session whose rows all vanished — though in
1537
+ steady state conversation_messages only gains rows, so that branch is just
1538
+ belt-and-suspenders. The chunking keeps the ``session_id IN (…)`` parameter
1539
+ list well under SQLite's variable limit.
1540
+
1541
+ The recomputed COUNT/MIN/MAX are byte-identical to the rail's prior live
1542
+ aggregate over the same rows — that is the load-bearing invariant
1543
+ (assert_rollup_matches_live in the maintenance test pins it)."""
1544
+ if session_ids is None:
1545
+ conn.execute("DELETE FROM conversation_sessions")
1546
+ conn.execute(
1547
+ "INSERT INTO conversation_sessions "
1548
+ "(session_id, msg_count, started_utc, last_activity_utc) "
1549
+ + _CONV_SESSIONS_SELECT + " GROUP BY session_id"
1550
+ )
1551
+ return
1552
+ ids = [s for s in session_ids if s is not None]
1553
+ for i in range(0, len(ids), 400):
1554
+ chunk = ids[i:i + 400]
1555
+ placeholders = ",".join("?" for _ in chunk)
1556
+ conn.execute(
1557
+ f"DELETE FROM conversation_sessions WHERE session_id IN ({placeholders})",
1558
+ chunk,
1559
+ )
1560
+ conn.execute(
1561
+ "INSERT INTO conversation_sessions "
1562
+ "(session_id, msg_count, started_utc, last_activity_utc) "
1563
+ + _CONV_SESSIONS_SELECT
1564
+ + f" AND session_id IN ({placeholders}) GROUP BY session_id",
1565
+ chunk,
1566
+ )
1567
+
1568
+
1419
1569
  def _consume_search_split(conn) -> None:
1420
1570
  """#177 S6: flock-held consumer for ``conversation_search_split_pending``
1421
1571
  (set by cache migration 010). Cursor-resumable: backfills
@@ -251,6 +251,32 @@ from typing import Any
251
251
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
252
252
 
253
253
 
254
+ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
255
+ """`ThreadingHTTPServer` that swallows client-disconnect tracebacks.
256
+
257
+ A backgrounded/closed/reloaded dashboard tab hangs up mid-response, so the
258
+ per-request thread's socket write raises one of the "peer went away"
259
+ exceptions, which `socketserver` routes through `handle_error`. On a local
260
+ dashboard that is expected and benign — dumping a socket-write stack trace
261
+ to the user's console for every such disconnect is pure noise (the original
262
+ report was repeated `BrokenPipeError` spam after hours idle). Everything
263
+ else still gets the full traceback via `super().handle_error`.
264
+
265
+ `daemon_threads = True` is folded in here (was an inline `srv.*` assignment):
266
+ SSE handler threads may block up to 15s on the keep-alive timeout — let them
267
+ die with the process.
268
+ """
269
+
270
+ daemon_threads = True
271
+
272
+ def handle_error(self, request, client_address):
273
+ exc = sys.exc_info()[1]
274
+ if isinstance(exc, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)):
275
+ # Client hung up mid-response; benign on a local dashboard.
276
+ return
277
+ super().handle_error(request, client_address)
278
+
279
+
254
280
  def _cctally():
255
281
  """Resolve the current ``cctally`` module at call-time (spec §5.5)."""
256
282
  return sys.modules["cctally"]
@@ -8251,8 +8277,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
8251
8277
  update_check_thread.start()
8252
8278
 
8253
8279
  # HTTP server on its own thread so the main thread can block on signal.
8254
- srv = ThreadingHTTPServer((args.host, args.port), DashboardHTTPHandler)
8255
- srv.daemon_threads = True # SSE handler threads may block up to 15s on keep-alive timeout let them die with the process.
8280
+ # `_QuietThreadingHTTPServer` folds in `daemon_threads = True` and silences
8281
+ # client-disconnect tracebacks (spec §5).
8282
+ srv = _QuietThreadingHTTPServer((args.host, args.port), DashboardHTTPHandler)
8256
8283
 
8257
8284
  bind_host = args.host
8258
8285
  bind_port = srv.server_address[1]
@@ -2360,6 +2360,27 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2360
2360
  byte_offset INTEGER NOT NULL
2361
2361
  );
2362
2362
 
2363
+ -- Browse-rail rollup (conversation_sessions). Materializes exactly the
2364
+ -- four structural aggregates the rail's old live GROUP BY produced
2365
+ -- (COUNT/MIN/MAX over conversation_messages per session_id) so
2366
+ -- GET /api/conversations no longer scans the whole message table to
2367
+ -- render a 50-row page. The explicit NOT NULL on the non-INTEGER PK
2368
+ -- matters (SQLite's legacy NULL-in-PK bug); the rail keys on a concrete
2369
+ -- session_id and the recompute's GROUP BY already filters nulls. The
2370
+ -- index lets the only paginated ordering (recent) early-terminate at
2371
+ -- LIMIT with no temp B-tree. Re-derivable like the rest of cache.db;
2372
+ -- sync_cache keeps it honest (scoped DELETE+INSERT re-derive +
2373
+ -- flag-gated full recompute) — migration 013 arms the one-time
2374
+ -- history backfill.
2375
+ CREATE TABLE IF NOT EXISTS conversation_sessions (
2376
+ session_id TEXT NOT NULL PRIMARY KEY,
2377
+ msg_count INTEGER NOT NULL DEFAULT 0,
2378
+ started_utc TEXT,
2379
+ last_activity_utc TEXT
2380
+ );
2381
+ CREATE INDEX IF NOT EXISTS idx_conv_sessions_recent
2382
+ ON conversation_sessions(last_activity_utc DESC, session_id DESC);
2383
+
2363
2384
  CREATE TABLE IF NOT EXISTS codex_session_files (
2364
2385
  path TEXT PRIMARY KEY,
2365
2386
  size_bytes INTEGER NOT NULL,
@@ -3407,6 +3428,20 @@ def _012_create_conversation_ai_titles(conn: sqlite3.Connection) -> None:
3407
3428
  conn.commit()
3408
3429
 
3409
3430
 
3431
+ @cache_migration("013_create_conversation_sessions")
3432
+ def _013_create_conversation_sessions(conn: sqlite3.Connection) -> None:
3433
+ """Flag-only arm for the conversation_sessions browse-rail rollup. The table
3434
+ is created by _apply_cache_schema (every open); this sets
3435
+ conversation_sessions_backfill_pending so sync_cache does the one-time full
3436
+ GROUP BY recompute under the cache.db.lock flock. No data work here — the
3437
+ dispatcher's central stamp (#140) marks a complete handler; a fresh install
3438
+ stamps WITHOUT running the handler, so the flag is NOT set there, which is
3439
+ correct (empty messages -> empty rollup; the incremental DELETE+INSERT
3440
+ re-derive fills both in lockstep). Mirrors 012."""
3441
+ _set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
3442
+ conn.commit()
3443
+
3444
+
3410
3445
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
3411
3446
 
3412
3447
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -352,18 +352,73 @@ def _session_latest_meta_map(conn, session_ids):
352
352
  return meta
353
353
 
354
354
 
355
+ # Rail sort keys, in the rollup table's STRUCTURAL columns (Task A). ``recent``
356
+ # rides idx_conv_sessions_recent(last_activity_utc DESC, session_id DESC) and
357
+ # early-terminates at LIMIT with no temp B-tree; ``oldest`` scan-sorts the few
358
+ # thousand rollup rows (microseconds). The live fallback re-expresses the SAME
359
+ # orderings over conversation_messages aggregates via _SORTS_LIVE below.
360
+ # When adding a sort key, add it to BOTH _SORTS and _SORTS_LIVE and exercise it
361
+ # in test_conversation_rail_rollup_reconcile.py (its _dump asserts the test's
362
+ # sort list covers exactly _SORTS.keys(), so a one-sided add fails loud).
355
363
  _SORTS = {
364
+ "recent": "last_activity_utc DESC, session_id DESC",
365
+ "oldest": "started_utc ASC, session_id ASC",
366
+ }
367
+
368
+ # The matching ORDER BY for the live GROUP BY fallback: the rollup's
369
+ # last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
370
+ # so the two branches paginate in byte-identical order (the load-bearing
371
+ # invariant). Keep this table in lockstep with _SORTS.
372
+ _SORTS_LIVE = {
356
373
  "recent": "MAX(timestamp_utc) DESC, session_id DESC",
357
374
  "oldest": "MIN(timestamp_utc) ASC, session_id ASC",
358
375
  }
359
376
 
360
377
 
361
- def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
362
- """All-history per-session browse rows (spec §3.1). NOT 365-day bounded."""
363
- order = _SORTS.get(sort, _SORTS["recent"])
364
- limit = max(1, min(int(limit), 200))
365
- offset = max(0, int(offset))
366
- rows = conn.execute(
378
+ def _rollup_authoritative(conn) -> bool:
379
+ """True when the conversation_sessions rollup is authoritative i.e. the
380
+ durable ``conversation_sessions_backfill_pending`` flag is NOT set, so the
381
+ rollup has been fully recomputed and the fast read is safe.
382
+
383
+ Returns True (authoritative) when the flag is absent, including the case
384
+ where cache_meta does not exist at all (a path-less / schema-not-applied or
385
+ in-memory connection). This mirrors Task A's
386
+ _conversation_sessions_backfill_pending OperationalError degrade-to-False —
387
+ here that means "no pending flag" -> authoritative -> read the rollup. A
388
+ populated cache.db always has cache_meta, so this only affects bare conns."""
389
+ try:
390
+ pending = conn.execute(
391
+ "SELECT 1 FROM cache_meta "
392
+ "WHERE key='conversation_sessions_backfill_pending'"
393
+ ).fetchone() is not None
394
+ except sqlite3.OperationalError:
395
+ return True
396
+ return not pending
397
+
398
+
399
+ def _list_session_rows_rollup(conn, order, limit, offset):
400
+ """FAST path: read the pre-aggregated rail rows straight from the
401
+ conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
402
+ ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
403
+ tuples — the same shape the live aggregate yields, so the downstream
404
+ assembly is identical. No WHERE is needed: the rollup is non-null by
405
+ construction (PK NOT NULL; the recompute's GROUP BY already drops NULLs)."""
406
+ return conn.execute(
407
+ "SELECT session_id, msg_count, "
408
+ " started_utc AS started, last_activity_utc AS last_activity "
409
+ "FROM conversation_sessions "
410
+ "ORDER BY " + order + " LIMIT ? OFFSET ?",
411
+ (limit + 1, offset),
412
+ ).fetchall()
413
+
414
+
415
+ def _list_session_rows_live(conn, order, limit, offset):
416
+ """RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
417
+ conversation_messages, used while the rollup is not authoritative (the flag
418
+ is set — e.g. an existing install before its first sync, or permanently
419
+ under ``--no-sync``). Byte-identical output to the rollup branch by
420
+ construction; ``order`` here is the _SORTS_LIVE aggregate expression."""
421
+ return conn.execute(
367
422
  "SELECT session_id, COUNT(*) AS msg_count, "
368
423
  " MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
369
424
  "FROM conversation_messages "
@@ -372,6 +427,33 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
372
427
  "ORDER BY " + order + " LIMIT ? OFFSET ?",
373
428
  (limit + 1, offset),
374
429
  ).fetchall()
430
+
431
+
432
+ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
433
+ """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
434
+
435
+ Reads the conversation_sessions rollup (Task A) when it is authoritative —
436
+ i.e. the durable ``conversation_sessions_backfill_pending`` flag is clear —
437
+ which lets the ``recent`` sort early-terminate on idx_conv_sessions_recent
438
+ with no full-table aggregate. While the flag is set (an existing install
439
+ before its first sync consumes the backfill, or permanently under
440
+ ``--no-sync``), it falls back to the RETAINED live GROUP BY over
441
+ conversation_messages — correct, possibly slower, never an empty rail.
442
+
443
+ The two branches are byte-identical for every (sort, limit, offset): the
444
+ rollup recomputes the same COUNT/MIN/MAX over the same rows, and the
445
+ fallback IS the old aggregate (pinned by the reconcile test over both
446
+ branches). Staleness bound: at most one sync tick (~5s) if a session gains
447
+ messages on the exact tick the rail reads — cosmetic only, and the rail
448
+ revalidates every visible tick anyway."""
449
+ limit = max(1, min(int(limit), 200))
450
+ offset = max(0, int(offset))
451
+ if _rollup_authoritative(conn):
452
+ order = _SORTS.get(sort, _SORTS["recent"])
453
+ rows = _list_session_rows_rollup(conn, order, limit, offset)
454
+ else:
455
+ order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
456
+ rows = _list_session_rows_live(conn, order, limit, offset)
375
457
  has_more = len(rows) > limit
376
458
  rows = rows[:limit]
377
459
  session_ids = [r[0] for r in rows]