Skip to content

Commit b9f34a6

Browse files
committed
review: log schema drift + harden get_composer envelope (#30)
CodeRabbit follow-ups to the round-2 wiring pass: 1. The three bubble-loading loops (api/workspaces.py:583, 1025; api/search.py:149) caught SchemaError, json.JSONDecodeError and ValueError together and dropped the row silently. That broke the read-boundary contract — a drifted bubble would simply vanish with no operator signal. Split the catches: SchemaError now logs `Schema drift in bubble <bid>: <reason>`; JSONDecodeError / ValueError still pass silently because that's a parser-noise issue, not a schema-contract issue. 2. get_composer() at api/composers.py:125 trusted that the per-workspace blob was a dict with a list-valued allComposers. A drifted local row like `[]` or `"…"` raised AttributeError on data.get(...) and turned schema drift into a 500. Mirrored the three envelope guards list_composers() already applies at line 60–74 (data is dict, allComposers present, allComposers is list) and wrapped the outer try with `except SchemaError` so drift logs the db_path and falls through to the global fallback instead of 500'ing. 3. scripts/ lacked __init__.py while every other top-level package directory (api/, models/, utils/, tests/) has one. The new `from scripts import export` calls in tests resolve at runtime via the sys.path insert in the test header, but mypy was reporting inconsistent module resolution on scripts.export — a CI typecheck risk. Added an empty scripts/__init__.py to match convention. 3 new regression tests in tests/test_models_wired_at_read_sites.py: - test_bubble_schema_drift_is_logged_not_swallowed_silently — seeds a malformed bubble, captures stdout, asserts the `Schema drift in bubble <bid>` line is present. - test_get_composer_handles_non_dict_envelope_via_fallback — replaces composer.composerData with a list payload, asserts the endpoint returns 200 via the global fallback (was 500 before the envelope guards). - test_get_composer_handles_non_list_all_composers_via_fallback — same shape for non-list allComposers. Verified locally: - python -m unittest discover tests: 223 passed (was 220) - python -m unittest tests.test_models_wired_at_read_sites: 13/13 - mypy resolves scripts.export consistently
1 parent 16f861d commit b9f34a6

5 files changed

Lines changed: 112 additions & 9 deletions

File tree

api/composers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,25 @@ def get_composer(composer_id):
124124

125125
if row and row[0]:
126126
data = json.loads(row[0])
127-
for c in (data.get("allComposers") or []):
127+
# Mirror the envelope guards list_composers() applies at line 60–74
128+
# so a drifted local row (data not a dict, or allComposers missing
129+
# / non-list) surfaces as a logged SchemaError, not a 500.
130+
if not isinstance(data, dict):
131+
raise SchemaError(
132+
"WorkspaceComposers",
133+
"composer.composerData",
134+
hint=f"expected object, got {type(data).__name__}",
135+
)
136+
if "allComposers" not in data:
137+
raise SchemaError("WorkspaceComposers", "allComposers")
138+
all_composers = data.get("allComposers")
139+
if not isinstance(all_composers, list):
140+
raise SchemaError(
141+
"WorkspaceComposers",
142+
"allComposers",
143+
hint=f"expected list, got {type(all_composers).__name__}",
144+
)
145+
for c in all_composers:
128146
if isinstance(c, dict) and c.get("composerId") == composer_id:
129147
try:
130148
local = WorkspaceLocalComposer.from_dict(c)
@@ -135,6 +153,8 @@ def get_composer(composer_id):
135153
print(f"Schema drift in workspace-local composer {composer_id}: {e}")
136154
continue
137155
return jsonify(local.raw)
156+
except SchemaError as e:
157+
print(f"Schema drift in {db_path}: {e}")
138158
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
139159
pass
140160

api/search.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,12 @@ def search():
150150
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
151151
text = extract_text_from_bubble(bubble.raw)
152152
bubble_map[bid] = {"text": text, "raw": bubble.raw}
153-
except (SchemaError, json.JSONDecodeError, ValueError):
154-
# Skip malformed bubble rows — search must keep returning
155-
# results from the well-formed ones.
153+
except SchemaError as e:
154+
# Drift logged so the operator can see why a chat dropped
155+
# out of search results; bad row still skipped so search
156+
# keeps returning results from the well-formed ones.
157+
print(f"Schema drift in bubble {bid}: {e}")
158+
except (json.JSONDecodeError, ValueError):
156159
pass
157160

158161
# Search through composerData

api/workspaces.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,12 @@ def list_workspaces():
583583
try:
584584
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
585585
bubble_map[bid] = bubble.raw
586-
except (SchemaError, json.JSONDecodeError, ValueError):
587-
# Skip malformed bubble rows — read-many path, one bad row
588-
# must not 500 the endpoint.
586+
except SchemaError as e:
587+
# Drift surfaces in logs so an operator sees disappearing
588+
# bubbles instead of guessing. The row is still skipped —
589+
# one bad bubble must not 500 the endpoint.
590+
print(f"Schema drift in bubble {bid}: {e}")
591+
except (json.JSONDecodeError, ValueError):
589592
pass
590593

591594
# Process each composer
@@ -1022,8 +1025,12 @@ def get_workspace_tabs(workspace_id):
10221025
try:
10231026
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
10241027
bubble_map[bid] = bubble.raw
1025-
except (SchemaError, json.JSONDecodeError, ValueError):
1026-
# Skip malformed rows — one bad bubble must not 500 the tabs endpoint.
1028+
except SchemaError as e:
1029+
# Drift surfaces in logs so an operator can chase disappearing
1030+
# bubbles instead of guessing. Bad row still skipped so the
1031+
# tabs endpoint can't 500 on one malformed bubble.
1032+
print(f"Schema drift in bubble {bid}: {e}")
1033+
except (json.JSONDecodeError, ValueError):
10271034
pass
10281035

10291036
# Load codeBlockDiffs

scripts/__init__.py

Whitespace-only changes.

tests/test_models_wired_at_read_sites.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,38 @@ def test_workspace_tabs_endpoint_calls_bubble_from_dict(self):
128128
"model is defined but not wired at the production read site",
129129
)
130130

