Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,4 @@ def export_chats():
type(e).__name__,
exc_info=True,
)
return jsonify({"error": f"Export failed: {str(e)}"}), 500
return jsonify({"error": "Export failed"}), 500
2 changes: 1 addition & 1 deletion api/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def footer(self):
type(e).__name__,
exc_info=True,
)
return jsonify({"error": f"Failed to generate PDF: {str(e)}"}), 500
return jsonify({"error": "Failed to generate PDF"}), 500


def _render_code_block(pdf, code_text: str):
Expand Down
26 changes: 21 additions & 5 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -170,8 +171,14 @@ def search():
e,
type(e).__name__,
)
except (json.JSONDecodeError, ValueError):
pass
parse_warnings.record_bubble_skipped()
except (json.JSONDecodeError, TypeError, ValueError) as e:
_logger.warning(
"Failed to decode Bubble from bubbleId:%s: %s",
bid,
e,
)
parse_warnings.record_bubble_skipped()

# Search through composerData
composer_rows = conn.execute(
Expand All @@ -189,8 +196,15 @@ def search():
e,
type(e).__name__,
)
parse_warnings.record_composer_skipped()
continue
except (json.JSONDecodeError, TypeError, ValueError):
except (json.JSONDecodeError, TypeError, ValueError) as e:
_logger.warning(
"Failed to decode Composer from composerData:%s: %s",
composer_id,
e,
)
parse_warnings.record_composer_skipped()
continue
try:
cd = composer.raw
Expand Down Expand Up @@ -285,6 +299,7 @@ def search():
composer_id,
e,
)
parse_warnings.record_composer_processing_failure()

except Exception:
_logger.exception("Error searching global storage")
Expand Down Expand Up @@ -498,7 +513,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")
Expand Down
7 changes: 5 additions & 2 deletions api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ def list_workspaces():
try:
workspace_path = resolve_workspace_path()
rules = current_app.config.get("EXCLUSION_RULES") or []
projects = list_workspace_projects(workspace_path, rules)
return jsonify(projects)
projects, warnings = list_workspace_projects(workspace_path, rules)
payload: dict = {"projects": projects}
if warnings:
payload["warnings"] = warnings
return jsonify(payload)
except Exception:
_logger.exception("Failed to get workspaces")
return jsonify({"error": "Failed to get workspaces"}), 500
Expand Down
2 changes: 2 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
from models.export import ExportEntry
from models.workspace import Workspace

Expand All @@ -9,6 +10,7 @@
"CliSessionMeta",
"Composer",
"ExportEntry",
"ParseWarningCollector",
"SchemaError",
"Workspace",
"WorkspaceLocalComposer",
Expand Down
74 changes: 74 additions & 0 deletions models/parse_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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
composers_processing_failed: 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

def record_composer_processing_failure(self, count: int = 1) -> None:
"""Post-parse assembly failed; not a JSON/schema parse skip."""
if count > 0:
self.composers_processing_failed += count

@property
def has_warnings(self) -> bool:
return (
self.composers_skipped > 0
or self.bubbles_skipped > 0
or self.composers_processing_failed > 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"
),
})
if self.composers_processing_failed:
n = self.composers_processing_failed
noun = "conversation" if n == 1 else "conversations"
warnings.append({
"type": "processing_error",
"count": n,
"detail": (
f"{n} {noun} could not be fully assembled after parsing"
),
})
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
13 changes: 9 additions & 4 deletions services/workspace_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -84,13 +85,15 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_skipped()
continue
if not isinstance(parsed, dict):
_logger.warning(
"Failed to parse Composer from composerData:%s: expected object, got %s",
cid,
type(parsed).__name__,
)
parse_warnings.record_composer_skipped()
continue
try:
composer = Composer.from_dict(parsed, composer_id=cid)
Expand All @@ -100,6 +103,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_skipped()
continue
cd = composer.raw
try:
Expand Down Expand Up @@ -134,6 +138,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
cid,
e,
)
parse_warnings.record_composer_processing_failure()
except Exception as e:
_logger.error(
"Failed to load composer rows from global storage: %s",
Expand Down Expand Up @@ -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()
28 changes: 13 additions & 15 deletions services/workspace_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,6 +83,7 @@ def assemble_workspace_tabs(
rules: list,
) -> tuple[dict, int]:
"""Build (payload, status) for GET /api/workspaces/<id>/tabs; status=404 if global storage is missing."""
parse_warnings = ParseWarningCollector()
response: dict = {"tabs": []}

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

# Load bubbles
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"):
for row in _safe_fetchall(
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
" AND value IS NOT NULL"
):
parts = row["key"].split(":")
if len(parts) >= 3:
bid = parts[2]
if row["value"] is None:
_logger.warning(
"Skipping Bubble cursorDiskKV row with NULL value: key=%r",
row["key"],
)
continue
try:
parsed = json.loads(row["value"])

Expand All @@ -155,6 +153,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)
Expand All @@ -168,6 +167,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)
Expand Down Expand Up @@ -210,6 +210,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
# Get composer data entries with conversations
composer_rows = _safe_fetchall(
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
" AND value IS NOT NULL"
" AND value LIKE '%fullConversationHeadersOnly%'"
" AND value NOT LIKE '%fullConversationHeadersOnly\":[]%'"
)
Expand All @@ -227,12 +228,6 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:

for row in composer_rows:
composer_id = row["key"].split(":")[1]
if row["value"] is None:
_logger.warning(
"Skipping Composer cursorDiskKV row with NULL value: key=%r",
row["key"],
)
continue
try:
parsed = json.loads(row["value"])
except (json.JSONDecodeError, TypeError, ValueError) as e:
Expand All @@ -245,6 +240,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)
Expand All @@ -257,6 +253,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
composer_id,
e,
)
parse_warnings.record_composer_skipped()
continue
try:
cd = composer.raw
Expand Down Expand Up @@ -579,8 +576,9 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
composer_id,
e,
)
parse_warnings.record_composer_processing_failure()

# 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
7 changes: 7 additions & 0 deletions static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading