From 93dea0480c8371fb6c8c76bc6d6f8d938709cfd7 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 25 May 2026 10:42:09 -0400 Subject: [PATCH 1/8] initial implementation for replacing except-pass --- .github/workflows/tests.yml | 2 +- tests/test_parse_failure_logging.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5cf093..29e917f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,7 +116,7 @@ jobs: # new endpoint file because `pytest tests/` would also re-collect the # 178 unittest.TestCase subclasses already run in the step above — # ~2× the CI minutes for zero extra signal. - run: python -m pytest tests/test_api_endpoints.py -v --tb=short + run: python -m pytest tests/test_api_endpoints.py tests/test_parse_failure_logging.py -v --tb=short # ── PyInstaller desktop build (Windows only, once per workflow) ──────── # Closes #44. Builds the onedir bundle and smoke-tests --help so the diff --git a/tests/test_parse_failure_logging.py b/tests/test_parse_failure_logging.py index 941835e..5399438 100644 --- a/tests/test_parse_failure_logging.py +++ b/tests/test_parse_failure_logging.py @@ -34,6 +34,7 @@ def _seed_listing_with_drifted_composer(parent: str) -> str: conn = sqlite3.connect(os.path.join(global_root, "state.vscdb")) conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)") + # Missing createdAt — Composer.from_dict raises SchemaError. conn.execute( "INSERT INTO cursorDiskKV VALUES (?, ?)", ( @@ -61,8 +62,10 @@ def _seed_tabs_with_drifted_bubble(parent: str) -> str: ws_dir = os.path.join(ws_root, "ws-a") os.makedirs(ws_dir, exist_ok=True) + proj_dir = os.path.join(ws_dir, "proj") + os.makedirs(proj_dir, exist_ok=True) with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f: - json.dump({"folder": "/tmp/proj"}, f) + json.dump({"folder": f"file://{proj_dir}"}, f) sqlite3.connect(os.path.join(ws_dir, "state.vscdb")).close() conn = sqlite3.connect(os.path.join(global_root, "state.vscdb")) @@ -82,6 +85,7 @@ def _seed_tabs_with_drifted_bubble(parent: str) -> str: }), ), ) + # Non-dict bubble value trips Bubble.from_dict schema gate. conn.execute( "INSERT INTO cursorDiskKV VALUES (?, ?)", ("bubbleId:cmp-ok:b-bad", json.dumps("not-a-dict")), @@ -126,9 +130,9 @@ def test_workspace_tabs_logs_bubble_json_decode_failure(self) -> None: conn.commit() with self.assertLogs("services.workspace_tabs", level="WARNING") as cm: with app.test_request_context("/api/workspaces/global/tabs"): - payload, status = assemble_workspace_tabs("global", ws_root, rules=[]) + _payload, _status = assemble_workspace_tabs("global", ws_root, rules=[]) - self.assertEqual(status, 200) + self.assertEqual(_status, 200) messages = [r.getMessage() for r in cm.records] self.assertTrue( any("decode Bubble" in m and "b-json" in m for m in messages), @@ -156,9 +160,9 @@ def test_workspace_tabs_logs_composer_json_decode_failure(self) -> None: conn.commit() with self.assertLogs("services.workspace_tabs", level="WARNING") as cm: with app.test_request_context("/api/workspaces/global/tabs"): - payload, status = assemble_workspace_tabs("global", ws_root, rules=[]) + _payload, _status = assemble_workspace_tabs("global", ws_root, rules=[]) - self.assertEqual(status, 200) + self.assertEqual(_status, 200) messages = [r.getMessage() for r in cm.records] self.assertTrue( any("decode Composer" in m and "cmp-json" in m for m in messages), From 7e6dca22a419e1b6f9ce861c6301b45eef5a86d6 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 25 May 2026 11:26:32 -0400 Subject: [PATCH 2/8] fix: test failure with bubble none and pytest missing --- .github/workflows/tests.yml | 2 +- tests/test_parse_failure_logging.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29e917f..b5cf093 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,7 +116,7 @@ jobs: # new endpoint file because `pytest tests/` would also re-collect the # 178 unittest.TestCase subclasses already run in the step above — # ~2× the CI minutes for zero extra signal. - run: python -m pytest tests/test_api_endpoints.py tests/test_parse_failure_logging.py -v --tb=short + run: python -m pytest tests/test_api_endpoints.py -v --tb=short # ── PyInstaller desktop build (Windows only, once per workflow) ──────── # Closes #44. Builds the onedir bundle and smoke-tests --help so the diff --git a/tests/test_parse_failure_logging.py b/tests/test_parse_failure_logging.py index 5399438..db2838e 100644 --- a/tests/test_parse_failure_logging.py +++ b/tests/test_parse_failure_logging.py @@ -34,7 +34,6 @@ def _seed_listing_with_drifted_composer(parent: str) -> str: conn = sqlite3.connect(os.path.join(global_root, "state.vscdb")) conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)") - # Missing createdAt — Composer.from_dict raises SchemaError. conn.execute( "INSERT INTO cursorDiskKV VALUES (?, ?)", ( @@ -85,7 +84,6 @@ def _seed_tabs_with_drifted_bubble(parent: str) -> str: }), ), ) - # Non-dict bubble value trips Bubble.from_dict schema gate. conn.execute( "INSERT INTO cursorDiskKV VALUES (?, ?)", ("bubbleId:cmp-ok:b-bad", json.dumps("not-a-dict")), From 70afd815d0bc16875dc15ce35a72a4a8be048b44 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 25 May 2026 12:36:33 -0400 Subject: [PATCH 3/8] fix: Broaden the decode guard to keep malformed KV rows non-fatal. --- services/workspace_tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index f993e7d..2834415 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -145,7 +145,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: continue try: parsed = json.loads(row["value"]) - + except (json.JSONDecodeError, TypeError, ValueError) as e: payload_len, payload_fp = _kv_payload_log_meta(row["value"]) _logger.warning( From 18a750994ce0731e200a8d00a591b8c291976863 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 25 May 2026 16:35:02 -0400 Subject: [PATCH 4/8] fix: replace remaining print and pattern. --- services/workspace_tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index 2834415..f993e7d 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -145,7 +145,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: continue try: parsed = json.loads(row["value"]) - + except (json.JSONDecodeError, TypeError, ValueError) as e: payload_len, payload_fp = _kv_payload_log_meta(row["value"]) _logger.warning( From eb45566e38ab1c82e927946a12cf9a7662298df7 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 25 May 2026 18:52:07 -0400 Subject: [PATCH 5/8] initial implementation --- api/search.py | 12 +- api/workspaces.py | 4 +- models/__init__.py | 3 + models/parse_warnings.py | 61 +++++ services/workspace_listing.py | 13 +- services/workspace_tabs.py | 12 +- static/css/style.css | 7 + static/js/app.js | 45 ++++ templates/index.html | 5 +- templates/search.html | 2 + templates/workspace.html | 3 + tests/test_parse_warnings.py | 289 +++++++++++++++++++++ tests/test_project_layouts_dict_shape.py | 2 +- tests/test_workspace_listing_cli.py | 8 +- tests/test_workspace_listing_sql_errors.py | 2 +- 15 files changed, 451 insertions(+), 17 deletions(-) create mode 100644 models/parse_warnings.py create mode 100644 tests/test_parse_warnings.py diff --git a/api/search.py b/api/search.py index eaede04..1fc594c 100644 --- a/api/search.py +++ b/api/search.py @@ -19,7 +19,7 @@ from utils.path_helpers import to_epoch_ms, warn_workspace_json_read from utils.text_extract import extract_text_from_bubble from utils.cli_chat_reader import list_cli_projects, traverse_blobs, messages_to_bubbles -from models import Bubble, Composer, SchemaError +from models import Bubble, Composer, ParseWarningCollector, SchemaError bp = Blueprint("search", __name__) _logger = logging.getLogger(__name__) @@ -79,6 +79,7 @@ def search(): workspace_path = resolve_workspace_path() results = [] + parse_warnings = ParseWarningCollector() query_lower = query.lower() global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb")) @@ -170,8 +171,9 @@ def search(): e, type(e).__name__, ) + parse_warnings.record_bubble_skipped() except (json.JSONDecodeError, ValueError): - pass + parse_warnings.record_bubble_skipped() # Search through composerData composer_rows = conn.execute( @@ -189,8 +191,10 @@ def search(): e, type(e).__name__, ) + parse_warnings.record_composer_skipped() continue except (json.JSONDecodeError, TypeError, ValueError): + parse_warnings.record_composer_skipped() continue try: cd = composer.raw @@ -285,6 +289,7 @@ def search(): composer_id, e, ) + parse_warnings.record_composer_skipped() except Exception: _logger.exception("Error searching global storage") @@ -498,7 +503,8 @@ def _ts(r): return t results.sort(key=_ts, reverse=True) - return jsonify({"results": results}) + payload: dict = {"results": results} + return jsonify(parse_warnings.attach_to(payload)) except Exception: _logger.exception("Search failed") diff --git a/api/workspaces.py b/api/workspaces.py index 2efc2fd..e85f769 100644 --- a/api/workspaces.py +++ b/api/workspaces.py @@ -55,7 +55,9 @@ def list_workspaces(): try: workspace_path = resolve_workspace_path() rules = current_app.config.get("EXCLUSION_RULES") or [] - projects = list_workspace_projects(workspace_path, rules) + projects, warnings = list_workspace_projects(workspace_path, rules) + if warnings: + return jsonify({"projects": projects, "warnings": warnings}) return jsonify(projects) except Exception: _logger.exception("Failed to get workspaces") diff --git a/models/__init__.py b/models/__init__.py index df249ce..c9333a9 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,6 +1,7 @@ from models.cli_session import CliSessionMeta from models.conversation import Bubble, Composer, WorkspaceLocalComposer from models.errors import SchemaError +from models.parse_warnings import ParseWarningCollector, attach_warnings from models.export import ExportEntry from models.workspace import Workspace @@ -9,7 +10,9 @@ "CliSessionMeta", "Composer", "ExportEntry", + "ParseWarningCollector", "SchemaError", "Workspace", "WorkspaceLocalComposer", + "attach_warnings", ] diff --git a/models/parse_warnings.py b/models/parse_warnings.py new file mode 100644 index 0000000..48dfadd --- /dev/null +++ b/models/parse_warnings.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ParseWarningCollector: + """Accumulates parse failures skipped during bubble/composer processing.""" + + composers_skipped: int = 0 + bubbles_skipped: int = 0 + + def record_composer_skipped(self, count: int = 1) -> None: + if count > 0: + self.composers_skipped += count + + def record_bubble_skipped(self, count: int = 1) -> None: + if count > 0: + self.bubbles_skipped += count + + @property + def has_warnings(self) -> bool: + return self.composers_skipped > 0 or self.bubbles_skipped > 0 + + def to_api_list(self) -> list[dict]: + """Structured warnings for JSON API responses (issue #67).""" + warnings: list[dict] = [] + if self.composers_skipped: + n = self.composers_skipped + noun = "conversation" if n == 1 else "conversations" + warnings.append({ + "type": "parse_error", + "count": n, + "detail": ( + f"{n} {noun} could not be loaded due to schema or JSON parse errors" + ), + }) + if self.bubbles_skipped: + n = self.bubbles_skipped + noun = "message" if n == 1 else "messages" + warnings.append({ + "type": "parse_error", + "count": n, + "detail": ( + f"{n} {noun} could not be loaded due to schema or JSON parse errors" + ), + }) + return warnings + + def attach_to(self, payload: dict) -> dict: + """Add ``warnings`` to a dict response when any failures were recorded.""" + if self.has_warnings: + payload = {**payload, "warnings": self.to_api_list()} + return payload + + +def attach_warnings(payload: dict, warnings: list[dict]) -> dict: + """Merge pre-built warnings into a response dict.""" + if warnings: + return {**payload, "warnings": warnings} + return payload diff --git a/services/workspace_listing.py b/services/workspace_listing.py index a23dbfd..a98397b 100644 --- a/services/workspace_listing.py +++ b/services/workspace_listing.py @@ -18,7 +18,7 @@ ) from utils.workspace_descriptor import read_json_file from utils.workspace_path import get_cli_chats_path -from models import Composer, SchemaError +from models import Composer, ParseWarningCollector, SchemaError from services.workspace_db import ( _build_composer_id_to_workspace_id, _collect_invalid_workspace_ids, @@ -37,8 +37,9 @@ ) -def list_workspace_projects(workspace_path: str, rules: list) -> list[dict]: - """Return the sorted project list that GET /api/workspaces renders.""" +def list_workspace_projects(workspace_path: str, rules: list) -> tuple[list[dict], list[dict]]: + """Return (projects, warnings) for GET /api/workspaces.""" + parse_warnings = ParseWarningCollector() workspace_entries = _collect_workspace_entries(workspace_path) invalid_workspace_ids = _collect_invalid_workspace_ids(workspace_entries) @@ -84,6 +85,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: cid, e, ) + parse_warnings.record_composer_skipped() continue if not isinstance(parsed, dict): _logger.warning( @@ -91,6 +93,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: cid, type(parsed).__name__, ) + parse_warnings.record_composer_skipped() continue try: composer = Composer.from_dict(parsed, composer_id=cid) @@ -100,6 +103,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: cid, e, ) + parse_warnings.record_composer_skipped() continue cd = composer.raw try: @@ -134,6 +138,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: cid, e, ) + parse_warnings.record_composer_skipped() except Exception as e: _logger.error( "Failed to load composer rows from global storage: %s", @@ -284,4 +289,4 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: _logger.warning("Failed to load CLI projects: %s", e) projects.sort(key=lambda p: p["lastModified"], reverse=True) - return projects + return projects, parse_warnings.to_api_list() diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index f993e7d..f574859 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -20,7 +20,7 @@ from utils.text_extract import extract_text_from_bubble from utils.tool_parser import parse_tool_call as _parse_tool_call from utils.workspace_descriptor import read_json_file -from models import Bubble, Composer, SchemaError +from models import Bubble, Composer, ParseWarningCollector, SchemaError from services.workspace_db import ( _build_composer_id_to_workspace_id, _collect_invalid_workspace_ids, @@ -83,6 +83,7 @@ def assemble_workspace_tabs( rules: list, ) -> tuple[dict, int]: """Build (payload, status) for GET /api/workspaces//tabs; status=404 if global storage is missing.""" + parse_warnings = ParseWarningCollector() response: dict = {"tabs": []} workspace_entries = _collect_workspace_entries(workspace_path) @@ -142,6 +143,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: "Skipping Bubble cursorDiskKV row with NULL value: key=%r", row["key"], ) + parse_warnings.record_bubble_skipped() continue try: parsed = json.loads(row["value"]) @@ -155,6 +157,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: payload_len, payload_fp, ) + parse_warnings.record_bubble_skipped() continue try: bubble_obj = Bubble.from_dict(parsed, bubble_id=bid) @@ -168,6 +171,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: bid, e, ) + parse_warnings.record_bubble_skipped() # Load codeBlockDiffs code_block_diff_map = load_code_block_diff_map(global_db) @@ -232,6 +236,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: "Skipping Composer cursorDiskKV row with NULL value: key=%r", row["key"], ) + parse_warnings.record_composer_skipped() continue try: parsed = json.loads(row["value"]) @@ -245,6 +250,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: payload_len, payload_fp, ) + parse_warnings.record_composer_skipped() continue try: composer = Composer.from_dict(parsed, composer_id=composer_id) @@ -257,6 +263,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: composer_id, e, ) + parse_warnings.record_composer_skipped() continue try: cd = composer.raw @@ -579,8 +586,9 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: composer_id, e, ) + parse_warnings.record_composer_skipped() # Sort tabs by timestamp descending (newest first) response["tabs"].sort(key=lambda t: t.get("timestamp") or 0, reverse=True) - return response, 200 + return parse_warnings.attach_to(response), 200 diff --git a/static/css/style.css b/static/css/style.css index 3fd7bb9..d911fdb 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -36,6 +36,9 @@ --success-bg: #052e16; --success-border: #166534; --danger-bg: #450a0a; + --warning-bg: #422006; + --warning-border: #854d0e; + --warning-text: #fcd34d; --danger-border: #991b1b; --code-bg: #1e1e1e; --spinner: #3b82f6; @@ -74,6 +77,9 @@ --success-bg: #f0fdf4; --success-border: #bbf7d0; --danger-bg: #fef2f2; + --warning-bg: #fffbeb; + --warning-border: #fcd34d; + --warning-text: #92400e; --danger-border: #fecaca; --code-bg: #f5f5f5; --spinner: #2563eb; @@ -249,6 +255,7 @@ h3 { font-size: 1.15rem; font-weight: 600; } .alert-info { background: var(--info-bg); border: 1px solid var(--info-border); color: var(--info-text); } .alert-success { background: var(--success-bg); border: 1px solid var(--success-border); color: var(--success); } .alert-danger { background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); } +.alert-warning { background: var(--warning-bg); border: 1px solid var(--warning-border); color: var(--warning-text); } /* ---------- Badges ---------- */ .badge { diff --git a/static/js/app.js b/static/js/app.js index 27688ba..6f8a3c9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -86,3 +86,48 @@ function formatDate(timestamp) { function sanitizeFilename(name) { return (name || '').replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 120); } + +/** + * Normalize GET /api/workspaces body: plain array (no warnings) or + * { projects, warnings } when parse failures occurred (issue #67). + */ +function normalizeWorkspacesResponse(body) { + if (Array.isArray(body)) { + return { projects: body, warnings: [] }; + } + if (body && typeof body === 'object') { + return { + projects: body.projects || [], + warnings: body.warnings || [], + }; + } + return { projects: [], warnings: [] }; +} + +/** Human-readable text from API ``warnings`` entries. */ +function formatParseWarnings(warnings) { + if (!warnings || !warnings.length) return ''; + return warnings.map(w => w.detail || `${w.count || 0} items could not be loaded`).join(' '); +} + +/** + * Show or replace an incomplete-results banner (issue #67). + * @param {string} containerId - element to prepend into + * @param {Array} warnings - API warnings array + */ +function showIncompleteResultsBanner(containerId, warnings) { + const container = document.getElementById(containerId); + if (!container || !warnings || !warnings.length) return; + + const existing = container.querySelector('.incomplete-results-banner'); + if (existing) existing.remove(); + + const text = formatParseWarnings(warnings); + const banner = document.createElement('div'); + banner.className = 'alert alert-warning incomplete-results-banner'; + banner.setAttribute('role', 'status'); + banner.textContent = text + ? `Some results may be incomplete: ${text}` + : 'Some results may be incomplete due to parse errors.'; + container.insertBefore(banner, container.firstChild); +} diff --git a/templates/index.html b/templates/index.html index 25ffa8d..99b360c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,6 +26,7 @@

Projects

Loading projects...

+