131+
def test_bubble_schema_drift_is_logged_not_swallowed_silently(self):
132+
# CodeRabbit: SchemaError used to be lumped in with JSONDecodeError /
133+
# ValueError and skipped silently. Schema drift must now print a
134+
# `Schema drift in bubble <bid>` line so disappearing bubbles can be
135+
# traced. The well-formed row still loads alongside.
136+
from app import create_app
137+
# Seed a deliberately-malformed bubble row that will trip
138+
# Bubble.from_dict's "expected non-empty str" gate on the bubble_id by
139+
# putting a non-dict at the value slot.
140+
global_db = os.path.join(self._tmp.name, "globalStorage", "state.vscdb")
141+
with closing(sqlite3.connect(global_db)) as conn:
142+
conn.execute(
143+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
144+
(f"bubbleId:{COMPOSER_ID}:bub-bad", json.dumps("not-a-dict")),
145+
)
146+
conn.commit()
147+
app = create_app()
148+
app.config["TESTING"] = True
149+
app.config["EXCLUSION_RULES"] = []
150+
import io
151+
from contextlib import redirect_stdout
152+
captured = io.StringIO()
153+
with redirect_stdout(captured):
154+
client = app.test_client()
155+
response = client.get("/api/search?q=sentinel-wired")
156+
self.assertEqual(response.status_code, 200)
157+
out = captured.getvalue()
158+
self.assertIn("Schema drift in bubble", out,
159+
msg=f"expected drift log line, got stdout:\n{out!r}")
160+
self.assertIn("bub-bad", out,
161+
msg="drift log must include the offending bubble id")
162+
131163
def test_workspace_tabs_endpoint_calls_composer_from_dict(self):
132164
# Brad's most-important finding: list_workspaces() at api/workspaces.py:605
133165
# validates each composer with Composer.from_dict, but get_workspace_tabs()
@@ -289,6 +321,47 @@ def test_get_composer_calls_workspace_local_composer_from_dict(self):
289321
"schema validation that list_composers performs.",
290322
)
291323

324+
def test_get_composer_handles_non_dict_envelope_via_fallback(self):
325+
# CodeRabbit: data.get(...) used to crash with AttributeError when the
326+
# per-workspace blob isn't a dict. Now must be caught as SchemaError
327+
# so the function falls through to the global fallback instead of 500.
328+
from app import create_app
329+
ws_db = os.path.join(self.workspace_path, WORKSPACE_ID, "state.vscdb")
330+
with closing(sqlite3.connect(ws_db)) as conn:
331+
# Replace the dict envelope with a list — would have raised
332+
# AttributeError on data.get(...) before the guards landed.
333+
conn.execute(
334+
"UPDATE ItemTable SET value = ? WHERE [key] = 'composer.composerData'",
335+
(json.dumps(["not", "a", "dict"]),),
336+
)
337+
conn.commit()
338+
app = create_app()
339+
app.config["TESTING"] = True
340+
app.config["EXCLUSION_RULES"] = []
341+
client = app.test_client()
342+
response = client.get(f"/api/composers/{COMPOSER_ID}")
343+
# Global fallback still has the composer seeded, so this must return
344+
# 200, not 500. The per-workspace drift gets logged and skipped.
345+
self.assertEqual(response.status_code, 200)
346+
self.assertEqual(response.get_json().get("name"), "Wired conversation")
347+
348+
def test_get_composer_handles_non_list_all_composers_via_fallback(self):
349+
from app import create_app
350+
ws_db = os.path.join(self.workspace_path, WORKSPACE_ID, "state.vscdb")
351+
with closing(sqlite3.connect(ws_db)) as conn:
352+
conn.execute(
353+
"UPDATE ItemTable SET value = ? WHERE [key] = 'composer.composerData'",
354+
(json.dumps({"allComposers": "should-be-list"}),),
355+
)
356+
conn.commit()
357+
app = create_app()
358+
app.config["TESTING"] = True
359+
app.config["EXCLUSION_RULES"] = []
360+
client = app.test_client()
361+
response = client.get(f"/api/composers/{COMPOSER_ID}")
362+
# Drift surfaces via global fallback, not as a 500.
363+
self.assertEqual(response.status_code, 200)
364+
292365
def test_get_composer_calls_composer_from_dict_on_global_fallback(self):
293366
# When the per-workspace path misses (composer only in globalStorage),
294367
# the fallback must validate via Composer.from_dict — not just decode

0 commit comments

Comments
 (0)