Skip to content

Commit ca2d597

Browse files
committed
review: narrow excepts + drop duplicate queries / synthetic bubbles (#31, round 5)
Eight findings from Brad's review, addressed together. Recurring shape: broad ``except Exception`` blocks were swallowing not just the realistic failure (KeyError on missing dict field, sqlite3.Error on corrupt table, OSError on missing file) but also bugs we'd want to know about. Each catch is now narrowed to the actual failure mode the surrounding code intends to handle, with regression coverage to pin the contract. 1. services/workspace_db.py:_build_composer_id_to_workspace_id — narrow sqlite3.Error catch on the per-workspace state.vscdb open, json.JSONDecodeError / ValueError catch on the row parse. Round-3 work; one site Brad flagged in the same pass. Test: tests/test_workspace_db_special_paths.py:: TestBuildComposerMappingCorruptDb. 2. services/workspace_resolver.py:_infer_workspace_name_from_context — same narrowing on the local-DB query (lconn.execute fetchone). Test: tests/test_workspace_name_db_errors.py:: TestLocalQueryErrorSwallowed. 3. services/workspace_listing.py:list_workspace_projects — local _safe_fetchall(query, params=()) helper routes the three cursorDiskKV LIKE queries (composerData, messageRequestContext, bubbleId) through sqlite3.Error → []. Same pattern as workspace_tabs.py round 4. Test: tests/test_workspace_listing_sql_errors.py. 4. api/workspaces.py:get_workspace CLI branch — replaced cp["workspace_name"] / cp["last_updated_ms"] / cp["workspace_path"] with .get(); added isinstance(cp, dict) skip to the lookup generator. Brad's "unsafe dict access in CLI branch" finding. Test: tests/test_get_workspace_cli_malformed.py (two cases). 5. services/workspace_listing.py CLI section — the projects loop now guards cp non-dict, missing project_id, non-list sessions, and non-dict session entries. Brad's "unsafe dict access in CLI project loop" finding. Test: tests/test_workspace_listing_cli.py:: TestMalformedCliProjectRecordSkipped. 6. services/cli_tabs.py:_get_cli_workspace_tabs — lookup generator filters isinstance(cp, dict); project field access switched to .get(); session iteration uses isinstance(session, dict). Brad's "unsafe dict access in project lookup" finding on lines 17/22/25. Test: tests/test_cli_tabs.py::TestMalformedCliProjectsListLookup. 7. services/workspace_tabs.py — diffs no longer double-represented. Previously each diff was emitted both as ``tab.codeBlockDiffs`` (which the frontend at dashboard/static/js/download.js:128,274 and templates/workspace.html:135 actually reads) AND as a synthetic ``**Tool Action:**`` AI bubble in ``tab.bubbles`` (no consumer). Dropped the synthesis loop + the now-unused ``synthetic`` flag and its filter in the response-time pass. Brad's "diffs double-represented" finding. Test: tests/test_workspace_tabs_malformed_nested.py:: TestDiffsEmittedOnlyAsCodeBlockDiffs replaces the prior synthetic-timestamp tests. 8. services/workspace_tabs.py — merged the two ``messageRequestContext:%`` LIKE queries (built message_request_ context_map and project_layouts_map respectively) into a single pass that fills both maps. Brad's "messageRequestContext queried twice" finding. 9. services/workspace_db.py — narrowed the two remaining broad excepts: _collect_workspace_entries → OSError; _collect_invalid_workspace_ids → (OSError, ValueError, KeyError, TypeError). Brad's "broad silent except Exception: pass" finding. 207 tests pass (was 196; +11 across this round). The recurring heuristic across all four rounds on this PR: every nested iteration site needs ``isinstance(..., dict)``, every DB read needs ``sqlite3.Error`` catch, and broad excepts swallow the bugs we want to know about.
1 parent b111439 commit ca2d597

13 files changed

Lines changed: 429 additions & 115 deletions

api/workspaces.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,22 @@ def get_workspace(workspace_id):
6868
project_id = workspace_id[4:]
6969
cli_projects = list_cli_projects(get_cli_chats_path())
7070
for cp in cli_projects:
71-
if cp["project_id"] == project_id:
72-
last_ms = cp["last_updated_ms"]
73-
return jsonify({
74-
"id": workspace_id,
75-
"name": cp["workspace_name"] or project_id[:12],
76-
"path": cp["workspace_path"],
77-
"folder": cp["workspace_path"],
78-
"lastModified": (
79-
datetime.fromtimestamp(last_ms / 1000, tz=timezone.utc).isoformat()
80-
if last_ms
81-
else datetime.now(tz=timezone.utc).isoformat()
82-
),
83-
"source": "cli",
84-
})
71+
if not isinstance(cp, dict) or cp.get("project_id") != project_id:
72+
continue
73+
last_ms = cp.get("last_updated_ms")
74+
workspace_path_field = cp.get("workspace_path")
75+
return jsonify({
76+
"id": workspace_id,
77+
"name": cp.get("workspace_name") or project_id[:12],
78+
"path": workspace_path_field,
79+
"folder": workspace_path_field,
80+
"lastModified": (
81+
datetime.fromtimestamp(last_ms / 1000, tz=timezone.utc).isoformat()
82+
if last_ms
83+
else datetime.now(tz=timezone.utc).isoformat()
84+
),
85+
"source": "cli",
86+
})
8587
return jsonify({"error": "CLI project not found"}), 404
8688

8789
workspace_path = resolve_workspace_path()

services/cli_tabs.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,26 @@ def _get_cli_workspace_tabs(workspace_id: str):
1414
try:
1515
project_id = workspace_id[4:]
1616
cli_projects = list_cli_projects(get_cli_chats_path())
17-
project = next((cp for cp in cli_projects if cp["project_id"] == project_id), None)
17+
project = next(
18+
(
19+
cp for cp in cli_projects
20+
if isinstance(cp, dict) and cp.get("project_id") == project_id
21+
),
22+
None,
23+
)
1824
if project is None:
1925
return jsonify({"error": "CLI project not found"}), 404
2026

2127
rules = current_app.config.get("EXCLUSION_RULES") or []
22-
ws_name = project["workspace_name"] or project_id[:12]
28+
ws_name = project.get("workspace_name") or project_id[:12]
29+
sessions = project.get("sessions") or []
30+
if not isinstance(sessions, list):
31+
sessions = []
2332
tabs = []
2433

25-
for session in project["sessions"]:
34+
for session in sessions:
35+
if not isinstance(session, dict):
36+
continue
2637
session_id = session.get("session_id")
2738
if not session_id:
2839
continue

services/workspace_db.py

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ def _collect_workspace_entries(workspace_path: str) -> list[dict]:
2020
wj = os.path.join(full, "workspace.json")
2121
if os.path.isfile(wj):
2222
entries.append({"name": name, "workspaceJsonPath": wj})
23-
except Exception:
23+
except OSError:
24+
# workspace_path missing / not readable / not a directory — return what
25+
# we have so far. OSError covers FileNotFoundError, PermissionError,
26+
# and NotADirectoryError.
2427
pass
2528
return entries
2629

@@ -34,7 +37,11 @@ def _collect_invalid_workspace_ids(workspace_entries: list[dict]) -> set[str]:
3437
folders = get_workspace_folder_paths(wd)
3538
if not folders:
3639
invalid.add(entry["name"])
37-
except Exception:
40+
except (OSError, ValueError, KeyError, TypeError):
41+
# OSError: workspace.json unreadable. ValueError covers
42+
# json.JSONDecodeError. KeyError / TypeError: malformed entry
43+
# dict. Any of these mean we can't resolve folders → mark invalid,
44+
# matching the pre-narrowing behaviour.
3845
invalid.add(entry["name"])
3946
return invalid
4047

@@ -46,27 +53,33 @@ def _build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: l
4653
db_path = os.path.join(workspace_path, entry["name"], "state.vscdb")
4754
if not os.path.isfile(db_path):
4855
continue
56+
# closing() guarantees .close() on scope exit (issue #17).
57+
# Path.as_uri() percent-encodes reserved chars; ``f"file:{path}"``
58+
# breaks sqlite URI parsing on paths with spaces, ``#``, etc.
59+
db_uri = Path(db_path).resolve().as_uri() + "?mode=ro"
60+
row: tuple | None = None
4961
try:
50-
# closing() guarantees .close() on scope exit (issue #17).
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"
5462
with closing(sqlite3.connect(db_uri, uri=True)) as conn:
5563
row = conn.execute(
5664
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
5765
).fetchone()
58-
if row and row[0]:
59-
data = json.loads(row[0])
60-
all_composers = data.get("allComposers")
61-
if isinstance(all_composers, list):
62-
for c in all_composers:
63-
if not isinstance(c, dict):
64-
continue
65-
cid = c.get("composerId")
66-
if cid:
67-
mapping[cid] = entry["name"]
68-
except Exception:
69-
pass
66+
except sqlite3.Error:
67+
continue
68+
if not (row and row[0]):
69+
continue
70+
try:
71+
data = json.loads(row[0])
72+
except (json.JSONDecodeError, ValueError):
73+
continue
74+
all_composers = data.get("allComposers") if isinstance(data, dict) else None
75+
if not isinstance(all_composers, list):
76+
continue
77+
for c in all_composers:
78+
if not isinstance(c, dict):
79+
continue
80+
cid = c.get("composerId")
81+
if cid:
82+
mapping[cid] = entry["name"]
7083
return mapping
7184

7285

services/workspace_listing.py

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

33
import json
44
import os
5+
import sqlite3
56
from datetime import datetime, timezone
67

78
from utils.cli_chat_reader import list_cli_projects
@@ -43,14 +44,19 @@ def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
4344
# closing semantics now baked into the context manager (issue #17).
4445
with _open_global_db(workspace_path) as (global_db, _):
4546
if global_db:
47+
def _safe_fetchall(query: str, params: tuple = ()) -> list:
48+
try:
49+
return global_db.execute(query, params).fetchall()
50+
except sqlite3.Error:
51+
return []
4652
try:
47-
composer_rows = global_db.execute(
53+
composer_rows = _safe_fetchall(
4854
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND LENGTH(value) > 10"
49-
).fetchall()
55+
)
5056

51-
ctx_rows = global_db.execute(
57+
ctx_rows = _safe_fetchall(
5258
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'messageRequestContext:%'"
53-
).fetchall()
59+
)
5460
project_layouts_map: dict[str, list] = {}
5561
for row in ctx_rows:
5662
parts = row["key"].split(":")
@@ -74,9 +80,9 @@ def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
7480
except Exception:
7581
pass
7682

77-
bubble_rows = global_db.execute(
83+
bubble_rows = _safe_fetchall(
7884
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
79-
).fetchall()
85+
)
8086
bubble_map: dict[str, dict] = {}
8187
for row in bubble_rows:
8288
parts = row["key"].split(":")
@@ -227,11 +233,21 @@ def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
227233
try:
228234
cli_projects = list_cli_projects(get_cli_chats_path())
229235
for cp in cli_projects:
230-
ws_name = cp["workspace_name"] or cp["project_id"][:12]
236+
if not isinstance(cp, dict):
237+
continue
238+
project_id = cp.get("project_id")
239+
if not isinstance(project_id, str) or not project_id:
240+
continue
241+
ws_name = cp.get("workspace_name") or project_id[:12]
231242
if is_excluded_by_rules(rules, ws_name):
232243
continue
244+
sessions = cp.get("sessions") or []
245+
if not isinstance(sessions, list):
246+
continue
233247
cli_convos = []
234-
for s in cp["sessions"]:
248+
for s in sessions:
249+
if not isinstance(s, dict):
250+
continue
235251
session_id = s.get("session_id")
236252
if not session_id:
237253
continue
@@ -245,9 +261,9 @@ def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
245261
cli_convos.append(session_name)
246262
if not cli_convos:
247263
continue
248-
last_ms = cp["last_updated_ms"]
264+
last_ms = cp.get("last_updated_ms")
249265
projects.append({
250-
"id": f"cli:{cp['project_id']}",
266+
"id": f"cli:{project_id}",
251267
"name": ws_name,
252268
"conversationCount": len(cli_convos),
253269
"lastModified": (

services/workspace_resolver.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,27 @@ def _infer_workspace_name_from_context(workspace_path: str, workspace_id: str) -
4242
if not os.path.isfile(local_db_path):
4343
return None
4444
composer_ids: list[str] = []
45+
# closing() guarantees .close() on scope exit (issue #17).
46+
# Path.as_uri() percent-encodes reserved chars (#, ?, spaces, etc.);
47+
# naive f"file:{path}" breaks sqlite URI parsing.
48+
_db_uri = Path(local_db_path).resolve().as_uri() + "?mode=ro"
49+
row: tuple | None = None
4550
try:
46-
# closing() guarantees .close() on scope exit (issue #17).
47-
# Path.as_uri() percent-encodes reserved chars (#, ?, spaces, etc.);
48-
# naive f"file:{path}" breaks sqlite URI parsing.
49-
_db_uri = Path(local_db_path).resolve().as_uri() + "?mode=ro"
5051
with closing(sqlite3.connect(_db_uri, uri=True)) as lconn:
5152
row = lconn.execute(
5253
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
5354
).fetchone()
54-
if row and row[0]:
55-
data = json.loads(row[0])
56-
for c in (data.get("allComposers") or []):
57-
cid = c.get("composerId") if isinstance(c, dict) else None
58-
if cid:
59-
composer_ids.append(cid)
60-
except Exception:
55+
except sqlite3.Error:
6156
return None
57+
if row and row[0]:
58+
try:
59+
data = json.loads(row[0])
60+
except (json.JSONDecodeError, ValueError):
61+
return None
62+
for c in (data.get("allComposers") or []):
63+
cid = c.get("composerId") if isinstance(c, dict) else None
64+
if cid:
65+
composer_ids.append(cid)
6266
if not composer_ids:
6367
return None
6468

services/workspace_tabs.py

Lines changed: 38 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
to_epoch_ms,
1313
)
1414
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
15-
from utils.text_extract import extract_text_from_bubble, format_tool_action
15+
from utils.text_extract import extract_text_from_bubble
1616
from utils.tool_parser import parse_tool_call as _parse_tool_call
1717
from utils.workspace_descriptor import _read_json_file
1818
from services.workspace_db import (
@@ -116,42 +116,41 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
116116
except Exception:
117117
pass
118118

119-
# Load messageRequestContext
119+
# Load messageRequestContext rows once; build both
120+
# message_request_context_map and project_layouts_map from the same pass.
121+
project_layouts_map: dict[str, list] = {}
120122
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'messageRequestContext:%'"):
121123
parts = row["key"].split(":")
124+
if len(parts) < 2:
125+
continue
126+
chat_id = parts[1]
127+
try:
128+
ctx = json.loads(row["value"])
129+
except Exception:
130+
continue
131+
if not isinstance(ctx, dict):
132+
continue
133+
134+
# Per-bubble context map (needs the contextId at parts[2])
122135
if len(parts) >= 3:
123-
chat_id = parts[1]
124136
context_id = parts[2]
125-
try:
126-
ctx = json.loads(row["value"])
127-
message_request_context_map.setdefault(chat_id, []).append({
128-
**ctx,
129-
"contextId": context_id,
130-
})
131-
except Exception:
132-
pass
137+
message_request_context_map.setdefault(chat_id, []).append({
138+
**ctx,
139+
"contextId": context_id,
140+
})
133141

134-
# Build projectLayoutsMap
135-
project_layouts_map: dict[str, list] = {}
136-
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'messageRequestContext:%'"):
137-
parts = row["key"].split(":")
138-
if len(parts) >= 2:
139-
cid = parts[1]
140-
try:
141-
ctx = json.loads(row["value"])
142-
layouts = ctx.get("projectLayouts")
143-
if isinstance(layouts, list):
144-
project_layouts_map.setdefault(cid, [])
145-
for layout in layouts:
146-
if isinstance(layout, str):
147-
try:
148-
layout = json.loads(layout)
149-
except Exception:
150-
continue
151-
if isinstance(layout, dict) and layout.get("rootPath"):
152-
project_layouts_map[cid].append(layout["rootPath"])
153-
except Exception:
154-
pass
142+
# Project-layout map (root paths used by the resolver)
143+
layouts = ctx.get("projectLayouts")
144+
if isinstance(layouts, list):
145+
project_layouts_map.setdefault(chat_id, [])
146+
for layout in layouts:
147+
if isinstance(layout, str):
148+
try:
149+
layout = json.loads(layout)
150+
except Exception:
151+
continue
152+
if isinstance(layout, dict) and layout.get("rootPath"):
153+
project_layouts_map[chat_id].append(layout["rootPath"])
155154

156155
# Get composer data entries with conversations
157156
composer_rows = _safe_fetchall(
@@ -363,22 +362,13 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
363362
)):
364363
continue
365364

366-
# Code block diffs as extra bubbles
365+
# codeBlockDiffs are emitted as a structured ``tab.codeBlockDiffs``
366+
# field below; the dashboard reads them from there (download.js,
367+
# workspace.html). Previously this loop also pushed a synthetic
368+
# ``Tool Action`` AI bubble into ``tab.bubbles``, double-representing
369+
# every diff on the wire and forcing a ``synthetic`` filter in the
370+
# response-time pass. Dropping the synthesis — frontend never read it.
367371
diffs = code_block_diff_map.get(composer_id, [])
368-
for diff in diffs:
369-
diff_text = format_tool_action(diff)
370-
if diff_text.strip():
371-
synthetic_ts = (
372-
to_epoch_ms(diff.get("timestamp"))
373-
or to_epoch_ms(diff.get("createdAt"))
374-
or max((b.get("timestamp") or 0) for b in bubbles)
375-
)
376-
bubbles.append({
377-
"type": "ai",
378-
"text": f"**Tool Action:**{diff_text}",
379-
"timestamp": synthetic_ts,
380-
"synthetic": True,
381-
})
382372

383373
bubbles.sort(key=lambda b: b.get("timestamp") or 0)
384374

@@ -387,7 +377,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
387377
for b in bubbles:
388378
if b["type"] == "user":
389379
last_user_ts = b.get("timestamp")
390-
elif b["type"] == "ai" and last_user_ts is not None and not b.get("synthetic"):
380+
elif b["type"] == "ai" and last_user_ts is not None:
391381
ts = b.get("timestamp")
392382
if ts and ts > last_user_ts:
393383
meta = b.setdefault("metadata", {})

0 commit comments

Comments
 (0)