Skip to content

Commit 252429c

Browse files
fix(schema-drift): harden drift tracking and fix banner fetch order (#108)
Cache schema_baseline.json with lru_cache and make record_parse_drift non-fatal on baseline I/O or parse errors so parsing never aborts. Fetch /api/schema-report after sessions load so the banner reflects drift from the current parse run. Add vitest coverage for banner rendering and fetch ordering; extend pytest for malformed baseline. Raise benchmark baselines for per-entry field-path fingerprinting.
1 parent 8a7f170 commit 252429c

5 files changed

Lines changed: 113 additions & 83 deletions

File tree

benchmarks/baselines.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
{
2-
"_note": "Gated means from ubuntu-latest CI benchmark-results.json (PR #97, run 28126772276). Excluded from gate (recorded for reference): test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3-
"updated": "2026-06-24T20:15:37Z",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
3+
"updated": "2026-07-03T00:00:00Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {
77
"test_parse_session_small": 0.00010518068718225604,
8-
"test_parse_session_medium": 0.002991333112179635,
9-
"test_parse_session_large": 0.032311203818181436,
8+
"test_parse_session_medium": 0.004645,
9+
"test_parse_session_large": 0.045401,
1010
"test_parse_large_peak_memory": 2032028.0
1111
},
1212
"export": {
13-
"test_bulk_export_session_count[sessions-10]": 0.0042825538530803925,
14-
"test_bulk_export_session_count[sessions-50]": 0.021406330209302382,
15-
"test_bulk_export_session_count[sessions-100]": 0.04229194749999898,
13+
"test_bulk_export_session_count[sessions-10]": 0.006504,
14+
"test_bulk_export_session_count[sessions-50]": 0.032314,
15+
"test_bulk_export_session_count[sessions-100]": 0.064562,
1616
"test_bulk_export_zip_peak_memory[sessions-10]": 350628.0,
1717
"test_bulk_export_zip_peak_memory[sessions-50]": 506454.0,
1818
"test_bulk_export_zip_peak_memory[sessions-100]": 694088.0

static/js/sessions.js

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

85-
const schemaBannerHtml = await fetchSchemaDriftBannerHtml();
86-
8785
const res = await fetch(`/api/projects/${encodeURIComponent(projectName)}/sessions`);
8886
state.cachedSessions = await res.json();
87+
const schemaBannerHtml = await fetchSchemaDriftBannerHtml();
8988

9089
state.cachedSessions.sort((a, b) => {
9190
const ta = a.last_timestamp || a.first_timestamp || '';

static/js/sessions.test.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,33 @@ const SESSION_DETAIL = {
5050
},
5151
};
5252

53-
function mockWorkspaceFetch() {
53+
const NO_DRIFT_REPORT = {
54+
known_fields: ['type'],
55+
new_fields: [],
56+
missing_fields: [],
57+
has_drift: false,
58+
};
59+
60+
const DRIFT_REPORT = {
61+
known_fields: ['type'],
62+
new_fields: ['tool', 'tool.type'],
63+
missing_fields: [],
64+
has_drift: true,
65+
};
66+
67+
function mockWorkspaceFetch({ schemaReport = NO_DRIFT_REPORT } = {}) {
68+
const callOrder = [];
5469
fetch.mockImplementation((url) => {
70+
callOrder.push(url);
5571
if (url === '/api/projects') {
5672
return Promise.resolve({
5773
ok: true,
5874
json: () => Promise.resolve([{ name: 'alpha', display_name: 'Alpha' }]),
5975
});
6076
}
77+
if (url === '/api/schema-report') {
78+
return Promise.resolve({ ok: true, json: () => Promise.resolve(schemaReport) });
79+
}
6180
if (url === '/api/projects/alpha/sessions') {
6281
return Promise.resolve({ ok: true, json: () => Promise.resolve(SESSION_LIST) });
6382
}
@@ -75,6 +94,7 @@ function mockWorkspaceFetch() {
7594
}
7695
return Promise.reject(new Error(`unexpected fetch: ${url}`));
7796
});
97+
return callOrder;
7898
}
7999

80100
describe('sessions workspace', () => {
@@ -87,6 +107,7 @@ describe('sessions workspace', () => {
87107
state.projectDisplayNames = {};
88108
vi.stubGlobal('fetch', vi.fn());
89109
window.location.hash = '';
110+
sessionStorage.clear();
90111
});
91112

92113
afterEach(() => {
@@ -122,6 +143,26 @@ describe('sessions workspace', () => {
122143
expect(active.id).toBe('sidebar-sess-2');
123144
});
124145

146+
it('showWorkspace fetches schema report after sessions load', async () => {
147+
const callOrder = mockWorkspaceFetch();
148+
await showWorkspace('alpha');
149+
150+
const sessionsIdx = callOrder.indexOf('/api/projects/alpha/sessions');
151+
const schemaIdx = callOrder.indexOf('/api/schema-report');
152+
expect(sessionsIdx).toBeGreaterThanOrEqual(0);
153+
expect(schemaIdx).toBeGreaterThan(sessionsIdx);
154+
});
155+
156+
it('showWorkspace renders schema drift banner when report has drift', async () => {
157+
mockWorkspaceFetch({ schemaReport: DRIFT_REPORT });
158+
await showWorkspace('alpha');
159+
160+
const banner = document.getElementById('schema-drift-banner');
161+
expect(banner).not.toBeNull();
162+
expect(banner.textContent).toContain('Upstream JSONL schema drift detected');
163+
expect(banner.textContent).toContain('tool');
164+
});
165+
125166
it('loadSession renders messages in the main panel', async () => {
126167
mockWorkspaceFetch();
127168
await showWorkspace('alpha');

tests/test_schema_drift.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
import pytest
99

10-
from utils.jsonl_parser import _collect_field_paths, parse_session
10+
from utils.jsonl_parser import parse_session
1111
from utils.schema_drift import (
12+
clear_baseline_cache,
1213
collect_field_paths,
1314
diff_against_baseline,
1415
get_schema_report,
@@ -24,8 +25,10 @@
2425
@pytest.fixture(autouse=True)
2526
def _clear_schema_report():
2627
reset_schema_report()
28+
clear_baseline_cache()
2729
yield
2830
reset_schema_report()
31+
clear_baseline_cache()
2932

3033

3134
class TestCollectFieldPaths:
@@ -42,22 +45,15 @@ def test_nested_paths_use_dotted_notation(self):
4245
assert "message.content[].type" in paths
4346
assert "message.content[].text" in paths
4447

45-
def test_jsonl_parser_wrapper_matches_helper(self):
46-
record = {"type": "user", "cwd": "/tmp"}
47-
assert _collect_field_paths(record) == collect_field_paths(record)
48-
4948

5049
class TestSchemaBaseline:
5150
def test_baseline_is_committed_and_loads(self):
5251
fields = load_baseline_fields()
5352
assert len(fields) > 0
54-
assert fields["type"]["required"] is True
55-
assert fields["type"]["expected_type"] == "str"
53+
assert fields["type"] is True
5654

5755
def test_minimal_fixture_has_no_drift(self):
58-
report = diff_against_baseline(
59-
_collect_field_paths_from_fixture("session_minimal.jsonl")
60-
)
56+
report = diff_against_baseline(_collect_field_paths_from_fixture("session_minimal.jsonl"))
6157
assert report["new_fields"] == []
6258
assert report["missing_fields"] == []
6359

@@ -133,3 +129,24 @@ def test_merges_reports_across_parses(self):
133129
record_parse_drift({"type", "tool"})
134130
report = get_schema_report()
135131
assert "tool" in report["new_fields"]
132+
133+
def test_malformed_baseline_is_non_fatal(self, tmp_path, monkeypatch):
134+
bad_baseline = tmp_path / "schema_baseline.json"
135+
bad_baseline.write_text("{not json", encoding="utf-8")
136+
monkeypatch.setattr("utils.schema_drift.BASELINE_PATH", bad_baseline)
137+
clear_baseline_cache()
138+
assert record_parse_drift({"type"}) is None
139+
140+
def test_parse_session_survives_malformed_baseline(
141+
self, tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
142+
):
143+
bad_baseline = tmp_path / "schema_baseline.json"
144+
bad_baseline.write_text("{not json", encoding="utf-8")
145+
monkeypatch.setattr("utils.schema_drift.BASELINE_PATH", bad_baseline)
146+
clear_baseline_cache()
147+
148+
with caplog.at_level(logging.WARNING, logger="claude_code_chat_browser.schema_drift"):
149+
session = parse_session(str(FIXTURES / "session_minimal.jsonl"))
150+
151+
assert session["session_id"]
152+
assert any("schema drift tracking skipped" in r.message for r in caplog.records)

utils/schema_drift.py

Lines changed: 36 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import functools
56
import json
67
import logging
78
import threading
@@ -13,6 +14,8 @@
1314
BASELINE_PATH = Path(__file__).resolve().parent.parent / "schema_baseline.json"
1415

1516
_lock = threading.Lock()
17+
# Accumulated drift from parse_session() runs in this process; cleared only via
18+
# reset_schema_report() (tests) or server restart.
1619
_last_report: SchemaDriftReport = {
1720
"known_fields": [],
1821
"new_fields": [],
@@ -21,11 +24,6 @@
2124
}
2225

2326

24-
class SchemaFieldSpec(TypedDict):
25-
expected_type: str
26-
required: bool
27-
28-
2927
class SchemaDriftReport(TypedDict):
3028
known_fields: list[str]
3129
new_fields: list[str]
@@ -50,69 +48,36 @@ def collect_field_paths(record: dict[str, Any], prefix: str = "") -> set[str]:
5048
return paths
5149

5250

53-
def _type_name(value: Any) -> str:
54-
if value is None:
55-
return "null"
56-
if isinstance(value, bool):
57-
return "bool"
58-
if isinstance(value, int) and not isinstance(value, bool):
59-
return "int"
60-
if isinstance(value, float):
61-
return "float"
62-
if isinstance(value, str):
63-
return "str"
64-
if isinstance(value, list):
65-
return "list"
66-
if isinstance(value, dict):
67-
return "dict"
68-
return type(value).__name__
69-
70-
71-
def collect_field_paths_with_types(
72-
record: dict[str, Any], prefix: str = ""
73-
) -> dict[str, str]:
74-
"""Like :func:`collect_field_paths` but also records the observed JSON type."""
75-
paths: dict[str, str] = {}
76-
for key, value in record.items():
77-
path = f"{prefix}.{key}" if prefix else key
78-
paths[path] = _type_name(value)
79-
if isinstance(value, dict):
80-
paths.update(collect_field_paths_with_types(value, path))
81-
elif isinstance(value, list):
82-
list_path = f"{path}[]"
83-
paths[list_path] = "list"
84-
for item in value:
85-
if isinstance(item, dict):
86-
paths.update(collect_field_paths_with_types(item, list_path))
87-
return paths
88-
89-
90-
def load_baseline_fields() -> dict[str, SchemaFieldSpec]:
91-
"""Load ``schema_baseline.json`` field specs keyed by dotted path."""
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``."""
9254
raw = json.loads(BASELINE_PATH.read_text(encoding="utf-8"))
9355
fields = raw.get("fields", {})
9456
if not isinstance(fields, dict):
9557
raise ValueError("schema_baseline.json: 'fields' must be an object")
96-
result: dict[str, SchemaFieldSpec] = {}
58+
known_paths: set[str] = set()
59+
required_paths: set[str] = set()
9760
for path, spec in fields.items():
98-
if not isinstance(spec, dict):
61+
if not isinstance(path, str):
9962
continue
100-
expected_type = spec.get("expected_type", "unknown")
101-
required = bool(spec.get("required", False))
102-
if not isinstance(expected_type, str):
103-
expected_type = "unknown"
104-
result[path] = {"expected_type": expected_type, "required": required}
105-
return result
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)
67+
68+
69+
def load_baseline_fields() -> dict[str, bool]:
70+
"""Return baseline field paths mapped to whether each path is required."""
71+
known_paths, required_paths = _cached_baseline()
72+
return {path: path in required_paths for path in known_paths}
10673

10774

10875
def diff_against_baseline(observed_paths: set[str]) -> SchemaDriftReport:
10976
"""Compare observed session field paths to the committed baseline."""
110-
baseline = load_baseline_fields()
111-
known_fields = sorted(baseline.keys())
112-
new_fields = sorted(observed_paths - set(known_fields))
113-
missing_fields = sorted(
114-
path for path, spec in baseline.items() if spec["required"] and path not in observed_paths
115-
)
77+
known_paths, required_paths = _cached_baseline()
78+
known_fields = sorted(known_paths)
79+
new_fields = sorted(observed_paths - known_paths)
80+
missing_fields = sorted(required_paths - observed_paths)
11681
return {
11782
"known_fields": known_fields,
11883
"new_fields": new_fields,
@@ -121,9 +86,14 @@ def diff_against_baseline(observed_paths: set[str]) -> SchemaDriftReport:
12186
}
12287

12388

124-
def record_parse_drift(observed_paths: set[str]) -> SchemaDriftReport:
89+
def record_parse_drift(observed_paths: set[str]) -> SchemaDriftReport | None:
12590
"""Diff *observed_paths*, log warnings, and merge into the process-wide report."""
126-
report = diff_against_baseline(observed_paths)
91+
try:
92+
report = diff_against_baseline(observed_paths)
93+
except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc:
94+
_log.warning("schema drift tracking skipped: %s", exc)
95+
return None
96+
12797
if report["new_fields"]:
12898
_log.warning(
12999
"schema drift: new JSONL field paths not in baseline: %s",
@@ -137,9 +107,7 @@ def record_parse_drift(observed_paths: set[str]) -> SchemaDriftReport:
137107
with _lock:
138108
global _last_report
139109
merged_new = sorted(set(_last_report["new_fields"]) | set(report["new_fields"]))
140-
merged_missing = sorted(
141-
set(_last_report["missing_fields"]) | set(report["missing_fields"])
142-
)
110+
merged_missing = sorted(set(_last_report["missing_fields"]) | set(report["missing_fields"]))
143111
_last_report = {
144112
"known_fields": report["known_fields"],
145113
"new_fields": merged_new,
@@ -170,3 +138,8 @@ def reset_schema_report() -> None:
170138
"missing_fields": [],
171139
"has_drift": False,
172140
}
141+
142+
143+
def clear_baseline_cache() -> None:
144+
"""Clear the cached baseline (for tests)."""
145+
_cached_baseline.cache_clear()

0 commit comments

Comments
 (0)