Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 15 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 @@ -122,6 +128,15 @@ def list_composers() -> tuple[Response, int] | Response:
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. 404 when not
found or schema drift blocks serving; 500 on unexpected failure.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
workspace_path = resolve_workspace_path()

Expand Down
22 changes: 22 additions & 0 deletions 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 @@ -98,6 +104,16 @@ def validate_path() -> tuple[Response, int] | Response:
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).

Body: ``{"path": "<workspaceStorage root>"}``. Path is 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 +143,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
6 changes: 6 additions & 0 deletions api/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ 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 array of log summary objects (id, title, timestamp, etc.). 500 on
unexpected failure.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
workspace_path = resolve_workspace_path()
logs = []
Expand Down
11 changes: 11 additions & 0 deletions api/pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ 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).

Body: ``{"markdown": "...", "title": "..."}``.

Returns:
``application/pdf`` attachment on success. 400/500 JSON errors 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 +62,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
8 changes: 8 additions & 0 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@

@bp.route("/api/search")
def search() -> tuple[Response, int] | Response:
"""Search chats, composers, and CLI sessions across Cursor storage.

Query params: ``q`` (required), ``type`` (``all`` | ``chat`` | ``composer``).

Returns:
JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
empty; 500 on unexpected failure.
"""
try:
query = request.args.get("q", "").strip()
search_type = request.args.get("type", "all")
Expand Down
37 changes: 37 additions & 0 deletions api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ def _request_nocache() -> bool:

@bp.route("/api/workspaces")
def list_workspaces() -> tuple[Response, int] | Response:
"""List workspace projects for the sidebar (GET /api/workspaces).

Honors ``?nocache=1`` to 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 @@ -76,6 +83,15 @@ def list_workspaces() -> tuple[Response, int] | Response:

@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 @@ -150,6 +166,17 @@ def get_workspace(workspace_id: str) -> tuple[Response, int] | Response:

@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 or ``cli:<project_id>``.

Query params: ``summary=1`` for lightweight tab headers only; ``nocache=1`` to
bypass cache on summary requests.

Returns:
Tabs payload from :func:`services.workspace_tabs` helpers. 500 on 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 @@ -176,6 +203,16 @@ def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response:

@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: IDE workspace folder name (CLI workspaces return 400).
composer_id: Composer UUID to load.

Returns:
Single-tab JSON from :func:`services.workspace_tabs.assemble_single_tab`.
400 for CLI workspaces; 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
11 changes: 11 additions & 0 deletions models/cli_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ 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 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