Skip to content

Commit aa6b31f

Browse files
Address timon's feedback
1 parent 252429c commit aa6b31f

6 files changed

Lines changed: 116 additions & 49 deletions

File tree

schema_baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"description": "Known Claude Code JSONL field paths as of 2026-07-03. Update deliberately when upstream format changes.",
3+
"description": "Known Claude Code JSONL field paths as of 2026-07-03. Drift detection compares path presence only (added/removed paths); expected_type is documentary metadata for baseline updates, not checked at runtime. Mark required:true only for paths present on every record (currently: type). Update deliberately when upstream format changes.",
44
"fields": {
55
"_futureSchemaVersion": {
66
"expected_type": "int",

static/js/sessions.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ export async function showWorkspace(projectName, selectedSessionId) {
8282
}
8383
const prettyName = state.projectDisplayNames[projectName] || projectName;
8484

85+
const schemaBannerPromise = fetchSchemaDriftBannerHtml();
8586
const res = await fetch(`/api/projects/${encodeURIComponent(projectName)}/sessions`);
8687
state.cachedSessions = await res.json();
87-
const schemaBannerHtml = await fetchSchemaDriftBannerHtml();
8888

8989
state.cachedSessions.sort((a, b) => {
9090
const ta = a.last_timestamp || a.first_timestamp || '';
@@ -123,7 +123,7 @@ export async function showWorkspace(projectName, selectedSessionId) {
123123
}
124124
sidebar += '</div>';
125125

126-
let html = `${schemaBannerHtml}<div class="workspace-top-bar">
126+
let html = `<div class="workspace-top-bar">
127127
<a class="btn btn-ghost btn-sm back-link" href="#" id="ws-back-link">
128128
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
129129
Back to Projects
@@ -142,7 +142,13 @@ export async function showWorkspace(projectName, selectedSessionId) {
142142
</div>`;
143143
smoothSet(content, html);
144144
bindSidebarSessionClicks();
145-
bindSchemaDriftBanner(content);
145+
void schemaBannerPromise.then((schemaBannerHtml) => {
146+
if (!schemaBannerHtml) return;
147+
const root = document.getElementById('content');
148+
if (!root) return;
149+
root.insertAdjacentHTML('afterbegin', schemaBannerHtml);
150+
bindSchemaDriftBanner(root);
151+
});
146152
content.querySelector('#ws-back-link')?.addEventListener('click', (e) => {
147153
e.preventDefault();
148154
showProjects();

static/js/sessions.test.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,22 +143,27 @@ describe('sessions workspace', () => {
143143
expect(active.id).toBe('sidebar-sess-2');
144144
});
145145

146-
it('showWorkspace fetches schema report after sessions load', async () => {
146+
it('showWorkspace starts schema report fetch with sessions without blocking render', async () => {
147147
const callOrder = mockWorkspaceFetch();
148148
await showWorkspace('alpha');
149149

150150
const sessionsIdx = callOrder.indexOf('/api/projects/alpha/sessions');
151151
const schemaIdx = callOrder.indexOf('/api/schema-report');
152152
expect(sessionsIdx).toBeGreaterThanOrEqual(0);
153-
expect(schemaIdx).toBeGreaterThan(sessionsIdx);
153+
expect(schemaIdx).toBeGreaterThanOrEqual(0);
154+
expect(schemaIdx).toBeLessThan(sessionsIdx + 2);
155+
expect(document.getElementById('sidebar')).not.toBeNull();
156+
expect(document.getElementById('schema-drift-banner')).toBeNull();
154157
});
155158

156159
it('showWorkspace renders schema drift banner when report has drift', async () => {
157160
mockWorkspaceFetch({ schemaReport: DRIFT_REPORT });
158161
await showWorkspace('alpha');
159162

163+
await vi.waitFor(() => {
164+
expect(document.getElementById('schema-drift-banner')).not.toBeNull();
165+
});
160166
const banner = document.getElementById('schema-drift-banner');
161-
expect(banner).not.toBeNull();
162167
expect(banner.textContent).toContain('Upstream JSONL schema drift detected');
163168
expect(banner.textContent).toContain('tool');
164169
});

tests/test_schema_drift.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,22 @@ def test_parse_session_survives_malformed_baseline(
149149
session = parse_session(str(FIXTURES / "session_minimal.jsonl"))
150150

151151
assert session["session_id"]
152-
assert any("schema drift tracking skipped" in r.message for r in caplog.records)
152+
assert any("baseline load failed" in r.message for r in caplog.records)
153+
154+
def test_duplicate_new_fields_log_once(self, caplog: pytest.LogCaptureFixture):
155+
with caplog.at_level(logging.WARNING, logger="claude_code_chat_browser.schema_drift"):
156+
parse_session(str(UNKNOWN_FIELD_FIXTURE))
157+
parse_session(str(UNKNOWN_FIELD_FIXTURE))
158+
159+
new_field_warnings = [
160+
r
161+
for r in caplog.records
162+
if r.name == "claude_code_chat_browser.schema_drift"
163+
and "new JSONL field paths" in r.message
164+
]
165+
assert len(new_field_warnings) == 1
166+
167+
def test_schema_drift_disabled_skips_fingerprint(self, monkeypatch):
168+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT", "0")
169+
parse_session(str(UNKNOWN_FIELD_FIXTURE))
170+
assert get_schema_report()["has_drift"] is False

utils/jsonl_parser.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
infer_title as _infer_title,
2626
normalize_content as _normalize_content,
2727
)
28-
from utils.schema_drift import collect_field_paths, record_parse_drift
28+
from utils.schema_drift import (
29+
collect_field_paths,
30+
is_schema_drift_enabled,
31+
record_parse_drift,
32+
schema_drift_sample_limit,
33+
)
2934
from utils.session_peek import quick_session_info
3035
from utils.tool_dispatch import _parse_tool_result, track_tool_file_activity
3136
from utils.validation import validate_session_dict
@@ -38,11 +43,6 @@
3843
_log = logging.getLogger(__name__)
3944

4045

41-
def _collect_field_paths(record: dict[str, Any]) -> set[str]:
42-
"""Recursive JSON path fingerprinting for schema drift detection."""
43-
return collect_field_paths(record)
44-
45-
4646
def _coerce_role(raw: str) -> RoleLiteral:
4747
if raw in _VALID_ROLES:
4848
return cast(RoleLiteral, raw)
@@ -175,6 +175,7 @@ def parse_session(filepath: str) -> SessionDict:
175175
messages: list[MessageDict] = []
176176
metadata = _new_session_metadata_builder(session_id)
177177
observed_field_paths: set[str] = set()
178+
schema_samples_remaining = schema_drift_sample_limit() if is_schema_drift_enabled() else 0
178179

179180
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
180181
for line in f:
@@ -189,7 +190,9 @@ def parse_session(filepath: str) -> SessionDict:
189190
if not isinstance(entry, dict):
190191
continue
191192

192-
observed_field_paths |= _collect_field_paths(entry)
193+
if schema_samples_remaining > 0:
194+
observed_field_paths |= collect_field_paths(entry)
195+
schema_samples_remaining -= 1
193196

194197
entry_type = entry.get("type")
195198
ts = _entry_timestamp(entry)
@@ -235,7 +238,8 @@ def parse_session(filepath: str) -> SessionDict:
235238

236239
title = _infer_title(messages)
237240

238-
record_parse_drift(observed_field_paths)
241+
if is_schema_drift_enabled() and observed_field_paths:
242+
record_parse_drift(observed_field_paths)
239243

240244
return validate_session_dict(
241245
{

utils/schema_drift.py

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
import functools
65
import json
76
import logging
7+
import os
88
import threading
99
from pathlib import Path
1010
from typing import Any, TypedDict
@@ -14,8 +14,11 @@
1414
BASELINE_PATH = Path(__file__).resolve().parent.parent / "schema_baseline.json"
1515

1616
_lock = threading.Lock()
17-
# Accumulated drift from parse_session() runs in this process; cleared only via
18-
# reset_schema_report() (tests) or server restart.
17+
# Process-wide union of *new* field paths seen since server start (or reset_schema_report).
18+
# Intentionally sticky: once upstream drift is detected, the banner stays until restart so
19+
# operators do not miss it. missing_fields reflects only the most recent sampled parse.
20+
_baseline_cache: tuple[frozenset[str], frozenset[str]] | None = None
21+
_baseline_load_failed: bool = False
1922
_last_report: SchemaDriftReport = {
2023
"known_fields": [],
2124
"new_fields": [],
@@ -31,6 +34,21 @@ class SchemaDriftReport(TypedDict):
3134
has_drift: bool
3235

3336

37+
def is_schema_drift_enabled() -> bool:
38+
"""Return False when CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT=0|false|no."""
39+
flag = os.environ.get("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT", "1").strip().lower()
40+
return flag not in ("0", "false", "no")
41+
42+
43+
def schema_drift_sample_limit() -> int:
44+
"""Max JSONL records per session to fingerprint (default 3). Set 0 to disable sampling cap."""
45+
raw = os.environ.get("CLAUDE_CODE_CHAT_BROWSER_SCHEMA_DRIFT_SAMPLE", "3").strip()
46+
try:
47+
return max(0, int(raw))
48+
except ValueError:
49+
return 3
50+
51+
3452
def collect_field_paths(record: dict[str, Any], prefix: str = "") -> set[str]:
3553
"""Recursively collect dotted JSON paths (with ``[]`` for list items)."""
3654
paths: set[str] = set()
@@ -48,33 +66,44 @@ def collect_field_paths(record: dict[str, Any], prefix: str = "") -> set[str]:
4866
return paths
4967

5068

51-
@functools.lru_cache(maxsize=1)
52-
def _cached_baseline() -> tuple[frozenset[str], frozenset[str]]:
53-
"""Load and cache known/required field paths from ``schema_baseline.json``."""
54-
raw = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
55-
fields = raw.get("fields", {})
56-
if not isinstance(fields, dict):
57-
raise ValueError("schema_baseline.json: 'fields' must be an object")
58-
known_paths: set[str] = set()
59-
required_paths: set[str] = set()
60-
for path, spec in fields.items():
61-
if not isinstance(path, str):
62-
continue
63-
known_paths.add(path)
64-
if isinstance(spec, dict) and spec.get("required"):
65-
required_paths.add(path)
66-
return frozenset(known_paths), frozenset(required_paths)
69+
def _load_baseline() -> tuple[frozenset[str], frozenset[str]]:
70+
"""Load baseline paths once; cache success and remember hard failures."""
71+
global _baseline_cache, _baseline_load_failed
72+
if _baseline_load_failed:
73+
raise ValueError("schema_baseline.json previously failed to load")
74+
if _baseline_cache is not None:
75+
return _baseline_cache
76+
try:
77+
raw = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
78+
fields = raw.get("fields", {})
79+
if not isinstance(fields, dict):
80+
raise ValueError("schema_baseline.json: 'fields' must be an object")
81+
known_paths: set[str] = set()
82+
required_paths: set[str] = set()
83+
for path, spec in fields.items():
84+
if not isinstance(path, str):
85+
continue
86+
known_paths.add(path)
87+
if isinstance(spec, dict) and spec.get("required"):
88+
required_paths.add(path)
89+
_baseline_cache = (frozenset(known_paths), frozenset(required_paths))
90+
return _baseline_cache
91+
except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc:
92+
if not _baseline_load_failed:
93+
_baseline_load_failed = True
94+
_log.warning("schema drift baseline load failed (will not retry): %s", exc)
95+
raise
6796

6897

6998
def load_baseline_fields() -> dict[str, bool]:
7099
"""Return baseline field paths mapped to whether each path is required."""
71-
known_paths, required_paths = _cached_baseline()
100+
known_paths, required_paths = _load_baseline()
72101
return {path: path in required_paths for path in known_paths}
73102

74103

75104
def diff_against_baseline(observed_paths: set[str]) -> SchemaDriftReport:
76-
"""Compare observed session field paths to the committed baseline."""
77-
known_paths, required_paths = _cached_baseline()
105+
"""Compare observed session field paths to the committed baseline (paths only, not types)."""
106+
known_paths, required_paths = _load_baseline()
78107
known_fields = sorted(known_paths)
79108
new_fields = sorted(observed_paths - known_paths)
80109
missing_fields = sorted(required_paths - observed_paths)
@@ -90,29 +119,32 @@ def record_parse_drift(observed_paths: set[str]) -> SchemaDriftReport | None:
90119
"""Diff *observed_paths*, log warnings, and merge into the process-wide report."""
91120
try:
92121
report = diff_against_baseline(observed_paths)
93-
except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc:
94-
_log.warning("schema drift tracking skipped: %s", exc)
122+
except (OSError, json.JSONDecodeError, ValueError, TypeError):
95123
return None
96124

97-
if report["new_fields"]:
125+
with _lock:
126+
global _last_report
127+
prior_new = set(_last_report["new_fields"])
128+
genuinely_new = sorted(set(report["new_fields"]) - prior_new)
129+
merged_new = sorted(prior_new | set(report["new_fields"]))
130+
131+
if genuinely_new:
98132
_log.warning(
99133
"schema drift: new JSONL field paths not in baseline: %s",
100-
report["new_fields"],
134+
genuinely_new,
101135
)
102136
if report["missing_fields"]:
103137
_log.warning(
104-
"schema drift: missing required JSONL field paths: %s",
138+
"schema drift: missing required JSONL field paths in sampled records: %s",
105139
report["missing_fields"],
106140
)
141+
107142
with _lock:
108-
global _last_report
109-
merged_new = sorted(set(_last_report["new_fields"]) | set(report["new_fields"]))
110-
merged_missing = sorted(set(_last_report["missing_fields"]) | set(report["missing_fields"]))
111143
_last_report = {
112144
"known_fields": report["known_fields"],
113145
"new_fields": merged_new,
114-
"missing_fields": merged_missing,
115-
"has_drift": bool(merged_new or merged_missing),
146+
"missing_fields": list(report["missing_fields"]),
147+
"has_drift": bool(merged_new or report["missing_fields"]),
116148
}
117149
return report
118150

@@ -142,4 +174,6 @@ def reset_schema_report() -> None:
142174

143175
def clear_baseline_cache() -> None:
144176
"""Clear the cached baseline (for tests)."""
145-
_cached_baseline.cache_clear()
177+
global _baseline_cache, _baseline_load_failed
178+
_baseline_cache = None
179+
_baseline_load_failed = False

0 commit comments

Comments
 (0)