Skip to content
Open
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
20 changes: 20 additions & 0 deletions api/composers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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/<composer_id>")
def get_composer(composer_id: str) -> tuple[Response, int] | Response:
"""Fetch one composer by ID (GET /api/composers/<composer_id>).

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.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
workspace_path = resolve_workspace_path()

Expand Down
42 changes: 41 additions & 1 deletion api/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions api/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [<log summary>, ...]}`` where each summary has ``id``,
``title``, ``timestamp``, etc. 500 with ``{"error": ..., "logs": []}`` on
unexpected failure.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
workspace_path = resolve_workspace_path()
logs = []
Expand Down
13 changes: 13 additions & 0 deletions api/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
body = request.get_json(silent=True) or {}
markdown_text = body.get("markdown", "")
Expand All @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
55 changes: 51 additions & 4 deletions api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
warn_workspace_json_read,
)
from utils.workspace_descriptor import read_json_file
from services.workspace_resolver import (
infer_workspace_name_from_context,
lookup_workspace_display_name,
)
from services.workspace_resolver import infer_workspace_name_from_context

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1): it is the cleanup your own PR body lists as out of scope, so it contradicts "docstrings only, not function changes"
2): #118 is still open and remove this exact same import, so whichever merges first makes the other conflict on this line. Recommend pulling this hunk out of #119 and letting #118 own the import cleanup

from services.cli_tabs import get_cli_workspace_tabs
from services.workspace_listing import list_workspace_projects
from services.workspace_tabs import (
Expand Down Expand Up @@ -57,6 +54,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()
Expand All @@ -70,12 +75,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/<id>
# ---------------------------------------------------------------------------

@bp.route("/api/workspaces/<workspace_id>")
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:<project_id>``.

Returns:
Workspace JSON (id, name, path, folder, lastModified). 404 when not found;
500 on unexpected failure.
"""
try:
if workspace_id == "global":
return json_response({
Expand Down Expand Up @@ -144,12 +160,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/<id>/tabs
# ---------------------------------------------------------------------------

@bp.route("/api/workspaces/<workspace_id>/tabs")
def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response:
"""List conversation tabs for a workspace (GET /api/workspaces/<id>/tabs).

Args:
workspace_id: Storage folder name, ``global`` for unassigned chats, or
``cli:<project_id>``.
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.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if workspace_id.startswith("cli:"):
try:
return get_cli_workspace_tabs(workspace_id, exclusion_rules())
Expand All @@ -170,12 +202,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/<id>/tabs/<composer_id>
# ---------------------------------------------------------------------------

@bp.route("/api/workspaces/<workspace_id>/tabs/<composer_id>")
def get_workspace_tab(workspace_id: str, composer_id: str) -> tuple[Response, int] | Response:
"""Lazy-load one conversation tab (GET /api/workspaces/<id>/tabs/<composer_id>).

Args:
workspace_id: Storage folder name, ``global`` for unassigned chats, or
``cli:<project_id>`` (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:
Expand Down
12 changes: 12 additions & 0 deletions models/cli_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
raw = require_dict(raw, model="CliSessionMeta", field="meta")
latest = require_truthy(
raw.get("latestRootBlobId"),
Expand Down
35 changes: 35 additions & 0 deletions models/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions models/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions models/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading