Skip to content

Commit 349a5e5

Browse files
committed
review: address CodeRabbit findings on PR #31 (5 fixes + tests)
Five CodeRabbit findings on the api/workspaces.py split, addressed together with regression coverage: 1. services/cli_tabs.py: wrap ``messages_to_bubbles(...)`` in try/except. ``traverse_blobs(...)`` was already guarded; one malformed session from this second call would 500 the whole tabs endpoint instead of being skipped like the traverse failures. Test: tests/test_cli_tabs.py mocks one session to throw, verifies the endpoint returns 200 with the failing session skipped and the healthy session intact. 2. services/workspace_db.py: build SQLite ``file:`` URIs via ``Path(...).resolve().as_uri()`` instead of f-string interpolation. The naive form breaks on paths containing spaces or other reserved characters. The resolver code already used the safe form; aligning the db helpers here. Test: tests/test_workspace_db_special_paths.py creates a workspace inside ``Cursor User/workspaceStorage/`` (path with a space), verifies the mapping and global-DB open both succeed. 3. services/workspace_listing.py + workspace_tabs.py: accept dict-shaped ``projectLayouts`` entries in addition to JSON-string ones. The resolver code already handled both shapes; this brings the listing/tabs paths in line so dict layouts no longer leave ``project_layouts_map`` empty (which silently fell composers back to the "global" bucket). Test: tests/test_project_layouts_dict_shape.py runs ``list_workspace_projects`` against both shapes, asserts the composer is routed to its workspace in both. 4. services/workspace_resolver.py: ``_get_project_from_file_path`` now uses ``os.path.commonpath`` instead of ``startswith`` so a workspace rooted at ``/repo/app`` doesn't sibling-match ``/repo/app2/...``. ``ValueError`` from ``commonpath`` (e.g. mismatched drive on Windows) is caught as "not within". Test: tests/test_project_path_boundary.py covers the sibling-prefix case, the file-outside-any-workspace case, and the regular inside-workspace case. 5. services/workspace_tabs.py: mark synthetic "Tool Action" bubbles with ``synthetic=True`` and skip them in the response-time pass. Synthetic bubbles get a fresh ``datetime.now()`` timestamp; without the flag they were treated as AI responses from the last user message, inflating ``responseTimeMs`` / ``totalResponseTimeMs`` with values unrelated to model latency. The flag stays internal — the tab serializer only copies type/text/timestamp/metadata to the wire payload. 186 tests pass (was 178; +8 new).
1 parent cb28c16 commit 349a5e5

9 files changed

Lines changed: 323 additions & 13 deletions

services/cli_tabs.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ def _get_cli_workspace_tabs(workspace_id: str):
3434
print(f"CLI: could not read session {session_id}: {e}")
3535
continue
3636

37-
bubbles = messages_to_bubbles(messages, created_ms)
37+
try:
38+
bubbles = messages_to_bubbles(messages, created_ms)
39+
except Exception as e:
40+
print(f"CLI: could not convert session {session_id} to bubbles: {e}")
41+
continue
3842
if not bubbles:
3943
continue
4044

services/workspace_db.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import sqlite3
66
from contextlib import closing, contextmanager
7+
from pathlib import Path
78

89
from utils.path_helpers import get_workspace_folder_paths
910
from utils.workspace_descriptor import _read_json_file
@@ -47,7 +48,10 @@ def _build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: l
4748
continue
4849
try:
4950
# closing() guarantees .close() on scope exit (issue #17).
50-
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
51+
# Path.as_uri() percent-encodes reserved chars; ``f"file:{path}"``
52+
# breaks sqlite URI parsing on paths with spaces, ``#``, etc.
53+
db_uri = Path(db_path).resolve().as_uri() + "?mode=ro"
54+
with closing(sqlite3.connect(db_uri, uri=True)) as conn:
5155
row = conn.execute(
5256
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
5357
).fetchone()
@@ -72,7 +76,8 @@ def _open_global_db(workspace_path: str):
7276
if not os.path.isfile(global_db_path):
7377
yield None, global_db_path
7478
return
75-
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
79+
db_uri = Path(global_db_path).resolve().as_uri() + "?mode=ro"
80+
conn = sqlite3.connect(db_uri, uri=True)
7681
conn.row_factory = sqlite3.Row
7782
try:
7883
yield conn, global_db_path

