Skip to content

Commit 79de6ac

Browse files
committed
fix: review comments
1 parent 2554b5c commit 79de6ac

2 files changed

Lines changed: 52 additions & 42 deletions

File tree

services/workspace_tabs.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ def _extract_chat_id_from_code_block_diff_key(key: str) -> str | None:
3737
return m.group(1) if m else None
3838

3939

40+
def _loads_disk_kv_value(raw: Any) -> Any | None:
41+
"""Parse a cursorDiskKV ``value`` column; ``None`` if missing or unparseable."""
42+
if raw is None:
43+
return None
44+
try:
45+
return json.loads(raw)
46+
except (json.JSONDecodeError, TypeError, ValueError):
47+
return None
48+
49+
4050
def assemble_workspace_tabs(
4151
workspace_id: str,
4252
workspace_path: str,
@@ -97,32 +107,30 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
97107
parts = row["key"].split(":")
98108
if len(parts) >= 3:
99109
bid = parts[2]
100-
if row["value"] is None:
110+
parsed = _loads_disk_kv_value(row["value"])
111+
if parsed is None:
101112
continue
102113
try:
103-
bubble_obj = Bubble.from_dict(json.loads(row["value"]), bubble_id=bid)
114+
bubble_obj = Bubble.from_dict(parsed, bubble_id=bid)
104115
bubble_map[bid] = bubble_obj.raw
105116
except SchemaError as e:
106117
# Drift logged so the operator can chase disappearing
107118
# bubbles instead of guessing. Bad row still skipped so the
108119
# tabs endpoint can't 500 on one malformed bubble.
109120
print(f"Schema drift in bubble {bid}: {e}")
110-
except (json.JSONDecodeError, ValueError):
111-
pass
112121

113122
# Load codeBlockDiffs
114123
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'codeBlockDiff:%'"):
115124
chat_id = _extract_chat_id_from_code_block_diff_key(row["key"])
116125
if not chat_id:
117126
continue
118-
try:
119-
d = json.loads(row["value"])
120-
code_block_diff_map.setdefault(chat_id, []).append({
121-
**d,
122-
"diffId": row["key"].split(":")[2] if len(row["key"].split(":")) > 2 else None,
123-
})
124-
except Exception:
125-
pass
127+
d = _loads_disk_kv_value(row["value"])
128+
if not isinstance(d, dict):
129+
continue
130+
code_block_diff_map.setdefault(chat_id, []).append({
131+
**d,
132+
"diffId": row["key"].split(":")[2] if len(row["key"].split(":")) > 2 else None,
133+
})
126134

127135
# Load messageRequestContext rows once; build both
128136
# message_request_context_map and project_layouts_map from the same pass.
@@ -132,10 +140,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
132140
if len(parts) < 2:
133141
continue
134142
chat_id = parts[1]
135-
try:
136-
ctx = json.loads(row["value"])
137-
except Exception:
138-
continue
143+
ctx = _loads_disk_kv_value(row["value"])
139144
if not isinstance(ctx, dict):
140145
continue
141146

@@ -153,9 +158,8 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
153158
project_layouts_map.setdefault(chat_id, [])
154159
for layout in layouts:
155160
if isinstance(layout, str):
156-
try:
157-
layout = json.loads(layout)
158-
except Exception:
161+
layout = _loads_disk_kv_value(layout)
162+
if not isinstance(layout, dict):
159163
continue
160164
if isinstance(layout, dict) and layout.get("rootPath"):
161165
project_layouts_map[chat_id].append(layout["rootPath"])
@@ -180,16 +184,17 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
180184

181185
for row in composer_rows:
182186
composer_id = row["key"].split(":")[1]
187+
parsed = _loads_disk_kv_value(row["value"])
188+
if parsed is None:
189+
continue
183190
try:
184-
composer = Composer.from_dict(json.loads(row["value"]), composer_id=composer_id)
191+
composer = Composer.from_dict(parsed, composer_id=composer_id)
185192
except SchemaError as e:
186193
# Drift skipped + logged so the two primary conversation
187194
# paths (list_workspaces + get_workspace_tabs) agree on what
188195
# counts as a valid composer.
189196
print(f"Schema drift in composer {composer_id}: {e}")
190197
continue
191-
except (json.JSONDecodeError, TypeError, ValueError):
192-
continue
193198
try:
194199
cd = composer.raw
195200

tests/test_workspace_tabs_null_bubble.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
A cursorDiskKV row with a NULL value column previously caused
44
json.loads(None) -> TypeError, which propagated as a 500 response.
5-
The fix adds an explicit None-guard before json.loads in the bubble
6-
loading loop of services/workspace_tabs.py.
5+
The fix uses ``_loads_disk_kv_value`` in ``services/workspace_tabs.py`` so
6+
NULL / unparseable cursorDiskKV values are skipped without raising.
77
"""
88

99
import json
@@ -12,6 +12,13 @@
1212
import tempfile
1313
import unittest
1414

15+
from services.workspace_tabs import assemble_workspace_tabs
16+
17+
# cursorDiskKV keys use typed prefixes; tabs[].id is the bare suffix only
18+
# (assemble_workspace_tabs: composer_id = row["key"].split(":")[1]).
19+
COMPOSER_ID = "composer-abc"
20+
COMPOSER_KV_KEY = f"composerData:{COMPOSER_ID}"
21+
1522

1623
class TestNullBubbleValueDoesNotCrashTabs(unittest.TestCase):
1724
def setUp(self):
@@ -30,21 +37,21 @@ def setUp(self):
3037
conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)")
3138
conn.execute(
3239
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
33-
("bubbleId:composer-abc:bubble-null", None), # NULL value — the crash case
40+
(f"bubbleId:{COMPOSER_ID}:bubble-null", None), # NULL value — the crash case
3441
)
3542
# Healthy bubble that should surface in the assembled tab.
3643
conn.execute(
3744
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
3845
(
39-
"bubbleId:composer-abc:bubble-ok",
46+
f"bubbleId:{COMPOSER_ID}:bubble-ok",
4047
json.dumps({"type": 1, "text": "hello world", "createdAt": 1739200000000}),
4148
),
4249
)
4350
# Composer referencing the healthy bubble — required for a tab to be built.
4451
conn.execute(
4552
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
4653
(
47-
"composerData:composer-abc",
54+
COMPOSER_KV_KEY,
4855
json.dumps({
4956
"name": "Test Chat",
5057
"modelConfig": {"modelName": "gpt-4o"},
@@ -64,8 +71,6 @@ def tearDown(self):
6471

6572
def test_null_bubble_row_is_skipped_without_exception(self):
6673
"""assemble_workspace_tabs must not raise when a bubble row has NULL value."""
67-
from services.workspace_tabs import assemble_workspace_tabs
68-
6974
try:
7075
_payload, status = assemble_workspace_tabs(
7176
workspace_id="global",
@@ -75,34 +80,34 @@ def test_null_bubble_row_is_skipped_without_exception(self):
7580
except TypeError as exc:
7681
self.fail(f"NULL bubble row raised TypeError: {exc}")
7782

78-
self.assertEqual(status, 200)
83+
self.assertEqual(status, 200, "NULL bubble row must not turn tabs load into an error response")
7984

8085
def test_healthy_bubbles_still_load_when_null_row_present(self):
8186
"""The healthy bubble surfaces in a tab even when a NULL row is present."""
82-
from services.workspace_tabs import assemble_workspace_tabs
83-
8487
payload, status = assemble_workspace_tabs(
8588
workspace_id="global",
8689
workspace_path=self.workspace_path,
8790
rules=[],
8891
)
89-
self.assertEqual(status, 200)
90-
self.assertIsInstance(payload, dict)
92+
self.assertEqual(status, 200, "tabs endpoint must succeed when only the null bubble row is bad")
93+
self.assertIsInstance(payload, dict, "tabs response must be a JSON object envelope")
9194
tabs = payload.get("tabs", [])
92-
self.assertEqual(len(tabs), 1, "Expected exactly one tab for composer-abc")
95+
self.assertEqual(len(tabs), 1, f"Expected exactly one tab for {COMPOSER_ID}")
9396

9497
tab = tabs[0]
95-
self.assertEqual(tab["id"], "composer-abc")
96-
self.assertEqual(tab["title"], "Test Chat")
97-
self.assertIn("bubbles", tab)
98-
self.assertIn("codeBlockDiffs", tab)
98+
# GET /tabs and workspace.html ?tab= use bare composer id, not the KV key.
99+
self.assertEqual(tab["id"], COMPOSER_ID, "tab id must be bare composer id (KV key suffix only)")
100+
self.assertNotEqual(tab["id"], COMPOSER_KV_KEY, "tab id must not include composerData: prefix")
101+
self.assertEqual(tab["title"], "Test Chat", "composer name from seeded cursorDiskKV row")
102+
self.assertIn("bubbles", tab, "tab payload must include bubbles for the conversation view")
103+
self.assertIn("codeBlockDiffs", tab, "tab payload must include codeBlockDiffs field (may be empty)")
99104

100105
bubbles = tab["bubbles"]
101106
self.assertEqual(len(bubbles), 1, "Expected exactly one bubble (null row skipped)")
102107
bubble = bubbles[0]
103-
self.assertEqual(bubble["type"], "user")
104-
self.assertEqual(bubble["text"], "hello world")
105-
self.assertIn("timestamp", bubble)
108+
self.assertEqual(bubble["type"], "user", "header type 1 maps to user bubble")
109+
self.assertEqual(bubble["text"], "hello world", "healthy bubble text must surface in the tab")
110+
self.assertIn("timestamp", bubble, "bubble must carry a timestamp for ordering/display")
106111

107112

108113
if __name__ == "__main__":

0 commit comments

Comments
 (0)