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 +5 -0
- package/bin/_cctally_cache.py +150 -0
- package/bin/_cctally_dashboard.py +29 -2
- package/bin/_cctally_db.py +35 -0
- package/bin/_lib_conversation_query.py +88 -6
- package/dashboard/static/assets/{index-40fOgWiM.js → index-C_xsUDIl.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +1 -1
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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
|
-
|
|
8255
|
-
|
|
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]
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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
|
|
362
|
-
"""
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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]
|