Skip to content

Commit 014b4e1

Browse files
authored
Add incomplete-result signaling on parse failure (#67) (#78)
* initial implementation for replacing except-pass * fix: test failure with bubble none and pytest missing * fix: Broaden the decode guard to keep malformed KV rows non-fatal. * fix: replace remaining print and pattern. * initial implementation * fix: review comments * fix: review comments * fix: review comments ### Must fix #1 — **Fixed** `GET /api/workspaces` always returns `{"projects": [...], "warnings"?: [...]}` so the shape no longer flips at runtime. ### Should fix #2 — **Fixed** Removed dead `attach_warnings` from `models/__init__.py` and deleted the unused module-level helper. ### Should fix #3 — **Fixed** Added `AND value IS NOT NULL` to bubble and composer queries in `workspace_tabs.py`; removed NULL branches that called `record_*_skipped()` (tombstones are not `parse_error`). ### Nice to have #4 — **Fixed** Renamed/updated `test_workspaces_api_object_when_clean` and adjusted `test_api_endpoints.py` `TestListWorkspaces` + exclusion tests for the object shape. ### Nice to have #5 — **Skipped** `if count > 0` guards — style-only; no behavior change for normal callers (`count` defaults to 1). ### Extra (follow-on from #1) - `tests/web-ui-smoke.sh` — parse `body["projects"]` when the response is an object (smoke would otherwise fail). - `static/js/app.js` — doc comment only; `normalizeWorkspacesResponse()` still accepts a legacy array. ### Validation - `pytest tests/test_parse_warnings.py tests/test_api_endpoints.py` — **25 passed** - `tests/test_workspace_tabs_null_bubble.py` — included; NULL row still must not crash (filtered in SQL, no false `parse_error` banner)
1 parent e6bae8c commit 014b4e1

21 files changed

Lines changed: 520 additions & 59 deletions

api/export_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,4 @@ def export_chats():
222222
type(e).__name__,
223223
exc_info=True,
224224
)
225-
return jsonify({"error": f"Export failed: {str(e)}"}), 500
225+
return jsonify({"error": "Export failed"}), 500

api/pdf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def footer(self):
176176
type(e).__name__,
177177
exc_info=True,
178178
)
179-
return jsonify({"error": f"Failed to generate PDF: {str(e)}"}), 500
179+
return jsonify({"error": "Failed to generate PDF"}), 500
180180

181181

182182
def _render_code_block(pdf, code_text: str):

api/search.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from utils.path_helpers import to_epoch_ms, warn_workspace_json_read
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
22+
from models import Bubble, Composer, ParseWarningCollector, SchemaError
2323

2424
bp = Blueprint("search", __name__)
2525
_logger = logging.getLogger(__name__)
@@ -79,6 +79,7 @@ def search():
7979

8080
workspace_path = resolve_workspace_path()
8181
results = []
82+
parse_warnings = ParseWarningCollector()
8283
query_lower = query.lower()
8384

8485
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
@@ -170,8 +171,14 @@ def search():
170171
e,
171172
type(e).__name__,
172173
)
173-
except (json.JSONDecodeError, ValueError):
174-
pass
174+
parse_warnings.record_bubble_skipped()
175+
except (json.JSONDecodeError, TypeError, ValueError) as e:
176+
_logger.warning(
177+
"Failed to decode Bubble from bubbleId:%s: %s",
178+
bid,
179+
e,
180+
)
181+
parse_warnings.record_bubble_skipped()
175182

176183
# Search through composerData
177184
composer_rows = conn.execute(
@@ -189,8 +196,15 @@ def search():
189196
e,
190197
type(e).__name__,
191198
)
199+
parse_warnings.record_composer_skipped()
192200
continue
193-
except (json.JSONDecodeError, TypeError, ValueError):
201+
except (json.JSONDecodeError, TypeError, ValueError) as e:
202+
_logger.warning(
203+
"Failed to decode Composer from composerData:%s: %s",
204+
composer_id,
205+
e,
206+
)
207+
parse_warnings.record_composer_skipped()
194208
continue
195209
try:
196210
cd = composer.raw
@@ -285,6 +299,7 @@ def search():
285299
composer_id,
286300
e,
287301
)
302+
parse_warnings.record_composer_processing_failure()
288303

289304
except Exception:
290305
_logger.exception("Error searching global storage")
@@ -498,7 +513,8 @@ def _ts(r):
498513
return t
499514
results.sort(key=_ts, reverse=True)
500515

501-
return jsonify({"results": results})
516+
payload: dict = {"results": results}
517+
return jsonify(parse_warnings.attach_to(payload))
502518

503519
except Exception:
504520
_logger.exception("Search failed")

api/workspaces.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,11 @@ def list_workspaces():
5555
try:
5656
workspace_path = resolve_workspace_path()
5757
rules = current_app.config.get("EXCLUSION_RULES") or []
58-
projects = list_workspace_projects(workspace_path, rules)
59-
return jsonify(projects)
58+
projects, warnings = list_workspace_projects(workspace_path, rules)
59+
payload: dict = {"projects": projects}
60+
if warnings:
61+
payload["warnings"] = warnings
62+
return jsonify(payload)
6063
except Exception:
6164
_logger.exception("Failed to get workspaces")
6265
return jsonify({"error": "Failed to get workspaces"}), 500

