Skip to content

Commit 1a0410d

Browse files
authored
feat: typed models + schema validation at DB read boundaries (closes #24) (#30)
* feat: typed models + schema validation at DB read boundaries (closes #24) Add models/ package with @DataClass definitions for Workspace, Composer, Bubble, CliSessionMeta, ExportEntry. Each model has a from_dict() classmethod that raises SchemaError when critical fields are missing, replacing silent dict.get() fallbacks at the four database read boundaries identified by the eval (Section 5.1, Schema Fragility). Wire into: - api/workspaces.py:601 — composerData rows in workspace listing - api/composers.py:57-63 — allComposers envelope in per-workspace fetch - api/search.py:163-164 — composerData rows during search - utils/cli_chat_reader.py:93-98 — CLI session meta blob in traverse_blobs Schema drift surfaces as a printed warning + skipped row, not a silent empty result. Existing call sites preserve their JSON response shapes (behavior-preserving wire-in). * review: address CodeRabbit feedback on PR #30 — tighten schema gates Six fixes from the CodeRabbit pass on the initial typed-models commit, plus matching regression tests so each can't silently regress later. 1. api/composers.py: validate the top-level JSON payload is a dict before key/list checks. A non-object decoded value previously bypassed the SchemaError path and was swallowed by the surrounding except. 2. models/{cli_session,conversation,workspace,export}.py: same isinstance guard at the top of every from_dict, so a non-dict raw (list, str, None) surfaces as SchemaError instead of AttributeError. 3. models/conversation.py: drop the `raw.get(...) or []` fallback on fullConversationHeadersOnly. The `or []` idiom coerced None/"" / 0 into [] and made the isinstance gate unreachable. 4. models/export.py: replace str(raw[...]) coercion of required string fields with an isinstance(value, str) gate. Coercion masked drift — an int log_id would silently turn into a string. 5. models/conversation.py: re-gate createdAt as required per issue #24. A live workspaceStorage scan confirmed 17/17 composers carry it, so a missing value is real schema drift, not benign older-record absence. Two existing test fixtures in test_search_exclusion_filtering that lacked createdAt updated; the prior tolerated test replaced with a spec-correct missing-raises test. 6. utils/cli_chat_reader.py: broaden the traverse_blobs catch to (SchemaError, ValueError, UnicodeDecodeError, TypeError). Malformed hex, non-UTF-8 bytes, and invalid JSON in the meta blob previously escaped to the caller; now all four routes return [] uniformly. Regression tests added: - tests/test_models.py: non_dict_payload (5 models × 5 bad types), headers_falsy_non_list, headers_empty_list_is_valid, export_entry_non_string_required, missing_created_at_raises - tests/test_cli_chat_reader.py: malformed_hex, non_utf8, invalid_json, non_dict meta payloads each return [] via traverse_blobs 201 tests pass (was 197); mypy clean on models/; real Cursor data (17 composers) still loads with zero Schema drift warnings. * review: route per-row workspace-local composer drift through SchemaError (#30) CodeRabbit flagged that the inline ``isinstance(c, dict) or not c.get(...)`` skip in api/composers.py masked row-level drift instead of surfacing it the way the rest of the boundary does. The exact suggested fix (Composer.from_dict(c)) would have rejected every workspace-local record: those carry composerId + lastUpdatedAt but not fullConversationHeadersOnly or createdAt, which only exist on the global cursorDiskKV rows. Add WorkspaceLocalComposer to models/conversation.py — a slim sibling model that validates the workspace-local shape (composerId required as a non-empty string; non-dict raw raises). api/composers.py:list_composers now wraps each entry in WorkspaceLocalComposer.from_dict, so per-row drift logs a Schema drift warning and skips, matching every other read-site in this PR. Regression coverage in tests/test_models.py: parses good shape; raises SchemaError for missing/empty/non-string composerId and for non-dict payloads. 204 tests pass (was 201; +3); mypy clean on models/; real Cursor smoke test shows zero drift warnings. * review: log non-schema read failures in list_composers (#30) CodeRabbit flagged that the bare ``except Exception: pass`` adjacent to the new SchemaError path swallowed decode / SQLite / non-schema errors silently and returned a partial composer list with no signal. Replace with a print that surfaces the exception and ``db_path``, matching the ``Schema drift in ...`` line a few lines up. No behaviour change beyond observability — the loop still continues to the next workspace. * docs: drop module docstrings and trim class docstrings to one-liners (#30) Internal cleanup on the typed-models package — file location and class name carry the intent; the multi-paragraph rationales were rehashing the PR description. Function bodies are unchanged. - models/__init__.py: removed 8-line module docstring - models/errors.py: dropped module docstring + SchemaError class docstring trimmed to one line - models/conversation.py: dropped module docstring; Composer, WorkspaceLocalComposer, Bubble docstrings trimmed to one line each - models/cli_session.py: dropped module docstring; CliSessionMeta docstring trimmed - models/workspace.py: dropped module docstring; Workspace docstring trimmed - models/export.py: dropped module docstring; ExportEntry docstring trimmed - tests/test_models.py: dropped module docstring; removed now-unused ``from pathlib import Path`` 204 tests pass. * docs: drop task-reference comments and section banners (#30) Per the review nit: stop narrating in code. Removes: - tests/test_models.py: 3 ``# --- section ---`` banners and 10 inline comments referencing issue #24 / CodeRabbit findings. - tests/test_cli_chat_reader.py: 4 ``"""Regression: ..."""`` docstrings and 4 line-comments restating what the test exercises. - utils/cli_chat_reader.py: 5-line narration on the broadened except arm — the tuple of exception types is the documentation. Test names and code already describe the WHAT; the WHY for these cases is in the PR description and the commit history, not the source. 204 tests still pass. * review: tighten ID + createdAt type gates across the model layer (#30) Three related CodeRabbit findings, addressed together: - Composer.composer_id, Bubble.bubble_id, Workspace.workspace_id now require ``isinstance(..., str) and value != ""``. Truthiness alone let non-string IDs (e.g. ``123``) slip past the boundary; aligned with the pattern already in WorkspaceLocalComposer. - Composer.createdAt is now type-gated. Presence was already enforced but any value type (e.g. ``"2026-05-12"``) silently became the timestamp. Required values must be ``int`` or ``float`` and explicitly not ``bool`` (since ``bool`` is a subclass of ``int`` in Python and a boolean is never a real timestamp). Regression tests pin each new gate: - test_non_string_composer_id_raises - test_non_string_bubble_id_raises - test_non_string_workspace_id_raises - test_non_numeric_created_at_raises - test_numeric_created_at_passes 209 tests pass (was 204; +5 new). * review: wire typed models load-bearing across read sites (#30) Closes five blocking findings from Brad's review pass: 1. Bubble / Workspace / ExportEntry were defined and unit-tested but had zero production callers — the PR's own "Used at" table didn't match the code. Wired each at the cited sites: * Bubble in api/workspaces.py (both bubble loaders, line 584 + 1023) * Bubble in api/search.py:150 * Workspace in api/composers.py:44 and api/workspaces.py:54 * ExportEntry reader in scripts/export.py:66 — paired with writer updates so the on-disk shape matches what the model accepts. 2. get_workspace_tabs() in api/workspaces.py bypassed Composer.from_dict while list_workspaces() in the same file validated. The two primary conversation-browsing paths now agree on what counts as a valid row. 3. get_composer() returned the raw dict from either per-workspace DB or global storage with no validation. Now goes through WorkspaceLocalComposer.from_dict (per-workspace, drift logged + skipped) and Composer.from_dict (global fallback, drift -> 404 + log). 4. WorkspaceLocalComposer.from_dict result was discarded after the gate. The typed object is now load-bearing: the sort key reads local.last_updated_at and the JSON's composerId/lastUpdatedAt are written from the validated fields, not the raw dict. 5. SchemaError message always read "missing required field" even on type/shape mismatches. Now reads "invalid field" when hint is set and "missing required field" only when hint is None, so log grepping can tell the two drift modes apart. Plus one forward-looking note: added a WHY comment at the numeric-only createdAt check in models/conversation.py pointing at the 17/17 live scan that justifies the strict isinstance(int, float) gate. 11 new regression tests: - tests/test_models_wired_at_read_sites.py (new file, 10 tests) pinning each from_dict call site with spy + side_effect patches so a future refactor that drops the typed model fails loudly. - tests/test_models.py one new test asserting the SchemaError wording distinction holds. Verified locally: - python -m unittest discover tests: 220 passed, OK (was 209) - python -m unittest tests.test_models_wired_at_read_sites: 10/10 pass - python app.py boots clean, curl / returns 200 * 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 * review: 2 CodeRabbit findings on PR #30 — response-shape parity + alias-vote drift guard * api/composers.py — get_composer() returned three different shapes for the same conceptual `conversation` field depending on which branch resolved the composer: - list_composers (line 89): `c["conversation"] = c.get("conversation") or []` - get_composer per-workspace (line 157): `return jsonify(local.raw)` — raw, no normalisation - get_composer global fallback (line 185): `payload.setdefault("conversation", [])` — fills missing but not None All three sites now use the line-89 idiom, so the endpoint family returns an identical shape regardless of source. * api/workspaces.py — `_infer_invalid_workspace_aliases()` was reparsing raw composer_rows with `json.loads` + bare `except Exception` *before* the per-row Composer.from_dict gates in list_workspaces() / get_workspace_tabs() ran. That meant schema-drifted composers could still cast votes in the workspace-alias inference and misassign otherwise-valid composers to the wrong workspace. Now gates via `Composer.from_dict(...)` with a narrow `(SchemaError, JSONDecodeError, TypeError, ValueError)` catch. Existing test_majority_vote_alias_selection fixture predated the strict createdAt gate from issue #24; added `createdAt` to each of the three composer rows so the fixture reflects real production shape. New test_drifted_composer_does_not_skew_vote pins the regression: seeds two well-formed boost-ws votes plus one drifted row that would vote for team-ws if counted, asserts the alias resolves to boost-ws. Verified: - mypy --ignore-missing-imports --no-strict-optional . → Success - ruff check api/ utils/ scripts/export.py app.py → All checks passed - unittest discover tests → 224 / 224 OK (was 223 + 1 new regression test)
1 parent 4210226 commit 1a0410d

16 files changed

Lines changed: 1274 additions & 70 deletions

api/composers.py

Lines changed: 94 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from utils.workspace_path import resolve_workspace_path
1616
from utils.path_helpers import to_epoch_ms
17+
from models import Composer, SchemaError, Workspace, WorkspaceLocalComposer
1718

1819
bp = Blueprint("composers", __name__)
1920
_logger = logging.getLogger(__name__)
@@ -42,9 +43,11 @@ def list_composers():
4243

4344
workspace_folder = None
4445
try:
45-
wd = _read_json_file(wj_path)
46-
workspace_folder = wd.get("folder")
47-
except Exception:
46+
workspace = Workspace.from_dict(_read_json_file(wj_path), workspace_id=name)
47+
workspace_folder = workspace.folder
48+
except (SchemaError, OSError, ValueError):
49+
# Missing / malformed workspace.json is non-fatal — the row still
50+
# contributes its composer data, just without a folder hint.
4851
pass
4952

5053
try:
@@ -56,18 +59,44 @@ def list_composers():
5659

5760
if row and row[0]:
5861
data = json.loads(row[0])
62+
if not isinstance(data, dict):
63+
raise SchemaError(
64+
"WorkspaceComposers",
65+
"composer.composerData",
66+
hint=f"expected object, got {type(data).__name__}",
67+
)
68+
if "allComposers" not in data:
69+
raise SchemaError("WorkspaceComposers", "allComposers")
5970
all_composers = data.get("allComposers")
60-
if isinstance(all_composers, list):
61-
for c in all_composers:
62-
c["conversation"] = c.get("conversation") or []
63-
c["workspaceId"] = name
64-
c["workspaceFolder"] = workspace_folder
65-
composers.append(c)
66-
except Exception:
67-
pass
68-
69-
composers.sort(key=lambda c: to_epoch_ms(c.get("lastUpdatedAt")), reverse=True)
70-
return jsonify(composers)
71+
if not isinstance(all_composers, list):
72+
raise SchemaError(
73+
"WorkspaceComposers",
74+
"allComposers",
75+
hint=f"expected list, got {type(all_composers).__name__}",
76+
)
77+
for c in all_composers:
78+
try:
79+
local = WorkspaceLocalComposer.from_dict(c)
80+
except SchemaError as e:
81+
print(f"Schema drift in {db_path}: {e}")
82+
continue
83+
# Use the typed view downstream so the dataclass is
84+
# load-bearing, not just a filter (Brad's review): the
85+
# sort key and the JSON's composerId both read off the
86+
# validated values, not the raw dict.
87+
c["composerId"] = local.composer_id
88+
c["lastUpdatedAt"] = local.last_updated_at
89+
c["conversation"] = c.get("conversation") or []
90+
c["workspaceId"] = name
91+
c["workspaceFolder"] = workspace_folder
92+
composers.append((local, c))
93+
except SchemaError as e:
94+
print(f"Schema drift in {db_path}: {e}")
95+
except Exception as e:
96+
print(f"Failed reading composers from {db_path}: {e}")
97+
98+
composers.sort(key=lambda pair: to_epoch_ms(pair[0].last_updated_at), reverse=True)
99+
return jsonify([c for _, c in composers])
71100

72101
except Exception:
73102
_logger.exception("Failed to get composers")
@@ -97,10 +126,45 @@ def get_composer(composer_id):
97126

98127
if row and row[0]:
99128
data = json.loads(row[0])
100-
for c in (data.get("allComposers") or []):
101-
if c.get("composerId") == composer_id:
102-
return jsonify(c)
103-
except Exception:
129+
# Mirror the envelope guards list_composers() applies at line 60–74
130+
# so a drifted local row (data not a dict, or allComposers missing
131+
# / non-list) surfaces as a logged SchemaError, not a 500.
132+
if not isinstance(data, dict):
133+
raise SchemaError(
134+
"WorkspaceComposers",
135+
"composer.composerData",
136+
hint=f"expected object, got {type(data).__name__}",
137+
)
138+
if "allComposers" not in data:
139+
raise SchemaError("WorkspaceComposers", "allComposers")
140+
all_composers = data.get("allComposers")
141+
if not isinstance(all_composers, list):
142+
raise SchemaError(
143+
"WorkspaceComposers",
144+
"allComposers",
145+
hint=f"expected list, got {type(all_composers).__name__}",
146+
)
147+
for c in all_composers:
148+
if isinstance(c, dict) and c.get("composerId") == composer_id:
149+
try:
150+
local = WorkspaceLocalComposer.from_dict(c)
151+
except SchemaError as e:
152+
# Same drift list_composers() logs and skips at line ~78,
153+
# so a single-composer fetch can't silently return malformed
154+
# JSON the list endpoint hid.
155+
print(f"Schema drift in workspace-local composer {composer_id}: {e}")
156+
continue
157+
# Match list_composers() at line 89 and the global
158+
# fallback below: `conversation` is normalised to []
159+
# whether it's absent or None, so the response shape
160+
# is identical regardless of which branch resolved
161+
# the composer (CodeRabbit on PR #30).
162+
payload = dict(local.raw)
163+
payload["conversation"] = payload.get("conversation") or []
164+
return jsonify(payload)
165+
except SchemaError as e:
166+
print(f"Schema drift in {db_path}: {e}")
167+
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
104168
pass
105169

106170
# Fallback: global storage
@@ -116,10 +180,18 @@ def get_composer(composer_id):
116180

117181
if row and row[0]:
118182
raw = row[0] if isinstance(row[0], str) else row[0].decode("utf-8")
119-
composer = json.loads(raw)
120-
composer.setdefault("conversation", [])
121-
return jsonify(composer)
122-
except Exception:
183+
try:
184+
composer = Composer.from_dict(json.loads(raw), composer_id=composer_id)
185+
except SchemaError as e:
186+
# Don't return malformed JSON to the client — surface the drift
187+
# as a 404 + log, matching the silent-skip behaviour of the
188+
# list endpoints for the same row.
189+
print(f"Schema drift in composer {composer_id}: {e}")
190+
return jsonify({"error": "Composer schema drift"}), 404
191+
payload = dict(composer.raw)
192+
payload["conversation"] = payload.get("conversation") or []
193+
return jsonify(payload)
194+
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
123195
pass
124196

125197
return jsonify({"error": "Composer not found"}), 404

api/search.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from utils.path_helpers import to_epoch_ms
2020
from utils.text_extract import extract_text_from_bubble
2121
from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles
22+
from models import Bubble, Composer, SchemaError
2223

2324
bp = Blueprint("search", __name__)
2425
_logger = logging.getLogger(__name__)
@@ -148,11 +149,15 @@ def search():
148149
if len(parts) >= 3:
149150
bid = parts[2]
150151
try:
151-
b = json.loads(row["value"])
152-
if isinstance(b, dict):
153-
text = extract_text_from_bubble(b)
154-
bubble_map[bid] = {"text": text, "raw": b}
155-
except Exception:
152+
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
153+
text = extract_text_from_bubble(bubble.raw)
154+
bubble_map[bid] = {"text": text, "raw": bubble.raw}
155+
except SchemaError as e:
156+
# Drift logged so the operator can see why a chat dropped
157+
# out of search results; bad row still skipped so search
158+
# keeps returning results from the well-formed ones.
159+
print(f"Schema drift in bubble {bid}: {e}")
160+
except (json.JSONDecodeError, ValueError):
156161
pass
157162

158163
# Search through composerData
@@ -163,17 +168,24 @@ def search():
163168
for row in composer_rows:
164169
composer_id = row["key"].split(":")[1]
165170
try:
166-
cd = json.loads(row["value"])
167-
headers = cd.get("fullConversationHeadersOnly") or []
171+
composer = Composer.from_dict(json.loads(row["value"]), composer_id=composer_id)
172+
except SchemaError as e:
173+
print(f"Schema drift in composer {composer_id}: {e}")
174+
continue
175+
except (json.JSONDecodeError, TypeError, ValueError):
176+
continue
177+
try:
178+
cd = composer.raw
179+
headers = composer.full_conversation_headers_only
168180
if not headers:
169181
continue
170182

171-
title = cd.get("name") or ""
183+
title = composer.name or ""
172184
ws_id = composer_id_to_ws.get(composer_id, "global")
173185
ws_name = ws_id_to_name.get(ws_id)
174186
project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id)
175187

176-
model_config = cd.get("modelConfig") or {}
188+
model_config = composer.model_config
177189
model_name = model_config.get("modelName")
178190
model_names = [model_name] if model_name and model_name != "default" else None
179191

@@ -245,7 +257,7 @@ def search():
245257
"workspaceFolder": ws_name,
246258
"chatId": composer_id,
247259
"chatTitle": title,
248-
"timestamp": to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(cd.get("createdAt")) or int(datetime.now().timestamp() * 1000),
260+
"timestamp": to_epoch_ms(composer.last_updated_at) or to_epoch_ms(composer.created_at) or int(datetime.now().timestamp() * 1000),
249261
"matchingText": matching_text,
250262
"type": "composer",
251263
})

