Skip to content

Commit a05fd29

Browse files
Session list performance: disk summary cache, display-name cache, and count alignment (#111)
* Add disk-backed session summary cache and display-name cache Session list and landing page re-parsed or re-read every .jsonl on each cold start and on every GET /api/projects call. Add SQLite summary cache (keyed by path, mtime, rules fingerprint) so complete rows survive restart; route get_projects and get_project_sessions through it. Cache project display names by directory max mtime. Align project card session_count with session list when no exclusion rules are active. * Add disk-backed session summary cache and display-name cache * Harden max_cache_rows against invalid env override Catch ValueError when CLAUDE_CODE_CHAT_BROWSER_SUMMARY_CACHE_MAX_ROWS is not an integer and fall back to DEFAULT_MAX_ROWS so put_summary() does not fail on the session-list path. * Address feedback
1 parent 6465d4a commit a05fd29

6 files changed

Lines changed: 604 additions & 42 deletions

File tree

api/projects.py

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,23 @@
55
from api._flask_types import FlaskReturn, json_response
66
from api.error_codes import ErrorCode, error_response
77
from models.project import ProjectSessionRowDict, SessionListItemDict
8-
from models.session import SessionDict
98
from utils.exclusion_rules import is_session_excluded
9+
from utils.jsonl_parser import quick_session_info
1010
from utils.session_cache import get_cached_session
1111
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
12+
from utils.session_summary_cache import (
13+
SummaryCacheRowDict,
14+
get_summary,
15+
put_summary,
16+
rules_fingerprint,
17+
session_row_from_summary,
18+
summary_from_peek,
19+
summary_from_session,
20+
)
1221

1322
projects_bp = Blueprint("projects", __name__)
1423

1524

16-
def _session_row_ok(s: SessionListItemDict, parsed: SessionDict) -> ProjectSessionRowDict:
17-
meta = parsed["metadata"]
18-
models = meta.get("models_used", [])
19-
return {
20-
"id": s["id"],
21-
"path": s["path"],
22-
"size_bytes": s["size_bytes"],
23-
"modified": s["modified"],
24-
"title": parsed["title"],
25-
"models": sorted(models) if isinstance(models, set) else list(models),
26-
"tokens": meta["total_input_tokens"] + meta["total_output_tokens"],
27-
"tool_calls": meta["total_tool_calls"],
28-
"first_timestamp": meta["first_timestamp"],
29-
"last_timestamp": meta["last_timestamp"],
30-
}
31-
32-
3325
def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
3426
return {
3527
"id": s["id"],
@@ -41,30 +33,53 @@ def _session_row_error(s: SessionListItemDict) -> ProjectSessionRowDict:
4133
}
4234

4335

36+
def _peek_or_cache_summary(path: str, mtime: float, rules_fp: str) -> SummaryCacheRowDict:
37+
"""Return a cached summary row (any completeness) or peek the file and store a partial row.
38+
39+
Used by get_projects for fast landing-page counts. Partial rows (is_complete=False)
40+
are acceptable here — is_untitled is derived from the same first-user-text peek that
41+
quick_session_info uses; peek and full-parse agree for the vast majority of sessions
42+
(first user message within the first 80 lines). The session list path always upgrades
43+
to a complete row via get_cached_session, so session_count and list count align after
44+
the first session-list visit. With no exclusion rules the counts are identical on first
45+
visit too, because is_excluded is always False for both paths.
46+
"""
47+
cached = get_summary(path, mtime, rules_fp)
48+
if cached is not None:
49+
return cached
50+
info = quick_session_info(path)
51+
row = summary_from_peek(info)
52+
put_summary(path, mtime, rules_fp, row)
53+
return row
54+
55+
4456
@projects_bp.route("/api/projects")
4557
def get_projects() -> FlaskReturn:
4658
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
4759
projects = list_projects(base)
48-
49-
# Enrich each project with accurate titled-session count and latest timestamp
50-
# so the landing page matches what the workspace page shows.
51-
# Uses quick_session_info() which peeks at files without full parsing.
52-
from utils.jsonl_parser import quick_session_info
60+
rules = current_app.config.get("EXCLUSION_RULES") or []
61+
rules_fp = rules_fingerprint(rules)
5362

5463
for project in projects:
5564
sessions = list_sessions(project["path"])
5665
titled_count = 0
5766
latest_ts = None
5867
for s in sessions:
5968
try:
60-
info = quick_session_info(s["path"])
61-
if info["title"] == "Untitled Session":
69+
row = _peek_or_cache_summary(s["path"], s["modified"], rules_fp)
70+
if row["is_untitled"]:
71+
continue
72+
if row["is_complete"] and row["is_excluded"]:
6273
continue
6374
titled_count += 1
64-
ts = info.get("last_timestamp") or info.get("first_timestamp")
75+
ts = row.get("last_timestamp") or row.get("first_timestamp")
6576
if ts and (latest_ts is None or ts > latest_ts):
6677
latest_ts = ts
6778
except Exception:
79+
current_app.logger.exception(
80+
"Failed to peek session summary for project %s",
81+
project["name"],
82+
)
6883
titled_count += 1
6984
project["session_count"] = titled_count
7085
if latest_ts:
@@ -82,21 +97,25 @@ def get_project_sessions(project_name: str) -> FlaskReturn:
8297
return error_response(ErrorCode.INVALID_PATH, "Invalid path", 400)
8398
sessions = list_sessions(project_dir)
8499
rules = current_app.config.get("EXCLUSION_RULES") or []
100+
rules_fp = rules_fingerprint(rules)
85101
result: list[ProjectSessionRowDict] = []
86102
for s in sessions:
87103
try:
88-
parsed = get_cached_session(s["path"])
89-
# Skip untitled sessions (no real conversation)
90-
if parsed["title"] == "Untitled Session":
104+
cached = get_summary(s["path"], s["modified"], rules_fp)
105+
if cached is not None and cached["is_complete"]:
106+
if cached["is_untitled"] or cached["is_excluded"]:
107+
continue
108+
result.append(session_row_from_summary(s, cached))
91109
continue
92-
if is_session_excluded(rules, parsed, project_name):
110+
111+
parsed = get_cached_session(s["path"])
112+
excluded = is_session_excluded(rules, parsed, project_name)
113+
row = summary_from_session(parsed, is_excluded=excluded)
114+
put_summary(s["path"], s["modified"], rules_fp, row)
115+
if row["is_untitled"] or excluded:
93116
continue
94-
result.append(_session_row_ok(s, parsed))
117+
result.append(session_row_from_summary(s, row))
95118
except Exception:
96-
# Full detail (class, message, traceback) to the server log via
97-
# logger.exception. The per-session card carries only `error: True`
98-
# — the class-name+message string was a leak (issue #25). The
99-
# operator looks at the server log for triage.
100119
current_app.logger.exception("Failed to parse session %s", s["id"])
101120
result.append(_session_row_error(s))
102121
return json_response(result)

tests/test_api_integration.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from __future__ import annotations
1111

12+
import pytest
13+
1214
from app import CSP_POLICY
1315
from tests.conftest import assert_error_response as _assert_error_shape
1416

@@ -124,3 +126,46 @@ def test_search_valid_limit(client):
124126
results = resp.get_json()
125127
assert isinstance(results, list)
126128
assert len(results) <= 5
129+
130+
131+
# --- session summary cache (disk) ---
132+
133+
134+
@pytest.fixture
135+
def summary_cache_db(tmp_path, monkeypatch):
136+
from utils.session_summary_cache import clear_cache, reset_connection_for_tests
137+
138+
db = tmp_path / "session_summary_cache.sqlite"
139+
reset_connection_for_tests(db)
140+
yield db
141+
clear_cache()
142+
143+
144+
def test_project_session_count_matches_list(client, summary_cache_db):
145+
"""Card count and session list agree when no exclusion rules are active.
146+
147+
get_projects uses peek (partial row); get_project_sessions uses full parse.
148+
Both filter on is_untitled, and with no rules is_excluded is always False,
149+
so counts align — this is the alignment guarantee from issue #109.
150+
"""
151+
# Hit session list first so disk cache is warm with complete rows.
152+
sessions = client.get("/api/projects/test-project/sessions").get_json()
153+
projects = client.get("/api/projects").get_json()
154+
project = next(p for p in projects if p["name"] == "test-project")
155+
assert project["session_count"] == len(sessions)
156+
157+
158+
def test_project_sessions_uses_disk_cache_on_second_request(client, summary_cache_db, monkeypatch):
159+
client.get("/api/projects/test-project/sessions")
160+
calls = 0
161+
162+
def counting_get_cached(path: str):
163+
nonlocal calls
164+
calls += 1
165+
from utils.session_cache import get_cached_session as real_get
166+
167+
return real_get(path)
168+
169+
monkeypatch.setattr("api.projects.get_cached_session", counting_get_cached)
170+
client.get("/api/projects/test-project/sessions")
171+
assert calls == 0

tests/test_session_path.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,30 @@ def test_get_claude_projects_dir_on_windows_runner(
3939
got = session_path.get_claude_projects_dir()
4040
expected = os.path.join(str(profile), ".claude", "projects")
4141
assert got == expected
42+
43+
44+
def test_display_name_cache_avoids_repeat_file_reads(
45+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
46+
) -> None:
47+
session_path.clear_display_name_cache()
48+
project_dir = tmp_path / "proj-hash"
49+
project_dir.mkdir()
50+
jsonl = project_dir / "session.jsonl"
51+
jsonl.write_text(
52+
'{"type":"user","cwd":"/home/user/MyProject","timestamp":"2026-01-01T00:00:00Z"}\n',
53+
encoding="utf-8",
54+
)
55+
calls = 0
56+
real_get = session_path._get_display_name
57+
58+
def counting_get_display_name(*args, **kwargs):
59+
nonlocal calls
60+
calls += 1
61+
return real_get(*args, **kwargs)
62+
63+
monkeypatch.setattr(session_path, "_get_display_name", counting_get_display_name)
64+
session_path.list_projects(str(tmp_path))
65+
first_calls = calls
66+
session_path.list_projects(str(tmp_path))
67+
assert first_calls > 0
68+
assert calls == first_calls

0 commit comments

Comments
 (0)