services/workspace_listing.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
6666
for layout in layouts:
6767
if isinstance(layout, str):
6868
try:
69-
obj = json.loads(layout)
70-
if isinstance(obj, dict) and obj.get("rootPath"):
71-
project_layouts_map[cid].append(obj["rootPath"])
69+
layout = json.loads(layout)
7270
except Exception:
73-
pass
71+
continue
72+
if isinstance(layout, dict) and layout.get("rootPath"):
73+
project_layouts_map[cid].append(layout["rootPath"])
7474
except Exception:
7575
pass
7676

services/workspace_resolver.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ def _get_project_from_file_path(
112112
wd = _read_json_file(entry["workspaceJsonPath"])
113113
for folder in get_workspace_folder_paths(wd):
114114
wp = normalize_file_path(folder)
115-
if normalized_path.startswith(wp) and len(wp) > best_len:
115+
try:
116+
is_within_workspace = os.path.commonpath([normalized_path, wp]) == wp
117+
except ValueError:
118+
is_within_workspace = False
119+
if is_within_workspace and len(wp) > best_len:
116120
best_len = len(wp)
117121
best_match = entry["name"]
118122
except Exception:

services/workspace_tabs.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,11 @@ def assemble_workspace_tabs(
138138
for layout in layouts:
139139
if isinstance(layout, str):
140140
try:
141-
obj = json.loads(layout)
142-
if isinstance(obj, dict) and obj.get("rootPath"):
143-
project_layouts_map[cid].append(obj["rootPath"])
141+
layout = json.loads(layout)
144142
except Exception:
145-
pass
143+
continue
144+
if isinstance(layout, dict) and layout.get("rootPath"):
145+
project_layouts_map[cid].append(layout["rootPath"])
146146
except Exception:
147147
pass
148148

@@ -351,6 +351,7 @@ def assemble_workspace_tabs(
351351
"type": "ai",
352352
"text": f"**Tool Action:**{diff_text}",
353353
"timestamp": int(datetime.now().timestamp() * 1000),
354+
"synthetic": True,
354355
})
355356

356357
bubbles.sort(key=lambda b: b.get("timestamp") or 0)
@@ -360,7 +361,7 @@ def assemble_workspace_tabs(
360361
for b in bubbles:
361362
if b["type"] == "user":
362363
last_user_ts = b.get("timestamp")
363-
elif b["type"] == "ai" and last_user_ts is not None:
364+
elif b["type"] == "ai" and last_user_ts is not None and not b.get("synthetic"):
364365
ts = b.get("timestamp")
365366
if ts and ts > last_user_ts:
366367
meta = b.setdefault("metadata", {})

tests/test_cli_tabs.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import unittest
6+
from unittest.mock import patch
7+
8+
from flask import Flask
9+
10+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11+
if REPO_ROOT not in sys.path:
12+
sys.path.insert(0, REPO_ROOT)
13+
14+
from services.cli_tabs import _get_cli_workspace_tabs
15+
16+
17+
def _make_app():
18+
app = Flask(__name__)
19+
app.config["TESTING"] = True
20+
app.config["EXCLUSION_RULES"] = []
21+
return app
22+
23+
24+
def _fake_project(project_id: str, sessions: list[dict]) -> dict:
25+
return {
26+
"project_id": project_id,
27+
"workspace_name": "Test Project",
28+
"workspace_path": "/tmp/test",
29+
"last_updated_ms": 1_715_000_000_000,
30+
"sessions": sessions,
31+
}
32+
33+
34+
def _fake_session(session_id: str) -> dict:
35+
return {
36+
"session_id": session_id,
37+
"db_path": f"/tmp/{session_id}.db",
38+
"meta": {"createdAt": 1_715_000_000_000, "name": "Session"},
39+
}
40+
41+
42+
class TestMessagesToBubblesFailureIsolation(unittest.TestCase):
43+
def test_failing_session_does_not_500_endpoint(self) -> None:
44+
app = _make_app()
45+
project = _fake_project("proj-1", [
46+
_fake_session("sess-bad"),
47+
_fake_session("sess-good"),
48+
])
49+
50+
def fake_messages_to_bubbles(messages, created_ms):
51+
if messages == ["bad"]:
52+
raise RuntimeError("simulated bubble conversion failure")
53+
return [{"type": "user", "text": "hi", "timestamp": created_ms}]
54+
55+
def fake_traverse_blobs(db_path):
56+
return ["bad"] if "sess-bad" in db_path else ["ok"]
57+
58+
with app.test_request_context("/api/workspaces/cli:proj-1/tabs"), \
59+
patch("services.cli_tabs.list_cli_projects", return_value=[project]), \
60+
patch("services.cli_tabs.traverse_blobs", side_effect=fake_traverse_blobs), \
61+
patch("services.cli_tabs.messages_to_bubbles", side_effect=fake_messages_to_bubbles):
62+
response = _get_cli_workspace_tabs("cli:proj-1")
63+
64+
self.assertEqual(response.status_code, 200)
65+
payload = response.get_json()
66+
tab_ids = [t["id"] for t in payload["tabs"]]
67+
self.assertNotIn("sess-bad", tab_ids)
68+
self.assertIn("sess-good", tab_ids)
69+
70+
71+
if __name__ == "__main__":
72+
unittest.main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import sqlite3
6+
import sys
7+
import tempfile
8+
import unittest
9+
10+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11+
if REPO_ROOT not in sys.path:
12+
sys.path.insert(0, REPO_ROOT)
13+
14+
from services.workspace_listing import list_workspace_projects
15+
16+
17+
def _make_workspace_storage(parent: str, *, layout_as_dict: bool) -> str:
18+
ws_root = os.path.join(parent, "workspaceStorage")
19+
global_root = os.path.join(parent, "globalStorage")
20+
os.makedirs(ws_root, exist_ok=True)
21+
os.makedirs(global_root, exist_ok=True)
22+
23+
ws_dir = os.path.join(ws_root, "ws-a")
24+
os.makedirs(ws_dir, exist_ok=True)
25+
target_folder = os.path.join(parent, "real-project")
26+
os.makedirs(target_folder, exist_ok=True)
27+
with open(os.path.join(ws_dir, "workspace.json"), "w") as f:
28+
json.dump({"folder": f"file://{target_folder}"}, f)
29+
sqlite3.connect(os.path.join(ws_dir, "state.vscdb")).close()
30+
31+
layout_payload: object
32+
if layout_as_dict:
33+
layout_payload = {"rootPath": target_folder}
34+
else:
35+
layout_payload = json.dumps({"rootPath": target_folder})
36+
37+
gdb = os.path.join(global_root, "state.vscdb")
38+
conn = sqlite3.connect(gdb)
39+
conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)")
40+
conn.execute(
41+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
42+
(
43+
"composerData:cmp-1",
44+
json.dumps({
45+
"name": "Test composer",
46+
"createdAt": 1_715_000_000_000,
47+
"lastUpdatedAt": 1_715_000_500_000,
48+
"fullConversationHeadersOnly": [{"bubbleId": "b-1"}],
49+
}),
50+
),
51+
)
52+
conn.execute(
53+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
54+
(
55+
"messageRequestContext:cmp-1:ctx-1",
56+
json.dumps({"projectLayouts": [layout_payload]}),
57+
),
58+
)
59+
conn.execute(
60+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
61+
("bubbleId:cmp-1:b-1", json.dumps({"type": "user", "text": "hello"})),
62+
)
63+
conn.commit()
64+
conn.close()
65+
return ws_root
66+
67+
68+
class TestProjectLayoutsDictShape(unittest.TestCase):
69+
def _assert_assigned_to_workspace(self, ws_root: str) -> None:
70+
projects = list_workspace_projects(ws_root, rules=[])
71+
ids = [p["id"] for p in projects]
72+
self.assertIn("ws-a", ids, msg=f"expected composer routed to ws-a, got {ids}")
73+
74+
def test_string_shaped_layout(self):
75+
with tempfile.TemporaryDirectory() as tmp:
76+
ws_root = _make_workspace_storage(tmp, layout_as_dict=False)
77+
self._assert_assigned_to_workspace(ws_root)
78+
79+
def test_dict_shaped_layout(self):
80+
with tempfile.TemporaryDirectory() as tmp:
81+
ws_root = _make_workspace_storage(tmp, layout_as_dict=True)
82+
self._assert_assigned_to_workspace(ws_root)
83+
84+
85+
if __name__ == "__main__":
86+
unittest.main()
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import sys
6+
import tempfile
7+
import unittest
8+
9+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10+
if REPO_ROOT not in sys.path:
11+
sys.path.insert(0, REPO_ROOT)
12+
13+
from services.workspace_resolver import _get_project_from_file_path
14+
15+
16+
def _write_workspace_json(parent: str, name: str, folder: str) -> dict:
17+
ws_dir = os.path.join(parent, name)
18+
os.makedirs(ws_dir, exist_ok=True)
19+
wj = os.path.join(ws_dir, "workspace.json")
20+
with open(wj, "w") as f:
21+
json.dump({"folder": folder}, f)
22+
return {"name": name, "workspaceJsonPath": wj}
23+
24+
25+
class TestPrefixCollision(unittest.TestCase):
26+
def test_sibling_prefix_does_not_match(self):
27+
with tempfile.TemporaryDirectory() as tmp:
28+
app = os.path.join(tmp, "repo", "app")
29+
app2 = os.path.join(tmp, "repo", "app2")
30+
os.makedirs(app, exist_ok=True)
31+
os.makedirs(app2, exist_ok=True)
32+
33+
entries = [
34+
_write_workspace_json(tmp, "ws-app", app),
35+
_write_workspace_json(tmp, "ws-app2", app2),
36+
]
37+
38+
file_in_app2 = os.path.join(app2, "src", "main.py")
39+
self.assertEqual(
40+
_get_project_from_file_path(file_in_app2, entries),
41+
"ws-app2",
42+
)
43+
44+
def test_file_outside_any_workspace_returns_none(self):
45+
with tempfile.TemporaryDirectory() as tmp:
46+
app = os.path.join(tmp, "repo", "app")
47+
os.makedirs(app, exist_ok=True)
48+
entries = [_write_workspace_json(tmp, "ws-app", app)]
49+
50+
unrelated = os.path.join(tmp, "elsewhere", "file.py")
51+
self.assertIsNone(_get_project_from_file_path(unrelated, entries))
52+
53+
def test_file_inside_workspace_still_matches(self):
54+
with tempfile.TemporaryDirectory() as tmp:
55+
app = os.path.join(tmp, "repo", "app")
56+
os.makedirs(app, exist_ok=True)
57+
entries = [_write_workspace_json(tmp, "ws-app", app)]
58+
59+
inside = os.path.join(app, "src", "main.py")
60+
self.assertEqual(_get_project_from_file_path(inside, entries), "ws-app")
61+
62+
63+
if __name__ == "__main__":
64+
unittest.main()

0 commit comments

Comments
 (0)