api/workspaces.py

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from utils.text_extract import extract_text_from_bubble, format_tool_action
3636
from utils.tool_parser import parse_tool_call as _parse_tool_call
3737
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
38+
from models import Bubble, Composer, SchemaError, Workspace
3839

3940
bp = Blueprint("workspaces", __name__)
4041
_logger = logging.getLogger(__name__)
@@ -53,11 +54,11 @@ def _get_workspace_display_name(workspace_path: str, workspace_id: str) -> str:
5354
return "Other chats"
5455
wj_path = os.path.join(workspace_path, workspace_id, "workspace.json")
5556
try:
56-
wd = _read_json_file(wj_path)
57-
name = get_workspace_display_name(wd)
57+
workspace = Workspace.from_dict(_read_json_file(wj_path), workspace_id=workspace_id)
58+
name = get_workspace_display_name(workspace.raw)
5859
if name:
5960
return name
60-
except Exception:
61+
except (SchemaError, OSError, ValueError):
6162
pass
6263
return workspace_id
6364

@@ -446,8 +447,15 @@ def _infer_invalid_workspace_aliases(
446447
if mapped not in invalid_workspace_ids:
447448
continue
448449
try:
449-
cd = json.loads(row["value"])
450-
except Exception:
450+
# Validate via Composer.from_dict so a schema-drifted row can't
451+
# steer the alias vote and misassign otherwise-valid composers to
452+
# the wrong workspace. The downstream per-row loops in
453+
# list_workspaces() / get_workspace_tabs() already drop drift,
454+
# but this helper runs BEFORE that loop and its votes shape
455+
# invalid_workspace_aliases for every other composer.
456+
composer = Composer.from_dict(json.loads(row["value"]), composer_id=cid)
457+
cd = composer.raw
458+
except (SchemaError, json.JSONDecodeError, TypeError, ValueError):
451459
continue
452460
inferred = _determine_project_for_conversation(
453461
cd,
@@ -583,10 +591,14 @@ def list_workspaces():
583591
if len(parts) >= 3:
584592
bid = parts[2]
585593
try:
586-
b = json.loads(row["value"])
587-
if isinstance(b, dict):
588-
bubble_map[bid] = b
589-
except Exception:
594+
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
595+
bubble_map[bid] = bubble.raw
596+
except SchemaError as e:
597+
# Drift surfaces in logs so an operator sees disappearing
598+
# bubbles instead of guessing. The row is still skipped —
599+
# one bad bubble must not 500 the endpoint.
600+
print(f"Schema drift in bubble {bid}: {e}")
601+
except (json.JSONDecodeError, ValueError):
590602
pass
591603

592604
# Process each composer
@@ -603,9 +615,15 @@ def list_workspaces():
603615
for row in composer_rows:
604616
cid = row["key"].split(":")[1]
605617
try:
606-
cd = json.loads(row["value"])
618+
composer = Composer.from_dict(json.loads(row["value"]), composer_id=cid)
619+
except SchemaError as e:
620+
print(f"Schema drift in composer {cid}: {e}")
621+
continue
622+
except (json.JSONDecodeError, TypeError, ValueError):
623+
continue
624+
try:
607625
pid = _determine_project_for_conversation(
608-
cd, cid, project_layouts_map,
626+
composer.raw, cid, project_layouts_map,
609627
project_name_map, workspace_path_map,
610628
workspace_entries, bubble_map, composer_id_to_ws, invalid_workspace_ids
611629
)
@@ -614,16 +632,16 @@ def list_workspaces():
614632
pid = invalid_workspace_aliases.get(mapped_ws)
615633
assigned = pid if pid else "global"
616634

617-
headers = cd.get("fullConversationHeadersOnly") or []
635+
headers = composer.full_conversation_headers_only
618636
has_bubbles = any(bubble_map.get(h.get("bubbleId")) for h in headers)
619637
if not has_bubbles:
620638
continue
621639

622640
conversation_map.setdefault(assigned, []).append({
623641
"composerId": cid,
624-
"name": cd.get("name") or f"Conversation {cid[:8]}",
625-
"lastUpdatedAt": to_epoch_ms(cd.get("lastUpdatedAt")) or to_epoch_ms(cd.get("createdAt")) or 0,
626-
"createdAt": to_epoch_ms(cd.get("createdAt")) or 0,
642+
"name": composer.name or f"Conversation {cid[:8]}",
643+
"lastUpdatedAt": to_epoch_ms(composer.last_updated_at) or to_epoch_ms(composer.created_at) or 0,
644+
"createdAt": to_epoch_ms(composer.created_at) or 0,
627645
})
628646
except Exception:
629647
pass
@@ -1013,10 +1031,14 @@ def get_workspace_tabs(workspace_id):
10131031
if len(parts) >= 3:
10141032
bid = parts[2]
10151033
try:
1016-
b = json.loads(row["value"])
1017-
if isinstance(b, dict):
1018-
bubble_map[bid] = b
1019-
except Exception:
1034+
bubble = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
1035+
bubble_map[bid] = bubble.raw
1036+
except SchemaError as e:
1037+
# Drift surfaces in logs so an operator can chase disappearing
1038+
# bubbles instead of guessing. Bad row still skipped so the
1039+
# tabs endpoint can't 500 on one malformed bubble.
1040+
print(f"Schema drift in bubble {bid}: {e}")
1041+
except (json.JSONDecodeError, ValueError):
10201042
pass
10211043

10221044
# Load codeBlockDiffs
@@ -1091,8 +1113,17 @@ def get_workspace_tabs(workspace_id):
10911113
for row in composer_rows:
10921114
composer_id = row["key"].split(":")[1]
10931115
try:
1094-
cd = json.loads(row["value"])
1095-
1116+
composer = Composer.from_dict(json.loads(row["value"]), composer_id=composer_id)
1117+
except SchemaError as e:
1118+
# Skip the same drift list_workspaces() drops at line 605 so the two
1119+
# primary conversation paths agree on what counts as a valid composer.
1120+
print(f"Schema drift in composer {composer_id}: {e}")
1121+
continue
1122+
except (json.JSONDecodeError, TypeError, ValueError):
1123+
continue
1124+
try:
1125+
cd = composer.raw
1126+
10961127
# Determine project
10971128
pid = _determine_project_for_conversation(
10981129
cd, composer_id, project_layouts_map,
@@ -1103,11 +1134,11 @@ def get_workspace_tabs(workspace_id):
11031134
if not pid and mapped_ws in invalid_workspace_ids:
11041135
pid = invalid_workspace_aliases.get(mapped_ws)
11051136
assigned = pid if pid else "global"
1106-
1137+
11071138
if assigned not in matching_ws_ids:
11081139
continue
1109-
1110-
headers = cd.get("fullConversationHeadersOnly") or []
1140+
1141+
headers = composer.full_conversation_headers_only
11111142

11121143
# Build bubbles
11131144
bubbles = []

models/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from models.cli_session import CliSessionMeta
2+
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
3+
from models.errors import SchemaError
4+
from models.export import ExportEntry
5+
from models.workspace import Workspace
6+
7+
__all__ = [
8+
"Bubble",
9+
"CliSessionMeta",
10+
"Composer",
11+
"ExportEntry",
12+
"SchemaError",
13+
"Workspace",
14+
"WorkspaceLocalComposer",
15+
]

0 commit comments

Comments
 (0)