cctally 1.48.0 → 1.50.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 +14 -0
- package/bin/_cctally_cache.py +44 -0
- package/bin/_cctally_dashboard.py +109 -3
- package/bin/_cctally_db.py +46 -7
- package/bin/_lib_conversation_query.py +173 -18
- package/bin/_lib_dashboard_dates.py +166 -0
- package/dashboard/static/assets/index-C96dGQ7N.js +68 -0
- package/dashboard/static/assets/index-ChyJiUWl.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-De72H7wU.js +0 -68
- package/dashboard/static/assets/index-QLGET4cL.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.50.0] - 2026-06-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Dashboard conversation viewer — mobile transcripts no longer shear sideways, and the session outline is no longer an interaction trap.** On a narrow (≤640px) viewport the reader's pinned controls toolbar overflowed the viewport, so any automatic scroll (jump-to-latest on open, an outline/landmark jump, or a focus-filter switch) could drift the whole transcript sideways and clip every line off the left edge; the toolbar and focus control are now constrained to the viewport, all reader content is width-clamped, and the reader is locked against a stray horizontal scroll, so transcripts read fully at 320/360/390px. The session outline — previously a persisted, default-on slide-over that buried the transcript the moment you opened a conversation, dismissable only by tapping an unlabeled backdrop strip — now defaults closed on mobile, opens on an ephemeral per-conversation flag, and carries a titled header with a visible ✕ close (the backdrop stays a second dismiss path), raised above the sticky topbar so the close control is always tappable (#205).
|
|
12
|
+
- **Dashboard conversation viewer — find-in-conversation is reachable on mobile, and text inputs no longer trigger iOS auto-zoom.** A `🔍 Find` toggle now sits in the reader controls row on both breakpoints (the `/` shortcut still works on desktop), so the in-conversation find bar is no longer unreachable by touch; switching to a different conversation clears an open find state, so navigating back and reselecting no longer auto-reopens the bar and pops the keyboard. All seven viewer text inputs (rail search, find bar, and the date/cost/cache-rebuild filter fields) now render at 16px on mobile, so focusing one no longer makes iOS Safari zoom the page. Desktop styling is unchanged (#205).
|
|
13
|
+
- **Dashboard conversation viewer — a more compact mobile reader header, a readable subagent-card title, and a recoverable list-fetch error.** On mobile the reader header now shows de-duplicated, abbreviated model names (the `claude-` prefix and a trailing `-YYYYMMDD` date stamp are stripped) on a single line and clamps the title to two lines, putting more transcript above the fold; subagent cards swap the one-line `Impl…` ellipsis for a two-line clamped title with the same abbreviated models; and a failed conversation-list fetch now shows a working **Retry** button instead of a dead error state. Desktop output is byte-identical (#205).
|
|
14
|
+
- **Dashboard conversation viewer — every interactive control now meets the 44×44px mobile touch-target minimum, and the reader's `← Back` is no longer hidden behind the sticky topbar.** Roughly 32 controls across the reader header, find bar, filters popover, browse rail, outline, and transcript body (disclosure chips, the jump-to-top FAB, the corner copy buttons, and more) now have a ≥44×44px effective hit area at the mobile breakpoint — the absolute-positioned copy buttons get an invisible centered hit area so a literal box never covers code/diff/terminal content, and inline content links keep the documented WCAG 2.5.5 inline exception. A height-calculation bug that let the page scroll roughly 64px behind the `z-index:50` sticky topbar — hiding the reader's `← Back` — is fixed by switching the mobile conversation view to `100dvh` and correcting its height budget, so `← Back` is reachable at every width, at rest and after a forced scroll. The outline backdrop and ✕ now carry distinct accessibility labels. Desktop runtime is byte-identical (#205).
|
|
15
|
+
|
|
16
|
+
## [1.49.0] - 2026-06-17
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Dashboard conversation viewer — Browse-list filters.** The Browse rail gains a compact `Filters ▾` popover that narrows the conversation list by four axes: date (This month / Last month / Last 7d presets, or an arbitrary from→to range), project (a multi-select matching any of the chosen projects, with per-project conversation counts), cost (an optional minimum and/or maximum USD with quick `≥$1` / `≥$5` / `≥$10` presets), and number of cache rebuilds (a `≥1` / `≥3` / `≥5` or custom threshold). Axes combine with AND, active filters show as removable chips under the search box, and filtering is server-side so pagination and counts stay correct. The date filter matches each session's last activity — the same instant the rail now sorts and groups by — so the date sections, the recent order, and the filter all agree. When a filter set matches nothing the rail shows a distinct "No conversations match these filters." message with a one-click "Clear filters" button (separate from the generic "No conversations." copy for an empty install). Filters apply to Browse only (disabled while a full-text search is active, which also dismisses the popover so the reader's keyboard navigation stays live) and are session-only (reset on reload). Each row's displayed cost is the live, pricing-immediate recompute (consistent with the detail and report views), while the cost filter compares the stored per-session rollup — the two agree except in the brief window after a pricing-table edit, before the next sync re-derives the stored value.
|
|
20
|
+
- **Dashboard conversation viewer — jump to the latest turn.** A `Latest ↓` action in the reader header (and the `End` key) pages the open conversation forward to its end and lands on the most recent turn with the usual flash, reusing the existing jump pipeline with an uncapped pager so it reaches the last turn even in very long conversations — and now correctly waits out a page-load that happens to be in flight when you trigger it, rather than stopping short. Landing at the bottom parks the reader in its stick-to-bottom position, so a live session keeps following new turns automatically.
|
|
21
|
+
|
|
8
22
|
## [1.48.0] - 2026-06-16
|
|
9
23
|
|
|
10
24
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -1637,6 +1637,7 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
|
|
|
1637
1637
|
"(session_id, msg_count, started_utc, last_activity_utc) "
|
|
1638
1638
|
+ _CONV_SESSIONS_SELECT + " GROUP BY session_id"
|
|
1639
1639
|
)
|
|
1640
|
+
_fill_conversation_sessions_filter_columns(conn, None)
|
|
1640
1641
|
return
|
|
1641
1642
|
ids = [s for s in session_ids if s is not None]
|
|
1642
1643
|
for i in range(0, len(ids), 400):
|
|
@@ -1653,6 +1654,49 @@ def _recompute_conversation_sessions(conn, session_ids=None) -> None:
|
|
|
1653
1654
|
+ f" AND session_id IN ({placeholders}) GROUP BY session_id",
|
|
1654
1655
|
chunk,
|
|
1655
1656
|
)
|
|
1657
|
+
_fill_conversation_sessions_filter_columns(conn, ids)
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _fill_conversation_sessions_filter_columns(conn, session_ids):
|
|
1661
|
+
"""Fill the rollup's browse-FILTER columns (project_label / cost_usd /
|
|
1662
|
+
cache_rebuild_count, migration 015) for the given sessions, or ALL when
|
|
1663
|
+
``session_ids is None``. The structural COUNT/MIN/MAX columns are filled by
|
|
1664
|
+
the INSERT in _recompute_conversation_sessions; this is the second pass that
|
|
1665
|
+
materializes the three filter axes so the rail's date/project/cost/rebuild
|
|
1666
|
+
filters are pure-SQL predicates.
|
|
1667
|
+
|
|
1668
|
+
project_label + cost reuse the query kernel's batch maps (the SAME
|
|
1669
|
+
_project_label / _session_cost_map the rail's per-page Python path used), so
|
|
1670
|
+
a filtered/displayed value equals what the live rail produced. cost is
|
|
1671
|
+
rounded to 6dp to match list_conversations' per-row rounding.
|
|
1672
|
+
cache_rebuild_count is a per-session assemble via the query kernel's
|
|
1673
|
+
single-source-of-truth helper (whole-session property — recompute, never
|
|
1674
|
+
increment).
|
|
1675
|
+
|
|
1676
|
+
No-op when the columns are absent (a pre-migration-015 cache.db being
|
|
1677
|
+
re-derived before its 015 ALTER lands), so an early/partial sync never
|
|
1678
|
+
raises ``no such column``. The CALLER owns the commit (this never commits)."""
|
|
1679
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(conversation_sessions)")}
|
|
1680
|
+
if "cache_rebuild_count" not in cols:
|
|
1681
|
+
return
|
|
1682
|
+
lq = _load_lib("_lib_conversation_query")
|
|
1683
|
+
if session_ids is None:
|
|
1684
|
+
ids = [r[0] for r in conn.execute(
|
|
1685
|
+
"SELECT session_id FROM conversation_sessions")]
|
|
1686
|
+
else:
|
|
1687
|
+
ids = [s for s in session_ids if s is not None]
|
|
1688
|
+
if not ids:
|
|
1689
|
+
return
|
|
1690
|
+
cost = lq._session_cost_map(conn, ids)
|
|
1691
|
+
meta = lq._session_latest_meta_map(conn, ids)
|
|
1692
|
+
for sid in ids:
|
|
1693
|
+
proj = lq._project_label(meta.get(sid, (None, None))[0])
|
|
1694
|
+
rebuilds = lq.session_cache_rebuild_count(conn, sid)
|
|
1695
|
+
conn.execute(
|
|
1696
|
+
"UPDATE conversation_sessions SET project_label=?, cost_usd=?, "
|
|
1697
|
+
"cache_rebuild_count=? WHERE session_id=?",
|
|
1698
|
+
(proj, round(cost.get(sid, 0.0), 6), rebuilds, sid),
|
|
1699
|
+
)
|
|
1656
1700
|
|
|
1657
1701
|
|
|
1658
1702
|
def _consume_search_split(conn) -> None:
|
|
@@ -5276,6 +5276,13 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
|
|
|
5276
5276
|
_CONV_SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
|
|
5277
5277
|
|
|
5278
5278
|
|
|
5279
|
+
class _BadConversationFilter(Exception):
|
|
5280
|
+
"""Internal sentinel: a browse-filter query param failed validation. The
|
|
5281
|
+
parse helper has ALREADY sent the 400 response when this is raised, so the
|
|
5282
|
+
caller just unwinds and returns (the conversation routes all 400 on bad
|
|
5283
|
+
input, consistent with the search ``kind`` facet). Module-private."""
|
|
5284
|
+
|
|
5285
|
+
|
|
5279
5286
|
def _cached_file_sigs(conn, paths):
|
|
5280
5287
|
"""{path: size_bytes} from session_files for the given paths — the cache's
|
|
5281
5288
|
own view of how far each file is ingested. Size-only by design, matching the
|
|
@@ -5419,6 +5426,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5419
5426
|
self._handle_share_history_get()
|
|
5420
5427
|
elif path == "/api/doctor":
|
|
5421
5428
|
self._handle_get_doctor()
|
|
5429
|
+
elif path == "/api/conversations/facets":
|
|
5430
|
+
self._handle_get_conversations_facets()
|
|
5422
5431
|
elif path == "/api/conversations":
|
|
5423
5432
|
self._handle_get_conversations()
|
|
5424
5433
|
elif path == "/api/conversation/search":
|
|
@@ -7462,12 +7471,90 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7462
7471
|
conn.close()
|
|
7463
7472
|
return True, body
|
|
7464
7473
|
|
|
7474
|
+
def _parse_conversation_filters(self, q):
|
|
7475
|
+
"""Parse the browse-list filter params (spec §2) from a ``parse_qs``
|
|
7476
|
+
mapping. On any malformed value this sends a **400** and returns
|
|
7477
|
+
``None`` — the caller just ``return``s (the conversation routes all 400
|
|
7478
|
+
on bad input). On success returns a dict of ``list_conversations``
|
|
7479
|
+
kwargs: ``date_from``/``date_to`` (UTC-ISO bounds), ``projects``
|
|
7480
|
+
(list[str] | None), ``cost_min``/``cost_max`` (float | None),
|
|
7481
|
+
``rebuild_min`` (int | None). Empty/blank params drop to ``None``.
|
|
7482
|
+
|
|
7483
|
+
Numeric axes validate strictly (a non-numeric cost / non-integer
|
|
7484
|
+
rebuild threshold is a hard 400). Date bounds route through the pure
|
|
7485
|
+
``_lib_dashboard_dates.parse_filter_date_range`` helper, which resolves
|
|
7486
|
+
naive date-only bounds in ``display.tz`` and raises ``ValueError`` (→
|
|
7487
|
+
400) on a malformed date. Projects accept BOTH repeated
|
|
7488
|
+
``?projects=a&projects=b`` and a single comma-joined ``?projects=a,b``.
|
|
7489
|
+
"""
|
|
7490
|
+
def _float(name):
|
|
7491
|
+
v = _qs_str(q, name, "")
|
|
7492
|
+
if v is None or v == "":
|
|
7493
|
+
return None
|
|
7494
|
+
try:
|
|
7495
|
+
return float(v)
|
|
7496
|
+
except ValueError:
|
|
7497
|
+
self._respond_json(400, {"error": f"bad {name}: {v}"})
|
|
7498
|
+
raise _BadConversationFilter
|
|
7499
|
+
|
|
7500
|
+
def _int(name):
|
|
7501
|
+
v = _qs_str(q, name, "")
|
|
7502
|
+
if v is None or v == "":
|
|
7503
|
+
return None
|
|
7504
|
+
try:
|
|
7505
|
+
return int(v)
|
|
7506
|
+
except ValueError:
|
|
7507
|
+
self._respond_json(400, {"error": f"bad {name}: {v}"})
|
|
7508
|
+
raise _BadConversationFilter
|
|
7509
|
+
|
|
7510
|
+
try:
|
|
7511
|
+
cost_min = _float("cost_min")
|
|
7512
|
+
cost_max = _float("cost_max")
|
|
7513
|
+
rebuild_min = _int("rebuild_min")
|
|
7514
|
+
except _BadConversationFilter:
|
|
7515
|
+
return None # 400 already sent
|
|
7516
|
+
|
|
7517
|
+
projects = [p for p in q.get("projects", []) if p] or None
|
|
7518
|
+
# Single comma-joined value -> split (the client may send either form).
|
|
7519
|
+
if projects and len(projects) == 1 and "," in projects[0]:
|
|
7520
|
+
projects = [s for s in projects[0].split(",") if s] or None
|
|
7521
|
+
|
|
7522
|
+
date_from = _qs_str(q, "date_from", "") or None
|
|
7523
|
+
date_to = _qs_str(q, "date_to", "") or None
|
|
7524
|
+
if date_from or date_to:
|
|
7525
|
+
from importlib import import_module
|
|
7526
|
+
tz = _resolve_display_tz_obj(
|
|
7527
|
+
_apply_display_tz_override(
|
|
7528
|
+
load_config(), type(self).display_tz_pref_override
|
|
7529
|
+
)
|
|
7530
|
+
).key
|
|
7531
|
+
try:
|
|
7532
|
+
df, dtt = import_module(
|
|
7533
|
+
"_lib_dashboard_dates"
|
|
7534
|
+
).parse_filter_date_range(date_from, date_to, tz_name=tz)
|
|
7535
|
+
except ValueError as exc:
|
|
7536
|
+
self._respond_json(400, {"error": str(exc)})
|
|
7537
|
+
return None
|
|
7538
|
+
else:
|
|
7539
|
+
df = dtt = None
|
|
7540
|
+
|
|
7541
|
+
return {
|
|
7542
|
+
"date_from": df,
|
|
7543
|
+
"date_to": dtt,
|
|
7544
|
+
"projects": projects,
|
|
7545
|
+
"cost_min": cost_min,
|
|
7546
|
+
"cost_max": cost_max,
|
|
7547
|
+
"rebuild_min": rebuild_min,
|
|
7548
|
+
}
|
|
7549
|
+
|
|
7465
7550
|
def _handle_get_conversations(self) -> None:
|
|
7466
7551
|
"""``GET /api/conversations`` — the browse rail (spec §3.1).
|
|
7467
7552
|
|
|
7468
7553
|
Gated first (loopback / Host allowlist). ``sort``/``limit``/``offset``
|
|
7469
|
-
are read from the query string; the kernel clamps bounds.
|
|
7470
|
-
|
|
7554
|
+
are read from the query string; the kernel clamps bounds. The browse
|
|
7555
|
+
filters (date/project/cost/rebuild — spec §2) are parsed/validated here
|
|
7556
|
+
(malformed → 400) and threaded into the kernel. Cache-open failures are
|
|
7557
|
+
500s, never 5xx-with-stacktrace.
|
|
7471
7558
|
"""
|
|
7472
7559
|
if not self._require_transcripts_allowed():
|
|
7473
7560
|
return
|
|
@@ -7476,14 +7563,33 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7476
7563
|
sort = _qs_str(q, "sort", "recent")
|
|
7477
7564
|
limit = _qs_int(q, "limit", 50)
|
|
7478
7565
|
offset = _qs_int(q, "offset", 0)
|
|
7566
|
+
filters = self._parse_conversation_filters(q)
|
|
7567
|
+
if filters is None:
|
|
7568
|
+
return # a 400 has already been sent
|
|
7479
7569
|
ok, body = self._run_conversation_query(
|
|
7480
7570
|
lambda conn: self._conversation_query().list_conversations(
|
|
7481
|
-
conn, sort=sort, limit=limit, offset=offset),
|
|
7571
|
+
conn, sort=sort, limit=limit, offset=offset, **filters),
|
|
7482
7572
|
"/api/conversations")
|
|
7483
7573
|
if not ok:
|
|
7484
7574
|
return
|
|
7485
7575
|
self._respond_json(200, body)
|
|
7486
7576
|
|
|
7577
|
+
def _handle_get_conversations_facets(self) -> None:
|
|
7578
|
+
"""``GET /api/conversations/facets`` — distinct project labels + their
|
|
7579
|
+
conversation counts, for the browse filter's project multi-select (spec
|
|
7580
|
+
§2). Behind the SAME loopback/Host privacy gate as the list route; a
|
|
7581
|
+
cheap indexed GROUP BY over the rollup. The popover loads its options
|
|
7582
|
+
once from here (deriving from a paginated page would be incomplete).
|
|
7583
|
+
"""
|
|
7584
|
+
if not self._require_transcripts_allowed():
|
|
7585
|
+
return
|
|
7586
|
+
ok, body = self._run_conversation_query(
|
|
7587
|
+
lambda conn: self._conversation_query().list_conversation_facets(conn),
|
|
7588
|
+
"/api/conversations/facets")
|
|
7589
|
+
if not ok:
|
|
7590
|
+
return
|
|
7591
|
+
self._respond_json(200, body)
|
|
7592
|
+
|
|
7487
7593
|
def _handle_get_conversation_detail(self, path: str) -> None:
|
|
7488
7594
|
"""``GET /api/conversation/<session-id>`` — the reader (spec §3.2).
|
|
7489
7595
|
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2360,11 +2360,18 @@ 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
|
|
2364
|
-
--
|
|
2363
|
+
-- Browse-rail rollup (conversation_sessions). Materializes the four
|
|
2364
|
+
-- structural aggregates the rail's old live GROUP BY produced
|
|
2365
2365
|
-- (COUNT/MIN/MAX over conversation_messages per session_id) so
|
|
2366
2366
|
-- GET /api/conversations no longer scans the whole message table to
|
|
2367
|
-
-- render a 50-row page
|
|
2367
|
+
-- render a 50-row page, PLUS three filter columns
|
|
2368
|
+
-- (project_label/cost_usd/cache_rebuild_count, migration 015) so the
|
|
2369
|
+
-- Browse list's date/project/cost/cache-rebuild filters are pure-SQL
|
|
2370
|
+
-- predicates. The structural columns are recomputed by a COUNT/MIN/MAX
|
|
2371
|
+
-- GROUP BY; the filter columns are filled per-session by
|
|
2372
|
+
-- _fill_conversation_sessions_filter_columns in the same flock-held
|
|
2373
|
+
-- recompute (cost via the query kernel's batch maps, cache_rebuild_count
|
|
2374
|
+
-- via a per-session assemble). The explicit NOT NULL on the non-INTEGER PK
|
|
2368
2375
|
-- matters (SQLite's legacy NULL-in-PK bug); the rail keys on a concrete
|
|
2369
2376
|
-- session_id and the recompute's GROUP BY already filters nulls. The
|
|
2370
2377
|
-- index lets the only paginated ordering (recent) early-terminate at
|
|
@@ -2373,10 +2380,13 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2373
2380
|
-- flag-gated full recompute) — migration 013 arms the one-time
|
|
2374
2381
|
-- history backfill.
|
|
2375
2382
|
CREATE TABLE IF NOT EXISTS conversation_sessions (
|
|
2376
|
-
session_id
|
|
2377
|
-
msg_count
|
|
2378
|
-
started_utc
|
|
2379
|
-
last_activity_utc
|
|
2383
|
+
session_id TEXT NOT NULL PRIMARY KEY,
|
|
2384
|
+
msg_count INTEGER NOT NULL DEFAULT 0,
|
|
2385
|
+
started_utc TEXT,
|
|
2386
|
+
last_activity_utc TEXT,
|
|
2387
|
+
project_label TEXT,
|
|
2388
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
2389
|
+
cache_rebuild_count INTEGER NOT NULL DEFAULT 0
|
|
2380
2390
|
);
|
|
2381
2391
|
CREATE INDEX IF NOT EXISTS idx_conv_sessions_recent
|
|
2382
2392
|
ON conversation_sessions(last_activity_utc DESC, session_id DESC);
|
|
@@ -3463,6 +3473,35 @@ def _014_conversation_queued_prompt_reingest(conn: sqlite3.Connection) -> None:
|
|
|
3463
3473
|
conn.commit()
|
|
3464
3474
|
|
|
3465
3475
|
|
|
3476
|
+
@cache_migration("015_conversation_sessions_filter_columns")
|
|
3477
|
+
def _015_conversation_sessions_filter_columns(conn: sqlite3.Connection) -> None:
|
|
3478
|
+
"""Add the browse-filter columns to the conversation_sessions rollup
|
|
3479
|
+
(project_label, cost_usd, cache_rebuild_count) so the rail's date/project/
|
|
3480
|
+
cost/cache-rebuild filters are pure-SQL predicates (spec §1). ALTERs are
|
|
3481
|
+
idempotent (duplicate-column tolerated). Arms the SHARED
|
|
3482
|
+
conversation_sessions_backfill_pending flag so the next sync_cache full
|
|
3483
|
+
recompute fills the new columns via the augmented
|
|
3484
|
+
_recompute_conversation_sessions — keeping the heavy per-session assemble
|
|
3485
|
+
(cache_rebuild_count) off the migration's critical path, mirroring 013.
|
|
3486
|
+
Central stamp via the dispatcher (#140); handler does NOT self-stamp.
|
|
3487
|
+
|
|
3488
|
+
A fresh install gets the three columns from _apply_cache_schema's CREATE
|
|
3489
|
+
TABLE and stamps 015 WITHOUT running this handler — the ALTERs no-op there
|
|
3490
|
+
(already present), and the empty rollup needs no backfill (the incremental
|
|
3491
|
+
DELETE+INSERT re-derive fills all columns in lockstep). Mirrors 013."""
|
|
3492
|
+
for ddl in (
|
|
3493
|
+
"ALTER TABLE conversation_sessions ADD COLUMN project_label TEXT",
|
|
3494
|
+
"ALTER TABLE conversation_sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0",
|
|
3495
|
+
"ALTER TABLE conversation_sessions ADD COLUMN cache_rebuild_count INTEGER NOT NULL DEFAULT 0",
|
|
3496
|
+
):
|
|
3497
|
+
try:
|
|
3498
|
+
conn.execute(ddl)
|
|
3499
|
+
except sqlite3.OperationalError:
|
|
3500
|
+
pass # idempotent: column already present
|
|
3501
|
+
_set_cache_meta(conn, "conversation_sessions_backfill_pending", "1")
|
|
3502
|
+
conn.commit()
|
|
3503
|
+
|
|
3504
|
+
|
|
3466
3505
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3467
3506
|
|
|
3468
3507
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -445,6 +445,30 @@ def _stamp_cache_failures(items):
|
|
|
445
445
|
running_max[key] = max(rm, cr)
|
|
446
446
|
|
|
447
447
|
|
|
448
|
+
def session_cache_rebuild_count(conn, session_id) -> int:
|
|
449
|
+
"""Per-session count of cache-rebuild (cache-failure) turns.
|
|
450
|
+
|
|
451
|
+
Single source of truth: assembles the session via the SAME pipeline the
|
|
452
|
+
reader/outline use and runs the SAME _stamp_cache_failures kernel, then
|
|
453
|
+
counts flagged items. /outline omits stats.cache_failures when the count is
|
|
454
|
+
0, so "no flagged items" -> 0 (byte-identical to outline). Stored on the
|
|
455
|
+
conversation_sessions rollup by _recompute_conversation_sessions; filtered
|
|
456
|
+
by list_conversations(rebuild_min=...).
|
|
457
|
+
|
|
458
|
+
_assemble_session already runs _stamp_cache_failures internally before
|
|
459
|
+
returning, so the items carry the flag on entry; re-running the kernel here
|
|
460
|
+
is an idempotent recompute (it only ADDS the key on a flagged turn, never
|
|
461
|
+
clears one, and the predicate is deterministic over the same items) that
|
|
462
|
+
keeps the helper correct even if assembly ever stops stamping.
|
|
463
|
+
"""
|
|
464
|
+
asm = _assemble_session(conn, session_id)
|
|
465
|
+
if asm is None:
|
|
466
|
+
return 0
|
|
467
|
+
items = asm["items"]
|
|
468
|
+
_stamp_cache_failures(items)
|
|
469
|
+
return sum(1 for it in items if "cache_failure" in it)
|
|
470
|
+
|
|
471
|
+
|
|
448
472
|
def _session_cost_map(conn, session_ids):
|
|
449
473
|
"""{session_id: total_cost_usd} for the given sessions. Joins
|
|
450
474
|
conversation_messages turn keys to the single deduped session_entries row
|
|
@@ -570,40 +594,135 @@ def _rollup_authoritative(conn) -> bool:
|
|
|
570
594
|
return not pending
|
|
571
595
|
|
|
572
596
|
|
|
573
|
-
|
|
597
|
+
# --- Browse-list filter predicates (spec §2) ------------------------------
|
|
598
|
+
# The rail filters on four axes (date / project / cost / cache-rebuilds). All
|
|
599
|
+
# four are STORED columns on the conversation_sessions rollup, so the rollup
|
|
600
|
+
# fast path expresses every axis as a parameterized WHERE pushed BEFORE
|
|
601
|
+
# LIMIT/OFFSET (pagination stays correct over the filtered set). The live
|
|
602
|
+
# GROUP BY fallback has no cost/project/rebuild columns, so it expresses ONLY
|
|
603
|
+
# the date axis (via HAVING over MAX(timestamp_utc)) — the rollup-only axes
|
|
604
|
+
# degrade in that brief, self-correcting non-authoritative window (spec §1
|
|
605
|
+
# dual-branch parity). The date predicate compares against the stored
|
|
606
|
+
# last_activity_utc string, so the caller MUST pass UTC-ISO bounds in the same
|
|
607
|
+
# format (...Z) — see bin/_lib_dashboard_dates.parse_filter_date_range.
|
|
608
|
+
_FILTER_KEYS = ("date_from", "date_to", "projects",
|
|
609
|
+
"cost_min", "cost_max", "rebuild_min")
|
|
610
|
+
# The rollup-only axes — the ones the live fallback cannot express. Used to set
|
|
611
|
+
# the page's filter_degraded flag when one is requested under the live branch.
|
|
612
|
+
_ROLLUP_ONLY_FILTER_KEYS = ("projects", "cost_min", "cost_max", "rebuild_min")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _empty_filters() -> dict:
|
|
616
|
+
"""A no-op filter dict (every axis None) — the unfiltered default and the
|
|
617
|
+
shape the row-source helpers expect."""
|
|
618
|
+
return {k: None for k in _FILTER_KEYS}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _rollup_where(filters):
|
|
622
|
+
"""(sql_fragment, params) for the ROLLUP branch — all four axes are stored
|
|
623
|
+
columns. Returns (" WHERE ...", [params]) or ("", []) when no axis is set.
|
|
624
|
+
Project IN-list naturally excludes empty/NULL project_label (neither the ''
|
|
625
|
+
no-cwd sentinel nor a NULL not-yet-filled row matches a real label).
|
|
626
|
+
|
|
627
|
+
The date axis is HALF-OPEN: ``date_from`` is an inclusive start-of-day lower
|
|
628
|
+
bound (``>=``) and ``date_to`` is the EXCLUSIVE start-of-next-day upper bound
|
|
629
|
+
(``<``) emitted by ``_lib_dashboard_dates.parse_filter_date_range``. The
|
|
630
|
+
strict ``<`` is load-bearing: it makes the lex compare against the stored
|
|
631
|
+
mixed-precision ``last_activity_utc`` (whole-second AND millisecond ``...Z``)
|
|
632
|
+
chronologically correct at the day edge (review Finding 1)."""
|
|
633
|
+
clauses, params = [], []
|
|
634
|
+
if filters["date_from"] is not None:
|
|
635
|
+
clauses.append("last_activity_utc >= ?"); params.append(filters["date_from"])
|
|
636
|
+
if filters["date_to"] is not None:
|
|
637
|
+
clauses.append("last_activity_utc < ?"); params.append(filters["date_to"])
|
|
638
|
+
if filters["projects"]:
|
|
639
|
+
ph = ",".join("?" for _ in filters["projects"])
|
|
640
|
+
clauses.append("project_label IN (%s)" % ph); params.extend(filters["projects"])
|
|
641
|
+
if filters["cost_min"] is not None:
|
|
642
|
+
clauses.append("cost_usd >= ?"); params.append(filters["cost_min"])
|
|
643
|
+
if filters["cost_max"] is not None:
|
|
644
|
+
clauses.append("cost_usd <= ?"); params.append(filters["cost_max"])
|
|
645
|
+
if filters["rebuild_min"] is not None:
|
|
646
|
+
clauses.append("cache_rebuild_count >= ?"); params.append(filters["rebuild_min"])
|
|
647
|
+
return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _live_having(filters):
|
|
651
|
+
"""(sql_fragment, params) for the LIVE fallback — only the DATE axis is
|
|
652
|
+
expressible (no cost/project/rebuild columns exist on the raw messages).
|
|
653
|
+
The rollup's last_activity_utc IS MAX(timestamp_utc), so the date predicate
|
|
654
|
+
is the same HALF-OPEN bound applied via HAVING over the aggregate: inclusive
|
|
655
|
+
``>=`` lower, EXCLUSIVE strict-``<`` upper — kept byte-for-byte in lockstep
|
|
656
|
+
with ``_rollup_where``'s date axis so both branches agree at the day edge
|
|
657
|
+
over mixed-precision timestamps (review Finding 1)."""
|
|
658
|
+
having, params = [], []
|
|
659
|
+
if filters["date_from"] is not None:
|
|
660
|
+
having.append("MAX(timestamp_utc) >= ?"); params.append(filters["date_from"])
|
|
661
|
+
if filters["date_to"] is not None:
|
|
662
|
+
having.append("MAX(timestamp_utc) < ?"); params.append(filters["date_to"])
|
|
663
|
+
return (" HAVING " + " AND ".join(having)) if having else "", params
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
|
|
574
667
|
"""FAST path: read the pre-aggregated rail rows straight from the
|
|
575
668
|
conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
|
|
576
669
|
``recent`` sort. Returns (session_id, msg_count, started, last_activity)
|
|
577
670
|
tuples — the same shape the live aggregate yields, so the downstream
|
|
578
|
-
assembly is identical.
|
|
579
|
-
|
|
671
|
+
assembly is identical. The optional ``filters`` dict pushes the four-axis
|
|
672
|
+
browse predicate (date/project/cost/rebuild — all stored columns) into the
|
|
673
|
+
WHERE BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over
|
|
674
|
+
the filtered set."""
|
|
675
|
+
where, params = _rollup_where(filters or _empty_filters())
|
|
580
676
|
return conn.execute(
|
|
581
677
|
"SELECT session_id, msg_count, "
|
|
582
678
|
" started_utc AS started, last_activity_utc AS last_activity "
|
|
583
|
-
"FROM conversation_sessions
|
|
584
|
-
|
|
585
|
-
|
|
679
|
+
"FROM conversation_sessions"
|
|
680
|
+
+ where +
|
|
681
|
+
" ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
682
|
+
(*params, limit + 1, offset),
|
|
586
683
|
).fetchall()
|
|
587
684
|
|
|
588
685
|
|
|
589
|
-
def _list_session_rows_live(conn, order, limit, offset):
|
|
686
|
+
def _list_session_rows_live(conn, order, limit, offset, filters=None):
|
|
590
687
|
"""RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
|
|
591
688
|
conversation_messages, used while the rollup is not authoritative (the flag
|
|
592
689
|
is set — e.g. an existing install before its first sync, or permanently
|
|
593
690
|
under ``--no-sync``). Byte-identical output to the rollup branch by
|
|
594
|
-
construction; ``order`` here is the _SORTS_LIVE aggregate expression.
|
|
691
|
+
construction; ``order`` here is the _SORTS_LIVE aggregate expression. The
|
|
692
|
+
optional ``filters`` dict applies ONLY the date axis (via HAVING over
|
|
693
|
+
MAX(timestamp_utc)); the rollup-only axes (project/cost/rebuild) cannot be
|
|
694
|
+
expressed here and are dropped — the caller flags filter_degraded."""
|
|
695
|
+
having, hparams = _live_having(filters or _empty_filters())
|
|
595
696
|
return conn.execute(
|
|
596
697
|
"SELECT session_id, COUNT(*) AS msg_count, "
|
|
597
698
|
" MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
|
|
598
699
|
"FROM conversation_messages "
|
|
599
700
|
"WHERE session_id IS NOT NULL "
|
|
600
|
-
"GROUP BY session_id
|
|
601
|
-
|
|
602
|
-
|
|
701
|
+
"GROUP BY session_id"
|
|
702
|
+
+ having +
|
|
703
|
+
" ORDER BY " + order + " LIMIT ? OFFSET ?",
|
|
704
|
+
(*hparams, limit + 1, offset),
|
|
603
705
|
).fetchall()
|
|
604
706
|
|
|
605
707
|
|
|
606
|
-
def
|
|
708
|
+
def list_conversation_facets(conn) -> dict:
|
|
709
|
+
"""Distinct projects (+ conversation counts) for the browse filter
|
|
710
|
+
multi-select (spec §2). Reads the rollup; cheap GROUP BY. Empty/NULL
|
|
711
|
+
project labels are dropped (a no-cwd session stores '' and a not-yet-filled
|
|
712
|
+
row stores NULL — neither is a real selectable project). Sorted ascending
|
|
713
|
+
by label so the popover renders a stable list. Returns
|
|
714
|
+
``{"projects": [{"project_label": str, "count": int}, ...]}``."""
|
|
715
|
+
rows = conn.execute(
|
|
716
|
+
"SELECT project_label, COUNT(*) FROM conversation_sessions "
|
|
717
|
+
"WHERE project_label IS NOT NULL AND project_label != '' "
|
|
718
|
+
"GROUP BY project_label ORDER BY project_label"
|
|
719
|
+
).fetchall()
|
|
720
|
+
return {"projects": [{"project_label": p, "count": n} for p, n in rows]}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
724
|
+
date_from=None, date_to=None, projects=None,
|
|
725
|
+
cost_min=None, cost_max=None, rebuild_min=None) -> dict:
|
|
607
726
|
"""All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
|
|
608
727
|
|
|
609
728
|
Reads the conversation_sessions rollup (Task A) when it is authoritative —
|
|
@@ -622,12 +741,27 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
|
|
|
622
741
|
revalidates every visible tick anyway."""
|
|
623
742
|
limit = max(1, min(int(limit), 200))
|
|
624
743
|
offset = max(0, int(offset))
|
|
744
|
+
# Browse-list filters (spec §2). The rollup branch expresses all four axes
|
|
745
|
+
# as column predicates; the live fallback only the date axis, so when a
|
|
746
|
+
# rollup-only axis is requested under the live branch we flag the page
|
|
747
|
+
# filter_degraded (a brief, self-correcting non-authoritative window).
|
|
748
|
+
filters = {
|
|
749
|
+
"date_from": date_from,
|
|
750
|
+
"date_to": date_to,
|
|
751
|
+
"projects": list(projects) if projects else None,
|
|
752
|
+
"cost_min": cost_min,
|
|
753
|
+
"cost_max": cost_max,
|
|
754
|
+
"rebuild_min": rebuild_min,
|
|
755
|
+
}
|
|
756
|
+
degraded = False
|
|
625
757
|
if _rollup_authoritative(conn):
|
|
626
758
|
order = _SORTS.get(sort, _SORTS["recent"])
|
|
627
|
-
rows = _list_session_rows_rollup(conn, order, limit, offset)
|
|
759
|
+
rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
|
|
628
760
|
else:
|
|
629
761
|
order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
|
|
630
|
-
|
|
762
|
+
degraded = any(
|
|
763
|
+
filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
|
|
764
|
+
rows = _list_session_rows_live(conn, order, limit, offset, filters)
|
|
631
765
|
has_more = len(rows) > limit
|
|
632
766
|
rows = rows[:limit]
|
|
633
767
|
session_ids = [r[0] for r in rows]
|
|
@@ -650,12 +784,17 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
|
|
|
650
784
|
}
|
|
651
785
|
for (sid, msg_count, started, last_activity) in rows
|
|
652
786
|
]
|
|
787
|
+
page = {
|
|
788
|
+
"next_offset": offset + len(conversations) if has_more else None,
|
|
789
|
+
"has_more": has_more,
|
|
790
|
+
}
|
|
791
|
+
if degraded:
|
|
792
|
+
# The rail surfaces this: project/cost/rebuild filters apply once the
|
|
793
|
+
# rollup finishes indexing; only the date axis held this page.
|
|
794
|
+
page["filter_degraded"] = True
|
|
653
795
|
return {
|
|
654
796
|
"conversations": conversations,
|
|
655
|
-
"page":
|
|
656
|
-
"next_offset": offset + len(conversations) if has_more else None,
|
|
657
|
-
"has_more": has_more,
|
|
658
|
-
},
|
|
797
|
+
"page": page,
|
|
659
798
|
}
|
|
660
799
|
|
|
661
800
|
|
|
@@ -1169,6 +1308,20 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1169
1308
|
_pl = _project_label(_latest(logical, 10))
|
|
1170
1309
|
_title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
|
|
1171
1310
|
|
|
1311
|
+
# Jump-to-latest target (spec §3): the {session_id, uuid, id} of the
|
|
1312
|
+
# conversation's FINAL rendered turn, sourced from the tail of the
|
|
1313
|
+
# whole-session assembled item list (so it lands on a real grouped item, not
|
|
1314
|
+
# a folded fragment). Codex P2 #4: assembled anchors carry session_id=None
|
|
1315
|
+
# (only the returned PAGE's anchors are patched), so build it EXPLICITLY with
|
|
1316
|
+
# the request session_id rather than copying items[-1]["anchor"] verbatim.
|
|
1317
|
+
# None only for a genuinely empty conversation (an existing session always
|
|
1318
|
+
# has >=1 item, so this stays non-None for any non-None return). Computed on
|
|
1319
|
+
# the unsliced list so it is the SAME regardless of which page was requested.
|
|
1320
|
+
last_anchor = None
|
|
1321
|
+
if items:
|
|
1322
|
+
_la = items[-1]["anchor"]
|
|
1323
|
+
last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
|
|
1324
|
+
|
|
1172
1325
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
1173
1326
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
1174
1327
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -1189,6 +1342,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1189
1342
|
"last_activity_utc": logical[-1][2],
|
|
1190
1343
|
"cost_usd": header_cost,
|
|
1191
1344
|
"models": sorted({r[6] for r in logical if r[6]}),
|
|
1345
|
+
"last_anchor": last_anchor,
|
|
1192
1346
|
"items": [],
|
|
1193
1347
|
"subagent_meta": subagent_meta,
|
|
1194
1348
|
"page": {"next_after": None, "has_more": False},
|
|
@@ -1224,6 +1378,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
1224
1378
|
"last_activity_utc": last[2],
|
|
1225
1379
|
"cost_usd": header_cost,
|
|
1226
1380
|
"models": models,
|
|
1381
|
+
"last_anchor": last_anchor,
|
|
1227
1382
|
"items": page,
|
|
1228
1383
|
"subagent_meta": subagent_meta,
|
|
1229
1384
|
"page": {"next_after": next_after, "has_more": has_more},
|