diff --git a/api/composers.py b/api/composers.py index a790bba..1ada343 100644 --- a/api/composers.py +++ b/api/composers.py @@ -30,6 +30,12 @@ def _read_json_file(path: str) -> Any: @bp.route("/api/composers") def list_composers() -> tuple[Response, int] | Response: + """List all composers across workspace databases (GET /api/composers). + + Returns: + JSON array of composer dicts sorted by ``lastUpdatedAt`` descending. + 500 on failure. + """ try: workspace_path = resolve_workspace_path() composers = [] @@ -120,8 +126,22 @@ def list_composers() -> tuple[Response, int] | Response: except Exception: _logger.exception("Failed to get composers") return json_response({"error": "Failed to get composers"}, 500) + + @bp.route("/api/composers/") def get_composer(composer_id: str) -> tuple[Response, int] | Response: + """Fetch one composer by ID (GET /api/composers/). + + Args: + composer_id: Composer UUID. + + Returns: + Composer JSON from per-workspace storage or global fallback. Per-workspace + schema drift is logged and skipped before global fallback is attempted. + 404 when the composer is absent from both stores (``{"error": "Composer not found"}``) + or when the global row fails validation (``{"error": "Composer schema drift"}``). + 500 on unexpected failure. + """ try: workspace_path = resolve_workspace_path() diff --git a/api/config_api.py b/api/config_api.py index 87e7711..5f190de 100644 --- a/api/config_api.py +++ b/api/config_api.py @@ -24,6 +24,12 @@ @bp.route("/api/detect-environment") def detect_environment() -> Response: + """Detect runtime OS, WSL, and SSH-remote context (GET /api/detect-environment). + + Returns: + JSON with ``os``, ``isWSL``, and ``isRemote``. Falls back to safe defaults + on detection errors. + """ try: is_wsl = False is_remote = bool( @@ -59,7 +65,22 @@ def detect_environment() -> Response: @bp.route("/api/validate-path", methods=["POST"]) def validate_path() -> tuple[Response, int] | Response: - """Same path rules as POST /api/set-workspace: realpath, markers (issue #15).""" + """Validate a workspace storage path without persisting it (POST /api/validate-path). + + Uses the same rules as :func:`set_workspace` (realpath, Cursor markers; issue #15). + + Args: + path: Workspace storage root from JSON body ``{"path": "..."}``. + + Returns: + JSON with ``valid``, ``workspaceCount``, and canonical ``path`` on success. + ``valid`` is ``false`` when the path fails validation or contains no + workspace folders with ``state.vscdb``. Invalid JSON body returns + ``{"valid": false, "error": "invalid JSON body", "workspaceCount": 0}``. + Path validation errors return ``{"valid": false, "error": "...", "workspaceCount": 0}``. + 500 with ``{"valid": false, "error": "Failed to validate path"}`` on + unexpected failure. + """ try: body = request.get_json(silent=True) or {} if not isinstance(body, dict): @@ -96,8 +117,21 @@ def validate_path() -> tuple[Response, int] | Response: exc_info=True, ) return json_response({"valid": False, "error": "Failed to validate path"}, 500) + + @bp.route("/api/set-workspace", methods=["POST"]) def set_workspace() -> tuple[Response, int] | Response: + """Persist a validated workspace storage path (POST /api/set-workspace). + + Args: + path: Workspace storage root from JSON body ``{"path": "..."}``. + Canonicalized via :func:`utils.path_validation.validate_workspace_path` + before storing the thread-safe module override. + + Returns: + ``{"success": true, "path": "..."}`` on success. 400 for invalid path or + body; 500 when override storage fails. + """ # Reject non-dict JSON bodies (array / string / number / null). Without # this, get_json returns the value directly, the truthy fallback `or {}` # is bypassed, and `body.get("path", "")` raises AttributeError — which @@ -127,6 +161,12 @@ def set_workspace() -> tuple[Response, int] | Response: @bp.route("/api/get-username") def get_username() -> Response: + """Return the detected Windows/WSL username (GET /api/get-username). + + Returns: + JSON ``{"username": "..."}``. Falls back to ``YOUR_USERNAME`` when + detection fails. + """ try: username = "YOUR_USERNAME" diff --git a/api/logs.py b/api/logs.py index a6bd804..3a0e3c8 100644 --- a/api/logs.py +++ b/api/logs.py @@ -30,6 +30,13 @@ def _extract_chat_id_from_bubble_key(key: str) -> str | None: @bp.route("/api/logs") def get_logs() -> tuple[Response, int] | Response: + """List chat logs from global and per-workspace storage (GET /api/logs). + + Returns: + JSON ``{"logs": [, ...]}`` where each summary has ``id``, + ``title``, ``timestamp``, etc. 500 with ``{"error": ..., "logs": []}`` on + unexpected failure. + """ try: workspace_path = resolve_workspace_path() logs = [] diff --git a/api/pdf.py b/api/pdf.py index b2d107a..765f1f0 100644 --- a/api/pdf.py +++ b/api/pdf.py @@ -47,6 +47,15 @@ def _safe_text(text: str) -> str: @bp.route("/api/generate-pdf", methods=["POST"]) def generate_pdf() -> tuple[Response, int] | Response: + """Render markdown chat content as a PDF download (POST /api/generate-pdf). + + Args: + markdown: Markdown source text (optional; defaults to ``""``). + title: Document title (optional; defaults to ``"Chat"``). + + Returns: + ``application/pdf`` attachment on success. 500 JSON error on failure. + """ try: body = request.get_json(silent=True) or {} markdown_text = body.get("markdown", "") @@ -55,10 +64,14 @@ def generate_pdf() -> tuple[Response, int] | Response: from fpdf import FPDF class PDFDoc(FPDF): + """Minimal fpdf2 document with page numbers in the footer.""" + def header(self) -> None: + """No running header (title is rendered in body).""" pass def footer(self) -> None: + """Render centered page ``n/total`` at the bottom.""" self.set_y(-15) self.set_font("Helvetica", "I", 8) self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C") diff --git a/api/search.py b/api/search.py index da25d42..1073d46 100644 --- a/api/search.py +++ b/api/search.py @@ -25,6 +25,16 @@ @bp.route("/api/search") def search() -> tuple[Response, int] | Response: + """Search chats, composers, and CLI sessions across Cursor storage. + + Args: + q: Search query string (required; 400 when empty). + type: Filter scope — ``all`` (default), ``chat``, or ``composer``. + + Returns: + JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is + empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure. + """ try: query = request.args.get("q", "").strip() search_type = request.args.get("type", "all") diff --git a/api/workspaces.py b/api/workspaces.py index 1c3ce87..9771f7b 100644 --- a/api/workspaces.py +++ b/api/workspaces.py @@ -57,6 +57,14 @@ def _request_nocache() -> bool: @bp.route("/api/workspaces") def list_workspaces() -> tuple[Response, int] | Response: + """List workspace projects for the sidebar (GET /api/workspaces). + + Args: + nocache: When ``1`` or ``true``, bypass the summary disk cache. + + Returns: + JSON with ``projects`` and optional ``warnings``. 500 on failure. + """ try: workspace_path = resolve_workspace_path() rules = exclusion_rules() @@ -70,12 +78,23 @@ def list_workspaces() -> tuple[Response, int] | Response: except Exception: _logger.exception("Failed to get workspaces") return json_response({"error": "Failed to get workspaces"}, 500) + + # --------------------------------------------------------------------------- # GET /api/workspaces/ # --------------------------------------------------------------------------- @bp.route("/api/workspaces/") def get_workspace(workspace_id: str) -> tuple[Response, int] | Response: + """Return metadata for one workspace, global bucket, or CLI project. + + Args: + workspace_id: Storage folder name, ``global``, or ``cli:``. + + Returns: + Workspace JSON (id, name, path, folder, lastModified). 404 when not found; + 500 on unexpected failure. + """ try: if workspace_id == "global": return json_response({ @@ -144,12 +163,28 @@ def get_workspace(workspace_id: str) -> tuple[Response, int] | Response: except Exception: _logger.exception("Failed to get workspace") return json_response({"error": "Failed to get workspace"}, 500) + + # --------------------------------------------------------------------------- # GET /api/workspaces//tabs # --------------------------------------------------------------------------- @bp.route("/api/workspaces//tabs") def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response: + """List conversation tabs for a workspace (GET /api/workspaces//tabs). + + Args: + workspace_id: Storage folder name, ``global`` for unassigned chats, or + ``cli:``. + summary: When ``1`` or ``true``, return lightweight tab headers only. + nocache: When ``1`` or ``true``, bypass cache on summary requests. + + Returns: + Tabs payload from :func:`services.workspace_tabs` helpers (typically + ``{"tabs": [...]}`` with optional ``warnings``). May return 200 with an + empty ``tabs`` list when upstream SQLite reads fail silently; 404 when + global storage is missing; 500 on unexpected route-level failure. + """ if workspace_id.startswith("cli:"): try: return get_cli_workspace_tabs(workspace_id, exclusion_rules()) @@ -170,12 +205,27 @@ def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response: except Exception: _logger.exception("Failed to get workspace tabs") return json_response({"error": "Failed to get workspace tabs"}, 500) + + # --------------------------------------------------------------------------- # GET /api/workspaces//tabs/ # --------------------------------------------------------------------------- @bp.route("/api/workspaces//tabs/") def get_workspace_tab(workspace_id: str, composer_id: str) -> tuple[Response, int] | Response: + """Lazy-load one conversation tab (GET /api/workspaces//tabs/). + + Args: + workspace_id: Storage folder name, ``global`` for unassigned chats, or + ``cli:`` (CLI workspaces return 400). + composer_id: Composer UUID to load. + + Returns: + Single-tab JSON from :func:`services.workspace_tabs.assemble_single_tab` + (typically ``{"tab": {...}}`` with optional ``warnings``). 400 for CLI + workspaces; 404 when global storage is missing, the composer is not found, + or it is not assigned to *workspace_id*; 500 on unexpected failure. + """ if workspace_id.startswith("cli:"): return json_response({"error": "Per-tab lazy load is not supported for CLI workspaces"}, 400) try: diff --git a/models/cli_session.py b/models/cli_session.py index a2e5196..ed8eb78 100644 --- a/models/cli_session.py +++ b/models/cli_session.py @@ -17,6 +17,18 @@ class CliSessionMeta: @classmethod def from_dict(cls, raw: dict[str, Any]) -> "CliSessionMeta": + """Parse CLI session ``meta`` JSON into a validated descriptor. + + Args: + raw: Decoded meta object from a CLI chat session. + + Returns: + Validated :class:`CliSessionMeta`. + + Raises: + SchemaError: When ``latestRootBlobId`` is missing, falsey (e.g. + empty string or ``None``), or not a string. + """ raw = require_dict(raw, model="CliSessionMeta", field="meta") latest = require_truthy( raw.get("latestRootBlobId"), diff --git a/models/conversation.py b/models/conversation.py index 98cabbb..495940f 100644 --- a/models/conversation.py +++ b/models/conversation.py @@ -30,6 +30,18 @@ class Composer: @classmethod def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer": + """Parse a global ``composerData`` row into a validated composer. + + Args: + raw: Decoded JSON object from cursorDiskKV. + composer_id: Composer UUID from the storage key. + + Returns: + Validated :class:`Composer` with required headers and timestamps. + + Raises: + SchemaError: When required fields are missing or malformed. + """ raw = require_dict(raw, model="Composer", field="composerData") require_non_empty_str(composer_id, model="Composer", field="composerId") require_key(raw, "fullConversationHeadersOnly", model="Composer") @@ -164,6 +176,17 @@ class WorkspaceLocalComposer: @classmethod def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer": + """Parse one ``allComposers`` entry from per-workspace state. + + Args: + raw: Composer summary dict from ``composer.composerData``. + + Returns: + Validated local composer row. + + Raises: + SchemaError: When ``composerId`` is missing or invalid. + """ raw = require_dict(raw, model="WorkspaceLocalComposer", field="composer") composer_id = require_non_empty_str_field( raw, "composerId", model="WorkspaceLocalComposer" @@ -187,6 +210,18 @@ class Bubble: @classmethod def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble": + """Parse one ``bubbleId:*`` KV value into a validated bubble. + + Args: + raw: Decoded bubble JSON (``bubble_id`` comes from the key, not value). + bubble_id: Bubble UUID from the storage key suffix. + + Returns: + Validated :class:`Bubble`. + + Raises: + SchemaError: When the payload or *bubble_id* is invalid. + """ raw = require_dict(raw, model="Bubble", field="bubble") require_non_empty_str(bubble_id, model="Bubble", field="bubbleId") return cls(bubble_id=bubble_id, raw=raw) diff --git a/models/export.py b/models/export.py index 50f7421..859dcb7 100644 --- a/models/export.py +++ b/models/export.py @@ -31,6 +31,17 @@ class ExportEntry: @classmethod def from_dict(cls, raw: dict[str, Any]) -> "ExportEntry": + """Parse one manifest.jsonl row into a validated export entry. + + Args: + raw: Decoded JSON object for a single manifest line. + + Returns: + Validated :class:`ExportEntry`. + + Raises: + SchemaError: When ``log_id``, ``title``, or ``workspace`` are missing. + """ raw = require_dict(raw, model="ExportEntry", field="entry") require_non_empty_str_fields( raw, diff --git a/models/workspace.py b/models/workspace.py index 8d9cdd9..8338b11 100644 --- a/models/workspace.py +++ b/models/workspace.py @@ -16,6 +16,18 @@ class Workspace: @classmethod def from_dict(cls, raw: dict[str, Any], *, workspace_id: str) -> "Workspace": + """Parse ``workspace.json`` into a validated workspace descriptor. + + Args: + raw: Decoded workspace.json object. + workspace_id: Workspace storage folder name. + + Returns: + Validated :class:`Workspace` (``folder`` may be ``None`` for CLI-only). + + Raises: + SchemaError: When required fields are missing or malformed. + """ raw = require_dict(raw, model="Workspace", field="workspace.json") require_non_empty_str(workspace_id, model="Workspace", field="workspaceId") folder = require_optional_str(raw.get("folder"), model="Workspace", field="folder") diff --git a/services/summary_cache.py b/services/summary_cache.py index 076f13b..298d2bd 100644 --- a/services/summary_cache.py +++ b/services/summary_cache.py @@ -26,6 +26,15 @@ def nocache_enabled(*, request_nocache: bool = False) -> bool: + """Return whether summary-cache reads should be bypassed. + + Args: + request_nocache: True when the HTTP request included ``?nocache=1``. + + Returns: + True when bypass is requested or ``CURSOR_CHAT_BROWSER_NOCACHE`` is set + to ``"1"``, ``"true"``, or ``"yes"`` (case-insensitive). + """ if request_nocache: return True return os.environ.get("CURSOR_CHAT_BROWSER_NOCACHE", "").strip().lower() in ( @@ -132,6 +141,15 @@ def _write_cache_file(path: Path | str, payload: dict[str, Any]) -> None: def get_cached_projects( fingerprint: dict[str, Any], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]] | None: + """Load cached workspace project list when the fingerprint matches. + + Args: + fingerprint: Storage mtime/rules digest from + :func:`fingerprint_workspace_storage`. + + Returns: + ``(projects, warnings)`` on hit, else ``None``. + """ data = _read_cache_file(PROJECTS_CACHE_FILE) if not data: return None @@ -151,6 +169,13 @@ def set_cached_projects( projects: list[dict[str, Any]], warnings: list[dict[str, Any]], ) -> None: + """Write workspace project list and warnings to the disk cache. + + Args: + fingerprint: Invalidation fingerprint paired with the payload. + projects: Sidebar project dicts. + warnings: Parse warnings emitted while building *projects*. + """ _write_cache_file( PROJECTS_CACHE_FILE, { @@ -164,6 +189,14 @@ def set_cached_projects( def get_cached_composer_id_to_ws( fingerprint: dict[str, Any], ) -> dict[str, str] | None: + """Load cached composer-id → workspace-id map when the fingerprint matches. + + Args: + fingerprint: Storage mtime/rules digest. + + Returns: + Mapping on hit, else ``None``. + """ data = _read_cache_file(COMPOSER_MAP_CACHE_FILE) if not data: return None @@ -179,6 +212,12 @@ def set_cached_composer_id_to_ws( fingerprint: dict[str, Any], mapping: dict[str, str], ) -> None: + """Persist composer-id → workspace-id map under *fingerprint*. + + Args: + fingerprint: Invalidation fingerprint paired with *mapping*. + mapping: Composer UUID to workspace folder name. + """ _write_cache_file( COMPOSER_MAP_CACHE_FILE, { @@ -197,6 +236,15 @@ def get_cached_tab_summaries( fingerprint: dict[str, Any], workspace_id: str, ) -> tuple[dict[str, Any], int] | None: + """Load cached tab-summary response for one workspace when fingerprint matches. + + Args: + fingerprint: Storage mtime/rules digest. + workspace_id: Workspace folder name the payload belongs to. + + Returns: + ``(payload, status)`` on hit, else ``None``. + """ data = _read_cache_file(_tab_summaries_path(workspace_id)) if not data: return None @@ -217,6 +265,14 @@ def set_cached_tab_summaries( payload: dict[str, Any], status: int, ) -> None: + """Persist tab-summary API payload for one workspace. + + Args: + fingerprint: Invalidation fingerprint paired with the response. + workspace_id: Workspace folder name. + payload: JSON body returned to clients. + status: HTTP status code paired with *payload*. + """ _write_cache_file( _tab_summaries_path(workspace_id), { diff --git a/utils/display_bubble.py b/utils/display_bubble.py index 6efb34e..5dbec81 100644 --- a/utils/display_bubble.py +++ b/utils/display_bubble.py @@ -130,10 +130,16 @@ def build_display_bubble_from_storage( def display_bubble_metadata(bubble: DisplayBubble) -> BubbleMetadata: + """Return metadata dict from a display bubble (empty dict when absent).""" return bubble.get("metadata") or {} def display_bubble_tool_calls(bubble: DisplayBubble) -> list[dict[str, Any]]: + """Return tool-call entries from a display bubble's metadata. + + Returns: + List copied from ``metadata.toolCalls``, or ``[]`` when absent. + """ return list(display_bubble_metadata(bubble).get("toolCalls") or []) diff --git a/utils/workspace_path.py b/utils/workspace_path.py index a4cdb03..5d9b364 100644 --- a/utils/workspace_path.py +++ b/utils/workspace_path.py @@ -19,12 +19,23 @@ def set_workspace_path_override(path: str | None) -> None: + """Set the thread-safe module-level workspace path override. + + Args: + path: Canonical workspace storage root from POST /api/set-workspace, or + ``None`` to clear the override. + """ global _workspace_path_override with _workspace_path_lock: _workspace_path_override = path def get_workspace_path_override() -> str | None: + """Return the current module-level workspace path override, if any. + + Returns: + Canonical path set via :func:`set_workspace_path_override`, else ``None``. + """ with _workspace_path_lock: return _workspace_path_override