models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from models.cli_session import CliSessionMeta
22
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
33
from models.errors import SchemaError
4+
from models.parse_warnings import ParseWarningCollector
45
from models.export import ExportEntry
56
from models.workspace import Workspace
67

@@ -9,6 +10,7 @@
910
"CliSessionMeta",
1011
"Composer",
1112
"ExportEntry",
13+
"ParseWarningCollector",
1214
"SchemaError",
1315
"Workspace",
1416
"WorkspaceLocalComposer",

models/parse_warnings.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class ParseWarningCollector:
8+
"""Accumulates parse failures skipped during bubble/composer processing."""
9+
10+
composers_skipped: int = 0
11+
bubbles_skipped: int = 0
12+
composers_processing_failed: int = 0
13+
14+
def record_composer_skipped(self, count: int = 1) -> None:
15+
if count > 0:
16+
self.composers_skipped += count
17+
18+
def record_bubble_skipped(self, count: int = 1) -> None:
19+
if count > 0:
20+
self.bubbles_skipped += count
21+
22+
def record_composer_processing_failure(self, count: int = 1) -> None:
23+
"""Post-parse assembly failed; not a JSON/schema parse skip."""
24+
if count > 0:
25+
self.composers_processing_failed += count
26+
27+
@property
28+
def has_warnings(self) -> bool:
29+
return (
30+
self.composers_skipped > 0
31+
or self.bubbles_skipped > 0
32+
or self.composers_processing_failed > 0
33+
)
34+
35+
def to_api_list(self) -> list[dict]:
36+
"""Structured warnings for JSON API responses (issue #67)."""
37+
warnings: list[dict] = []
38+
if self.composers_skipped:
39+
n = self.composers_skipped
40+
noun = "conversation" if n == 1 else "conversations"
41+
warnings.append({
42+
"type": "parse_error",
43+
"count": n,
44+
"detail": (
45+
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
46+
),
47+
})
48+
if self.bubbles_skipped:
49+
n = self.bubbles_skipped
50+
noun = "message" if n == 1 else "messages"
51+
warnings.append({
52+
"type": "parse_error",
53+
"count": n,
54+
"detail": (
55+
f"{n} {noun} could not be loaded due to schema or JSON parse errors"
56+
),
57+
})
58+
if self.composers_processing_failed:
59+
n = self.composers_processing_failed
60+
noun = "conversation" if n == 1 else "conversations"
61+
warnings.append({
62+
"type": "processing_error",
63+
"count": n,
64+
"detail": (
65+
f"{n} {noun} could not be fully assembled after parsing"
66+
),
67+
})
68+
return warnings
69+
70+
def attach_to(self, payload: dict) -> dict:
71+
"""Add ``warnings`` to a dict response when any failures were recorded."""
72+
if self.has_warnings:
73+
payload = {**payload, "warnings": self.to_api_list()}
74+
return payload

services/workspace_listing.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from utils.workspace_descriptor import read_json_file
2020
from utils.workspace_path import get_cli_chats_path
21-
from models import Composer, SchemaError
21+
from models import Composer, ParseWarningCollector, SchemaError
2222
from services.workspace_db import (
2323
_build_composer_id_to_workspace_id,
2424
_collect_invalid_workspace_ids,
@@ -37,8 +37,9 @@
3737
)
3838

3939

40-
def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]:
41-
"""Return the sorted project list that GET /api/workspaces renders."""
40+
def list_workspace_projects(workspace_path: str, rules: list) -> tuple[list[dict], list[dict]]:
41+
"""Return (projects, warnings) for GET /api/workspaces."""
42+
parse_warnings = ParseWarningCollector()
4243
workspace_entries = _collect_workspace_entries(workspace_path)
4344
invalid_workspace_ids = _collect_invalid_workspace_ids(workspace_entries)
4445

@@ -84,13 +85,15 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
8485
cid,
8586
e,
8687
)
88+
parse_warnings.record_composer_skipped()
8789
continue
8890
if not isinstance(parsed, dict):
8991
_logger.warning(
9092
"Failed to parse Composer from composerData:%s: expected object, got %s",
9193
cid,
9294
type(parsed).__name__,
9395
)
96+
parse_warnings.record_composer_skipped()
9497
continue
9598
try:
9699
composer = Composer.from_dict(parsed, composer_id=cid)
@@ -100,6 +103,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
100103
cid,
101104
e,
102105
)
106+
parse_warnings.record_composer_skipped()
103107
continue
104108
cd = composer.raw
105109
try:
@@ -134,6 +138,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
134138
cid,
135139
e,
136140
)
141+
parse_warnings.record_composer_processing_failure()
137142
except Exception as e:
138143
_logger.error(
139144
"Failed to load composer rows from global storage: %s",
@@ -284,4 +289,4 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
284289
_logger.warning("Failed to load CLI projects: %s", e)
285290

286291
projects.sort(key=lambda p: p["lastModified"], reverse=True)
287-
return projects
292+
return projects, parse_warnings.to_api_list()

services/workspace_tabs.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from utils.text_extract import extract_text_from_bubble
2121
from utils.tool_parser import parse_tool_call as _parse_tool_call
2222
from utils.workspace_descriptor import read_json_file
23-
from models import Bubble, Composer, SchemaError
23+
from models import Bubble, Composer, ParseWarningCollector, SchemaError
2424
from services.workspace_db import (
2525
_build_composer_id_to_workspace_id,
2626
_collect_invalid_workspace_ids,
@@ -83,6 +83,7 @@ def assemble_workspace_tabs(
8383
rules: list,
8484
) -> tuple[dict, int]:
8585
"""Build (payload, status) for GET /api/workspaces/<id>/tabs; status=404 if global storage is missing."""
86+
parse_warnings = ParseWarningCollector()
8687
response: dict = {"tabs": []}
8788

8889
workspace_entries = _collect_workspace_entries(workspace_path)
@@ -133,16 +134,13 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
133134
return []
134135

135136
# Load bubbles
136-
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"):
137+
for row in _safe_fetchall(
138+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
139+
" AND value IS NOT NULL"
140+
):
137141
parts = row["key"].split(":")
138142
if len(parts) >= 3:
139143
bid = parts[2]
140-
if row["value"] is None:
141-
_logger.warning(
142-
"Skipping Bubble cursorDiskKV row with NULL value: key=%r",
143-
row["key"],
144-
)
145-
continue
146144
try:
147145
parsed = json.loads(row["value"])
148146

@@ -155,6 +153,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
155153
payload_len,
156154
payload_fp,
157155
)
156+
parse_warnings.record_bubble_skipped()
158157
continue
159158
try:
160159
bubble_obj = Bubble.from_dict(parsed, bubble_id=bid)
@@ -168,6 +167,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
168167
bid,
169168
e,
170169
)
170+
parse_warnings.record_bubble_skipped()
171171

172172
# Load codeBlockDiffs
173173
code_block_diff_map = load_code_block_diff_map(global_db)
@@ -210,6 +210,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
210210
# Get composer data entries with conversations
211211
composer_rows = _safe_fetchall(
212212
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
213+
" AND value IS NOT NULL"
213214
" AND value LIKE '%fullConversationHeadersOnly%'"
214215
" AND value NOT LIKE '%fullConversationHeadersOnly\":[]%'"
215216
)
@@ -227,12 +228,6 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
227228

228229
for row in composer_rows:
229230
composer_id = row["key"].split(":")[1]
230-
if row["value"] is None:
231-
_logger.warning(
232-
"Skipping Composer cursorDiskKV row with NULL value: key=%r",
233-
row["key"],
234-
)
235-
continue
236231
try:
237232
parsed = json.loads(row["value"])
238233
except (json.JSONDecodeError, TypeError, ValueError) as e:
@@ -245,6 +240,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
245240
payload_len,
246241
payload_fp,
247242
)
243+
parse_warnings.record_composer_skipped()
248244
continue
249245
try:
250246
composer = Composer.from_dict(parsed, composer_id=composer_id)
@@ -257,6 +253,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
257253
composer_id,
258254
e,
259255
)
256+
parse_warnings.record_composer_skipped()
260257
continue
261258
try:
262259
cd = composer.raw
@@ -579,8 +576,9 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
579576
composer_id,
580577
e,
581578
)
579+
parse_warnings.record_composer_processing_failure()
582580

583581
# Sort tabs by timestamp descending (newest first)
584582
response["tabs"].sort(key=lambda t: t.get("timestamp") or 0, reverse=True)
585583

586-
return response, 200
584+
return parse_warnings.attach_to(response), 200

static/css/style.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
--success-bg: #052e16;
3737
--success-border: #166534;
3838
--danger-bg: #450a0a;
39+
--warning-bg: #422006;
40+
--warning-border: #854d0e;
41+
--warning-text: #fcd34d;
3942
--danger-border: #991b1b;
4043
--code-bg: #1e1e1e;
4144
--spinner: #3b82f6;
@@ -74,6 +77,9 @@
7477
--success-bg: #f0fdf4;
7578
--success-border: #bbf7d0;
7679
--danger-bg: #fef2f2;
80+
--warning-bg: #fffbeb;
81+
--warning-border: #fcd34d;
82+
--warning-text: #92400e;
7783
--danger-border: #fecaca;
7884
--code-bg: #f5f5f5;
7985
--spinner: #2563eb;
@@ -249,6 +255,7 @@ h3 { font-size: 1.15rem; font-weight: 600; }
249255
.alert-info { background: var(--info-bg); border: 1px solid var(--info-border); color: var(--info-text); }
250256
.alert-success { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success); }
251257
.alert-danger { background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); }
258+
.alert-warning { background: var(--warning-bg); border: 1px solid var(--warning-border); color: var(--warning-text); }
252259

253260
/* ---------- Badges ---------- */
254261
.badge {

0 commit comments

Comments
 (0)