From 183a4a84c534fa19fa6a4590e4e1a952c8c6ebf6 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 4 Jun 2026 10:26:38 -0400 Subject: [PATCH 1/4] feat: initial implementation --- api/export_api.py | 27 ++++--- scripts/export.py | 32 ++++---- services/workspace_context.py | 133 ++++++++++++++++++++++++++++++++ services/workspace_listing.py | 21 ++--- services/workspace_tabs.py | 50 +++++++----- tests/test_workspace_context.py | 102 ++++++++++++++++++++++++ 6 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 services/workspace_context.py create mode 100644 tests/test_workspace_context.py diff --git a/api/export_api.py b/api/export_api.py index 1186a13..1517b85 100644 --- a/api/export_api.py +++ b/api/export_api.py @@ -22,17 +22,15 @@ from utils.text_extract import extract_text_from_bubble, slug from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules from utils.cursor_md_exporter import cursor_ide_chat_to_markdown +from services.workspace_context import ( + enrich_workspace_context_from_global_db, + resolve_workspace_context, +) from services.workspace_db import ( - build_composer_id_to_workspace_id, - collect_workspace_entries, - load_bubble_map, load_code_block_diff_map, open_global_db, ) -from services.workspace_resolver import ( - create_project_name_to_workspace_id_map, - lookup_workspace_display_name, -) +from services.workspace_resolver import lookup_workspace_display_name bp = Blueprint("export_api", __name__) _logger = logging.getLogger(__name__) @@ -102,9 +100,13 @@ def export_chats(): last_export_ms = to_epoch_ms(ts_str) # ── Workspace scanning via service layer ────────────────────────────── - workspace_entries = collect_workspace_entries(workspace_path) - composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries) - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) + ctx = resolve_workspace_context( + workspace_path, + include_invalid_workspace_ids=False, + include_workspace_path_map=False, + ) + workspace_entries = ctx.workspace_entries + composer_id_to_ws = ctx.composer_id_to_workspace_id # Build display-name and slug maps ws_id_to_slug: dict[str, str] = {} @@ -124,7 +126,10 @@ def export_chats(): if global_db is None: return jsonify({"error": "Cursor global storage not found"}), 404 - bubble_map = load_bubble_map(global_db) + ctx = enrich_workspace_context_from_global_db( + ctx, global_db, populate_bubble_map=True, + ) + bubble_map = ctx.bubble_map code_block_diff_map = load_code_block_diff_map(global_db) try: diff --git a/scripts/export.py b/scripts/export.py index 371cf81..932a0ce 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -53,18 +53,15 @@ cursor_ide_chat_to_markdown, ) from models import ExportEntry, SchemaError # noqa: E402 +from services.workspace_context import ( # noqa: E402 + enrich_workspace_context_from_global_db, + resolve_workspace_context, +) from services.workspace_db import ( # noqa: E402 - build_composer_id_to_workspace_id, - collect_invalid_workspace_ids, - collect_workspace_entries, - load_bubble_map, load_code_block_diff_map, - load_project_layouts_map, open_global_db, ) from services.workspace_resolver import ( # noqa: E402 - create_project_name_to_workspace_id_map, - create_workspace_path_to_id_map, determine_project_for_conversation, infer_invalid_workspace_aliases, lookup_workspace_display_name, @@ -203,11 +200,12 @@ def main(): ) # ── Workspace scanning via service layer ────────────────────────────────── - workspace_entries = collect_workspace_entries(workspace_path) - invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries) - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) - workspace_path_map = create_workspace_path_to_id_map(workspace_entries) - composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, workspace_entries) + ctx = resolve_workspace_context(workspace_path) + workspace_entries = ctx.workspace_entries + invalid_workspace_ids = ctx.invalid_workspace_ids + project_name_map = ctx.project_name_to_workspace_id + workspace_path_map = ctx.workspace_path_to_id + composer_id_to_ws = ctx.composer_id_to_workspace_id # Build display-name and slug maps from workspace entries. # Entries whose workspace.json cannot be resolved are omitted so the @@ -235,8 +233,14 @@ def main(): global_db_path, ) else: - project_layouts_map = load_project_layouts_map(global_db) - bubble_map = load_bubble_map(global_db) + ctx = enrich_workspace_context_from_global_db( + ctx, + global_db, + populate_project_layouts=True, + populate_bubble_map=True, + ) + project_layouts_map = ctx.project_layouts_map + bubble_map = ctx.bubble_map code_block_diff_map = load_code_block_diff_map(global_db) try: diff --git a/services/workspace_context.py b/services/workspace_context.py new file mode 100644 index 0000000..054f309 --- /dev/null +++ b/services/workspace_context.py @@ -0,0 +1,133 @@ +"""Workspace determination ceremony — single orchestrator for shared maps.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +from services.workspace_db import ( + build_composer_id_to_workspace_id, + build_composer_id_to_workspace_id_cached, + collect_invalid_workspace_ids, + collect_workspace_entries, + load_bubble_map, + load_project_layouts_map, +) +from services.workspace_resolver import ( + create_project_name_to_workspace_id_map, + create_workspace_path_to_id_map, +) + +if TYPE_CHECKING: + import sqlite3 + + +@dataclass(frozen=True) +class WorkspaceContext: + """Precomputed workspace-resolution maps for conversation assignment.""" + + workspace_path: str + workspace_entries: list[dict] + invalid_workspace_ids: set[str] + composer_id_to_workspace_id: dict[str, str] + project_name_to_workspace_id: dict[str, str] + workspace_path_to_id: dict[str, str] + project_layouts_map: dict[str, list] + bubble_map: dict[str, dict] + + +def resolve_workspace_context( + workspace_path: str, + *, + workspace_entries: list[dict] | None = None, + rules: list | None = None, + nocache: bool = False, + use_composer_cache: bool = False, + include_invalid_workspace_ids: bool = True, + include_workspace_path_map: bool = True, + global_db: sqlite3.Connection | None = None, + populate_project_layouts: bool = False, + populate_bubble_map: bool = False, +) -> WorkspaceContext: + """Run the workspace-determination ceremony and return a typed context. + + Always resolves ``workspace_entries`` (when not supplied), composer and + project-name maps. Optional pieces are controlled by flags so lightweight + consumers (e.g. HTTP export) can omit unused maps. + + Args: + workspace_path: Cursor ``workspaceStorage`` root. + workspace_entries: Pre-collected entries; when ``None``, scanned from disk. + rules: Exclusion rules; required when ``use_composer_cache`` is ``True``. + nocache: Skip the mtime-keyed composer-map disk cache. + use_composer_cache: Use :func:`build_composer_id_to_workspace_id_cached`. + include_invalid_workspace_ids: When ``False``, ``invalid_workspace_ids`` is empty. + include_workspace_path_map: When ``False``, ``workspace_path_to_id`` is empty. + global_db: Open global ``state.vscdb`` connection for optional KV loads. + populate_project_layouts: Populate ``project_layouts_map`` from *global_db*. + populate_bubble_map: Populate ``bubble_map`` from *global_db*. + + Returns: + :class:`WorkspaceContext` with all requested maps populated. + """ + entries = ( + workspace_entries + if workspace_entries is not None + else collect_workspace_entries(workspace_path) + ) + invalid_ids = ( + collect_invalid_workspace_ids(entries) + if include_invalid_workspace_ids + else set() + ) + project_name_map = create_project_name_to_workspace_id_map(entries) + workspace_path_map = ( + create_workspace_path_to_id_map(entries) + if include_workspace_path_map + else {} + ) + if use_composer_cache: + if rules is None: + raise ValueError("rules is required when use_composer_cache=True") + composer_id_to_ws = build_composer_id_to_workspace_id_cached( + workspace_path, entries, rules, nocache=nocache, + ) + else: + composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, entries) + + project_layouts: dict[str, list] = {} + bubble_map: dict[str, dict] = {} + if global_db is not None: + if populate_project_layouts: + project_layouts = load_project_layouts_map(global_db) + if populate_bubble_map: + bubble_map = load_bubble_map(global_db) + + return WorkspaceContext( + workspace_path=workspace_path, + workspace_entries=entries, + invalid_workspace_ids=invalid_ids, + composer_id_to_workspace_id=composer_id_to_ws, + project_name_to_workspace_id=project_name_map, + workspace_path_to_id=workspace_path_map, + project_layouts_map=project_layouts, + bubble_map=bubble_map, + ) + + +def enrich_workspace_context_from_global_db( + ctx: WorkspaceContext, + global_db: sqlite3.Connection, + *, + populate_project_layouts: bool = False, + populate_bubble_map: bool = False, +) -> WorkspaceContext: + """Return *ctx* with global KV maps loaded from an open global DB connection.""" + updates: dict = {} + if populate_project_layouts: + updates["project_layouts_map"] = load_project_layouts_map(global_db) + if populate_bubble_map: + updates["bubble_map"] = load_bubble_map(global_db) + if not updates: + return ctx + return replace(ctx, **updates) diff --git a/services/workspace_listing.py b/services/workspace_listing.py index 890b8c1..1a6a17b 100644 --- a/services/workspace_listing.py +++ b/services/workspace_listing.py @@ -24,10 +24,9 @@ nocache_enabled, set_cached_projects, ) +from services.workspace_context import resolve_workspace_context from services.workspace_db import ( COMPOSER_ROWS_WITH_HEADERS_SQL, - build_composer_id_to_workspace_id_cached, - collect_invalid_workspace_ids, collect_workspace_entries, global_storage_db_path, load_project_layouts_for_composer, @@ -36,8 +35,6 @@ ) from utils.workspace_path import get_cli_chats_path from services.workspace_resolver import ( - create_project_name_to_workspace_id_map, - create_workspace_path_to_id_map, determine_project_for_conversation, infer_invalid_workspace_aliases, infer_workspace_name_from_context, @@ -125,13 +122,17 @@ def _build_workspace_projects_uncached( nocache: bool, ) -> tuple[list[dict], list[dict]]: parse_warnings = ParseWarningCollector() - invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries) - - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) - workspace_path_map = create_workspace_path_to_id_map(workspace_entries) - composer_id_to_ws = build_composer_id_to_workspace_id_cached( - workspace_path, workspace_entries, rules, nocache=nocache, + ctx = resolve_workspace_context( + workspace_path, + workspace_entries=workspace_entries, + rules=rules, + nocache=nocache, + use_composer_cache=True, ) + invalid_workspace_ids = ctx.invalid_workspace_ids + project_name_map = ctx.project_name_to_workspace_id + workspace_path_map = ctx.workspace_path_to_id + composer_id_to_ws = ctx.composer_id_to_workspace_id conversation_map: dict[str, list] = {} diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index b6073d3..d28a21e 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -27,10 +27,9 @@ nocache_enabled, set_cached_tab_summaries, ) +from services.workspace_context import resolve_workspace_context from services.workspace_db import ( COMPOSER_ROWS_WITH_HEADERS_SQL, - build_composer_id_to_workspace_id_cached, - collect_invalid_workspace_ids, collect_workspace_entries, global_storage_db_path, load_bubbles_for_composer, @@ -43,8 +42,6 @@ ) from utils.workspace_path import get_cli_chats_path from services.workspace_resolver import ( - create_project_name_to_workspace_id_map, - create_workspace_path_to_id_map, determine_project_for_conversation, infer_invalid_workspace_aliases, lookup_workspace_display_name, @@ -489,12 +486,17 @@ def _build_workspace_tab_summaries_uncached( parse_warnings = ParseWarningCollector() response: dict = {"tabs": []} - invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries) - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) - workspace_path_map = create_workspace_path_to_id_map(workspace_entries) - composer_id_to_ws = build_composer_id_to_workspace_id_cached( - workspace_path, workspace_entries, rules, nocache=nocache, + ctx = resolve_workspace_context( + workspace_path, + workspace_entries=workspace_entries, + rules=rules, + nocache=nocache, + use_composer_cache=True, ) + invalid_workspace_ids = ctx.invalid_workspace_ids + project_name_map = ctx.project_name_to_workspace_id + workspace_path_map = ctx.workspace_path_to_id + composer_id_to_ws = ctx.composer_id_to_workspace_id matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries) with open_global_db(workspace_path) as (global_db, _): @@ -635,13 +637,16 @@ def assemble_single_tab( """ parse_warnings = ParseWarningCollector() - workspace_entries = collect_workspace_entries(workspace_path) - invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries) - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) - workspace_path_map = create_workspace_path_to_id_map(workspace_entries) - composer_id_to_ws = build_composer_id_to_workspace_id_cached( - workspace_path, workspace_entries, rules, + ctx = resolve_workspace_context( + workspace_path, + rules=rules, + use_composer_cache=True, ) + workspace_entries = ctx.workspace_entries + invalid_workspace_ids = ctx.invalid_workspace_ids + project_name_map = ctx.project_name_to_workspace_id + workspace_path_map = ctx.workspace_path_to_id + composer_id_to_ws = ctx.composer_id_to_workspace_id matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries) with open_global_db(workspace_path) as (global_db, _): @@ -768,13 +773,16 @@ def assemble_workspace_tabs( parse_warnings = ParseWarningCollector() response: dict = {"tabs": []} - workspace_entries = collect_workspace_entries(workspace_path) - invalid_workspace_ids = collect_invalid_workspace_ids(workspace_entries) - project_name_map = create_project_name_to_workspace_id_map(workspace_entries) - workspace_path_map = create_workspace_path_to_id_map(workspace_entries) - composer_id_to_ws = build_composer_id_to_workspace_id_cached( - workspace_path, workspace_entries, rules, + ctx = resolve_workspace_context( + workspace_path, + rules=rules, + use_composer_cache=True, ) + workspace_entries = ctx.workspace_entries + invalid_workspace_ids = ctx.invalid_workspace_ids + project_name_map = ctx.project_name_to_workspace_id + workspace_path_map = ctx.workspace_path_to_id + composer_id_to_ws = ctx.composer_id_to_workspace_id matching_ws_ids = _build_matching_ws_ids(workspace_id, workspace_path, workspace_entries) bubble_map: dict[str, dict] = {} diff --git a/tests/test_workspace_context.py b/tests/test_workspace_context.py new file mode 100644 index 0000000..cf7659b --- /dev/null +++ b/tests/test_workspace_context.py @@ -0,0 +1,102 @@ +"""Tests for workspace determination ceremony orchestrator (issue #91).""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import tempfile +from unittest.mock import patch + +import pytest + +from services.workspace_context import ( + WorkspaceContext, + enrich_workspace_context_from_global_db, + resolve_workspace_context, +) + + +def _make_workspace_root(tmp: str) -> str: + ws_root = os.path.join(tmp, "workspaceStorage") + os.makedirs(ws_root) + ws_id = "abc123workspace" + ws_dir = os.path.join(ws_root, ws_id) + os.makedirs(ws_dir) + wj = { + "folders": [{"path": "file:///tmp/myproject"}], + } + with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f: + json.dump(wj, f) + return ws_root + + +def test_resolve_workspace_context_minimal_flags(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + ctx = resolve_workspace_context( + ws_root, + include_invalid_workspace_ids=False, + include_workspace_path_map=False, + ) + assert isinstance(ctx, WorkspaceContext) + assert len(ctx.workspace_entries) == 1 + assert ctx.invalid_workspace_ids == set() + assert ctx.workspace_path_to_id == {} + assert ctx.project_layouts_map == {} + assert ctx.bubble_map == {} + + +def test_resolve_workspace_context_full_workspace_maps(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + ctx = resolve_workspace_context(ws_root) + assert len(ctx.workspace_entries) == 1 + assert "myproject" in ctx.project_name_to_workspace_id + assert ctx.project_name_to_workspace_id["myproject"] == "abc123workspace" + assert len(ctx.workspace_path_to_id) >= 1 + + +def test_resolve_workspace_context_requires_rules_for_cache(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + with pytest.raises(ValueError, match="rules is required"): + resolve_workspace_context(ws_root, use_composer_cache=True) + + +def test_resolve_workspace_context_accepts_pre_collected_entries(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + entries = [{"name": "x", "workspaceJsonPath": "/fake/workspace.json"}] + with patch( + "services.workspace_context.collect_workspace_entries", + return_value=entries, + ) as mock_collect: + ctx = resolve_workspace_context(ws_root, workspace_entries=entries) + mock_collect.assert_not_called() + assert ctx.workspace_entries is entries + + +def test_enrich_workspace_context_from_global_db(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + ctx = resolve_workspace_context(ws_root) + db_path = os.path.join(tmp, "global.vscdb") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute( + "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)" + ) + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?, ?)", + ("bubbleId:cid1:bid1", json.dumps({"type": 1, "text": "hi"})), + ) + conn.commit() + try: + enriched = enrich_workspace_context_from_global_db( + ctx, conn, populate_bubble_map=True, + ) + finally: + conn.close() + assert enriched.bubble_map.get("bid1") is not None + assert ctx.bubble_map == {} From 34309760b32b9a611a54c2f328f3afc4284e8492 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 4 Jun 2026 11:56:45 -0400 Subject: [PATCH 2/4] fix: type check error --- services/workspace_tabs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index d28a21e..eeea2e5 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -848,20 +848,20 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list: if len(parts) < 2: continue chat_id = parts[1] - ctx = _loads_kv_value_logged(row["key"], row["value"]) - if not isinstance(ctx, dict): + mrc = _loads_kv_value_logged(row["key"], row["value"]) + if not isinstance(mrc, dict): continue # Per-bubble context map (needs the contextId at parts[2]) if len(parts) >= 3: context_id = parts[2] message_request_context_map.setdefault(chat_id, []).append({ - **ctx, + **mrc, "contextId": context_id, }) # Project-layout map (root paths used by the resolver) - layouts = ctx.get("projectLayouts") + layouts = mrc.get("projectLayouts") if isinstance(layouts, list): project_layouts_map.setdefault(chat_id, []) for layout in layouts: From 14ddb4331c00062130d38f27010675098e430ad3 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 4 Jun 2026 15:39:59 -0400 Subject: [PATCH 3/4] fix: reviewer's comments --- api/export_api.py | 17 +--- services/workspace_context.py | 139 +++++++++++++++----------------- services/workspace_listing.py | 7 +- services/workspace_tabs.py | 19 ++--- tests/test_workspace_context.py | 109 ++++++++++++++++++++----- 5 files changed, 167 insertions(+), 124 deletions(-) diff --git a/api/export_api.py b/api/export_api.py index 1517b85..4feb412 100644 --- a/api/export_api.py +++ b/api/export_api.py @@ -22,11 +22,9 @@ from utils.text_extract import extract_text_from_bubble, slug from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules from utils.cursor_md_exporter import cursor_ide_chat_to_markdown -from services.workspace_context import ( - enrich_workspace_context_from_global_db, - resolve_workspace_context, -) +from services.workspace_context import resolve_workspace_context_minimal from services.workspace_db import ( + load_bubble_map, load_code_block_diff_map, open_global_db, ) @@ -100,11 +98,7 @@ def export_chats(): last_export_ms = to_epoch_ms(ts_str) # ── Workspace scanning via service layer ────────────────────────────── - ctx = resolve_workspace_context( - workspace_path, - include_invalid_workspace_ids=False, - include_workspace_path_map=False, - ) + ctx = resolve_workspace_context_minimal(workspace_path) workspace_entries = ctx.workspace_entries composer_id_to_ws = ctx.composer_id_to_workspace_id @@ -126,10 +120,7 @@ def export_chats(): if global_db is None: return jsonify({"error": "Cursor global storage not found"}), 404 - ctx = enrich_workspace_context_from_global_db( - ctx, global_db, populate_bubble_map=True, - ) - bubble_map = ctx.bubble_map + bubble_map = load_bubble_map(global_db) code_block_diff_map = load_code_block_diff_map(global_db) try: diff --git a/services/workspace_context.py b/services/workspace_context.py index 054f309..56c51ab 100644 --- a/services/workspace_context.py +++ b/services/workspace_context.py @@ -2,8 +2,8 @@ from __future__ import annotations +import sqlite3 from dataclasses import dataclass, replace -from typing import TYPE_CHECKING from services.workspace_db import ( build_composer_id_to_workspace_id, @@ -18,15 +18,11 @@ create_workspace_path_to_id_map, ) -if TYPE_CHECKING: - import sqlite3 - @dataclass(frozen=True) class WorkspaceContext: """Precomputed workspace-resolution maps for conversation assignment.""" - workspace_path: str workspace_entries: list[dict] invalid_workspace_ids: set[str] composer_id_to_workspace_id: dict[str, str] @@ -36,82 +32,79 @@ class WorkspaceContext: bubble_map: dict[str, dict] +def _entries( + workspace_path: str, + workspace_entries: list[dict] | None, +) -> list[dict]: + if workspace_entries is not None: + return workspace_entries + return collect_workspace_entries(workspace_path) + + +def _assemble_context( + entries: list[dict], + *, + invalid_workspace_ids: set[str], + workspace_path_to_id: dict[str, str], + composer_id_to_workspace_id: dict[str, str], +) -> WorkspaceContext: + return WorkspaceContext( + workspace_entries=entries, + invalid_workspace_ids=invalid_workspace_ids, + composer_id_to_workspace_id=composer_id_to_workspace_id, + project_name_to_workspace_id=create_project_name_to_workspace_id_map(entries), + workspace_path_to_id=workspace_path_to_id, + project_layouts_map={}, + bubble_map={}, + ) + + def resolve_workspace_context( workspace_path: str, *, workspace_entries: list[dict] | None = None, - rules: list | None = None, - nocache: bool = False, - use_composer_cache: bool = False, - include_invalid_workspace_ids: bool = True, - include_workspace_path_map: bool = True, - global_db: sqlite3.Connection | None = None, - populate_project_layouts: bool = False, - populate_bubble_map: bool = False, ) -> WorkspaceContext: - """Run the workspace-determination ceremony and return a typed context. - - Always resolves ``workspace_entries`` (when not supplied), composer and - project-name maps. Optional pieces are controlled by flags so lightweight - consumers (e.g. HTTP export) can omit unused maps. - - Args: - workspace_path: Cursor ``workspaceStorage`` root. - workspace_entries: Pre-collected entries; when ``None``, scanned from disk. - rules: Exclusion rules; required when ``use_composer_cache`` is ``True``. - nocache: Skip the mtime-keyed composer-map disk cache. - use_composer_cache: Use :func:`build_composer_id_to_workspace_id_cached`. - include_invalid_workspace_ids: When ``False``, ``invalid_workspace_ids`` is empty. - include_workspace_path_map: When ``False``, ``workspace_path_to_id`` is empty. - global_db: Open global ``state.vscdb`` connection for optional KV loads. - populate_project_layouts: Populate ``project_layouts_map`` from *global_db*. - populate_bubble_map: Populate ``bubble_map`` from *global_db*. - - Returns: - :class:`WorkspaceContext` with all requested maps populated. - """ - entries = ( - workspace_entries - if workspace_entries is not None - else collect_workspace_entries(workspace_path) + """Full workspace maps with an uncached composer→workspace scan (CLI export).""" + entries = _entries(workspace_path, workspace_entries) + return _assemble_context( + entries, + invalid_workspace_ids=collect_invalid_workspace_ids(entries), + workspace_path_to_id=create_workspace_path_to_id_map(entries), + composer_id_to_workspace_id=build_composer_id_to_workspace_id( + workspace_path, entries, + ), ) - invalid_ids = ( - collect_invalid_workspace_ids(entries) - if include_invalid_workspace_ids - else set() - ) - project_name_map = create_project_name_to_workspace_id_map(entries) - workspace_path_map = ( - create_workspace_path_to_id_map(entries) - if include_workspace_path_map - else {} - ) - if use_composer_cache: - if rules is None: - raise ValueError("rules is required when use_composer_cache=True") - composer_id_to_ws = build_composer_id_to_workspace_id_cached( + + +def resolve_workspace_context_cached( + workspace_path: str, + rules: list, + *, + workspace_entries: list[dict] | None = None, + nocache: bool = False, +) -> WorkspaceContext: + """Full workspace maps with a mtime-keyed composer map (listing / tabs).""" + entries = _entries(workspace_path, workspace_entries) + return _assemble_context( + entries, + invalid_workspace_ids=collect_invalid_workspace_ids(entries), + workspace_path_to_id=create_workspace_path_to_id_map(entries), + composer_id_to_workspace_id=build_composer_id_to_workspace_id_cached( workspace_path, entries, rules, nocache=nocache, - ) - else: - composer_id_to_ws = build_composer_id_to_workspace_id(workspace_path, entries) - - project_layouts: dict[str, list] = {} - bubble_map: dict[str, dict] = {} - if global_db is not None: - if populate_project_layouts: - project_layouts = load_project_layouts_map(global_db) - if populate_bubble_map: - bubble_map = load_bubble_map(global_db) + ), + ) - return WorkspaceContext( - workspace_path=workspace_path, - workspace_entries=entries, - invalid_workspace_ids=invalid_ids, - composer_id_to_workspace_id=composer_id_to_ws, - project_name_to_workspace_id=project_name_map, - workspace_path_to_id=workspace_path_map, - project_layouts_map=project_layouts, - bubble_map=bubble_map, + +def resolve_workspace_context_minimal(workspace_path: str) -> WorkspaceContext: + """Entries, project-name, and composer maps only (HTTP export).""" + entries = collect_workspace_entries(workspace_path) + return _assemble_context( + entries, + invalid_workspace_ids=set(), + workspace_path_to_id={}, + composer_id_to_workspace_id=build_composer_id_to_workspace_id( + workspace_path, entries, + ), ) diff --git a/services/workspace_listing.py b/services/workspace_listing.py index 1a6a17b..9f8128e 100644 --- a/services/workspace_listing.py +++ b/services/workspace_listing.py @@ -24,7 +24,7 @@ nocache_enabled, set_cached_projects, ) -from services.workspace_context import resolve_workspace_context +from services.workspace_context import resolve_workspace_context_cached from services.workspace_db import ( COMPOSER_ROWS_WITH_HEADERS_SQL, collect_workspace_entries, @@ -122,12 +122,11 @@ def _build_workspace_projects_uncached( nocache: bool, ) -> tuple[list[dict], list[dict]]: parse_warnings = ParseWarningCollector() - ctx = resolve_workspace_context( + ctx = resolve_workspace_context_cached( workspace_path, + rules, workspace_entries=workspace_entries, - rules=rules, nocache=nocache, - use_composer_cache=True, ) invalid_workspace_ids = ctx.invalid_workspace_ids project_name_map = ctx.project_name_to_workspace_id diff --git a/services/workspace_tabs.py b/services/workspace_tabs.py index eeea2e5..03503b2 100644 --- a/services/workspace_tabs.py +++ b/services/workspace_tabs.py @@ -27,7 +27,7 @@ nocache_enabled, set_cached_tab_summaries, ) -from services.workspace_context import resolve_workspace_context +from services.workspace_context import resolve_workspace_context_cached from services.workspace_db import ( COMPOSER_ROWS_WITH_HEADERS_SQL, collect_workspace_entries, @@ -486,12 +486,11 @@ def _build_workspace_tab_summaries_uncached( parse_warnings = ParseWarningCollector() response: dict = {"tabs": []} - ctx = resolve_workspace_context( + ctx = resolve_workspace_context_cached( workspace_path, + rules, workspace_entries=workspace_entries, - rules=rules, nocache=nocache, - use_composer_cache=True, ) invalid_workspace_ids = ctx.invalid_workspace_ids project_name_map = ctx.project_name_to_workspace_id @@ -637,11 +636,7 @@ def assemble_single_tab( """ parse_warnings = ParseWarningCollector() - ctx = resolve_workspace_context( - workspace_path, - rules=rules, - use_composer_cache=True, - ) + ctx = resolve_workspace_context_cached(workspace_path, rules) workspace_entries = ctx.workspace_entries invalid_workspace_ids = ctx.invalid_workspace_ids project_name_map = ctx.project_name_to_workspace_id @@ -773,11 +768,7 @@ def assemble_workspace_tabs( parse_warnings = ParseWarningCollector() response: dict = {"tabs": []} - ctx = resolve_workspace_context( - workspace_path, - rules=rules, - use_composer_cache=True, - ) + ctx = resolve_workspace_context_cached(workspace_path, rules) workspace_entries = ctx.workspace_entries invalid_workspace_ids = ctx.invalid_workspace_ids project_name_map = ctx.project_name_to_workspace_id diff --git a/tests/test_workspace_context.py b/tests/test_workspace_context.py index cf7659b..f8868d1 100644 --- a/tests/test_workspace_context.py +++ b/tests/test_workspace_context.py @@ -8,12 +8,12 @@ import tempfile from unittest.mock import patch -import pytest - from services.workspace_context import ( WorkspaceContext, enrich_workspace_context_from_global_db, resolve_workspace_context, + resolve_workspace_context_cached, + resolve_workspace_context_minimal, ) @@ -31,14 +31,26 @@ def _make_workspace_root(tmp: str) -> str: return ws_root -def test_resolve_workspace_context_minimal_flags(): +def _add_workspace_without_folders(ws_root: str, ws_id: str) -> None: + """Workspace folder with empty ``folders`` — treated as invalid by collect_invalid_workspace_ids.""" + ws_dir = os.path.join(ws_root, ws_id) + os.makedirs(ws_dir) + with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f: + json.dump({"folders": []}, f) + + +def _open_global_db(tmp: str) -> sqlite3.Connection: + db_path = os.path.join(tmp, "global.vscdb") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)") + return conn + + +def test_resolve_workspace_context_minimal(): with tempfile.TemporaryDirectory() as tmp: ws_root = _make_workspace_root(tmp) - ctx = resolve_workspace_context( - ws_root, - include_invalid_workspace_ids=False, - include_workspace_path_map=False, - ) + ctx = resolve_workspace_context_minimal(ws_root) assert isinstance(ctx, WorkspaceContext) assert len(ctx.workspace_entries) == 1 assert ctx.invalid_workspace_ids == set() @@ -57,11 +69,52 @@ def test_resolve_workspace_context_full_workspace_maps(): assert len(ctx.workspace_path_to_id) >= 1 -def test_resolve_workspace_context_requires_rules_for_cache(): +def test_resolve_workspace_context_populates_invalid_workspace_ids(): with tempfile.TemporaryDirectory() as tmp: ws_root = _make_workspace_root(tmp) - with pytest.raises(ValueError, match="rules is required"): - resolve_workspace_context(ws_root, use_composer_cache=True) + _add_workspace_without_folders(ws_root, "invalidws") + ctx = resolve_workspace_context(ws_root) + assert ctx.invalid_workspace_ids == {"invalidws"} + assert "abc123workspace" not in ctx.invalid_workspace_ids + + +def test_resolve_workspace_context_cached_uses_cached_composer_map(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + rules = [["token"]] + cached_map = {"composer-abc": "abc123workspace"} + with ( + patch( + "services.workspace_context.build_composer_id_to_workspace_id_cached", + return_value=cached_map, + ) as mock_cached, + patch( + "services.workspace_context.build_composer_id_to_workspace_id", + ) as mock_scan, + ): + ctx = resolve_workspace_context_cached(ws_root, rules) + mock_cached.assert_called_once_with( + ws_root, ctx.workspace_entries, rules, nocache=False, + ) + mock_scan.assert_not_called() + assert ctx.composer_id_to_workspace_id == cached_map + assert ctx.invalid_workspace_ids == set() + assert len(ctx.workspace_path_to_id) >= 1 + + +def test_resolve_workspace_context_cached_accepts_pre_collected_entries(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + entries = [{"name": "x", "workspaceJsonPath": "/fake/workspace.json"}] + with patch( + "services.workspace_context.collect_workspace_entries", + return_value=entries, + ) as mock_collect: + ctx = resolve_workspace_context_cached( + ws_root, [], workspace_entries=entries, + ) + mock_collect.assert_not_called() + assert ctx.workspace_entries is entries def test_resolve_workspace_context_accepts_pre_collected_entries(): @@ -77,16 +130,10 @@ def test_resolve_workspace_context_accepts_pre_collected_entries(): assert ctx.workspace_entries is entries -def test_enrich_workspace_context_from_global_db(): +def test_enrich_populates_bubble_map(): with tempfile.TemporaryDirectory() as tmp: - ws_root = _make_workspace_root(tmp) - ctx = resolve_workspace_context(ws_root) - db_path = os.path.join(tmp, "global.vscdb") - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - conn.execute( - "CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)" - ) + ctx = resolve_workspace_context(_make_workspace_root(tmp)) + conn = _open_global_db(tmp) conn.execute( "INSERT INTO cursorDiskKV VALUES (?, ?)", ("bubbleId:cid1:bid1", json.dumps({"type": 1, "text": "hi"})), @@ -100,3 +147,25 @@ def test_enrich_workspace_context_from_global_db(): conn.close() assert enriched.bubble_map.get("bid1") is not None assert ctx.bubble_map == {} + + +def test_enrich_populates_project_layouts_map(): + with tempfile.TemporaryDirectory() as tmp: + ctx = resolve_workspace_context(_make_workspace_root(tmp)) + conn = _open_global_db(tmp) + mrc = { + "projectLayouts": [json.dumps({"rootPath": "/tmp/myproject"})], + } + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?, ?)", + ("messageRequestContext:composer-1:ctx1", json.dumps(mrc)), + ) + conn.commit() + try: + enriched = enrich_workspace_context_from_global_db( + ctx, conn, populate_project_layouts=True, + ) + finally: + conn.close() + assert enriched.project_layouts_map["composer-1"] == ["/tmp/myproject"] + assert ctx.project_layouts_map == {} From d14ad1f3e4ade805349411952309e919629d1717 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Thu, 4 Jun 2026 16:18:12 -0400 Subject: [PATCH 4/4] fix: nit pack comments --- services/workspace_context.py | 15 ++++++-- tests/test_workspace_context.py | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/services/workspace_context.py b/services/workspace_context.py index 56c51ab..d05580d 100644 --- a/services/workspace_context.py +++ b/services/workspace_context.py @@ -95,9 +95,18 @@ def resolve_workspace_context_cached( ) -def resolve_workspace_context_minimal(workspace_path: str) -> WorkspaceContext: - """Entries, project-name, and composer maps only (HTTP export).""" - entries = collect_workspace_entries(workspace_path) +def resolve_workspace_context_minimal( + workspace_path: str, + *, + workspace_entries: list[dict] | None = None, +) -> WorkspaceContext: + """Entries, project-name, and composer maps only (HTTP export). + + Args: + workspace_path: Cursor ``workspaceStorage`` root. + workspace_entries: Pre-collected entries; when ``None``, scanned from disk. + """ + entries = _entries(workspace_path, workspace_entries) return _assemble_context( entries, invalid_workspace_ids=set(), diff --git a/tests/test_workspace_context.py b/tests/test_workspace_context.py index f8868d1..f7225e5 100644 --- a/tests/test_workspace_context.py +++ b/tests/test_workspace_context.py @@ -78,6 +78,18 @@ def test_resolve_workspace_context_populates_invalid_workspace_ids(): assert "abc123workspace" not in ctx.invalid_workspace_ids +def test_resolve_workspace_context_cached_passes_nocache(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + with patch( + "services.workspace_context.build_composer_id_to_workspace_id_cached", + return_value={}, + ) as mock_cached: + resolve_workspace_context_cached(ws_root, [], nocache=True) + mock_cached.assert_called_once() + assert mock_cached.call_args.kwargs["nocache"] is True + + def test_resolve_workspace_context_cached_uses_cached_composer_map(): with tempfile.TemporaryDirectory() as tmp: ws_root = _make_workspace_root(tmp) @@ -130,6 +142,19 @@ def test_resolve_workspace_context_accepts_pre_collected_entries(): assert ctx.workspace_entries is entries +def test_resolve_workspace_context_minimal_accepts_pre_collected_entries(): + with tempfile.TemporaryDirectory() as tmp: + ws_root = _make_workspace_root(tmp) + entries = [{"name": "x", "workspaceJsonPath": "/fake/workspace.json"}] + with patch( + "services.workspace_context.collect_workspace_entries", + return_value=entries, + ) as mock_collect: + ctx = resolve_workspace_context_minimal(ws_root, workspace_entries=entries) + mock_collect.assert_not_called() + assert ctx.workspace_entries is entries + + def test_enrich_populates_bubble_map(): with tempfile.TemporaryDirectory() as tmp: ctx = resolve_workspace_context(_make_workspace_root(tmp)) @@ -169,3 +194,46 @@ def test_enrich_populates_project_layouts_map(): conn.close() assert enriched.project_layouts_map["composer-1"] == ["/tmp/myproject"] assert ctx.project_layouts_map == {} + + +def test_enrich_populates_both_global_maps(): + with tempfile.TemporaryDirectory() as tmp: + ctx = resolve_workspace_context(_make_workspace_root(tmp)) + conn = _open_global_db(tmp) + mrc = { + "projectLayouts": [json.dumps({"rootPath": "/tmp/myproject"})], + } + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?, ?)", + ("messageRequestContext:composer-1:ctx1", json.dumps(mrc)), + ) + conn.execute( + "INSERT INTO cursorDiskKV VALUES (?, ?)", + ("bubbleId:cid1:bid1", json.dumps({"type": 1, "text": "hi"})), + ) + conn.commit() + try: + enriched = enrich_workspace_context_from_global_db( + ctx, + conn, + populate_project_layouts=True, + populate_bubble_map=True, + ) + finally: + conn.close() + assert enriched.project_layouts_map["composer-1"] == ["/tmp/myproject"] + assert enriched.bubble_map.get("bid1") is not None + assert ctx.project_layouts_map == {} + assert ctx.bubble_map == {} + + +def test_enrich_with_no_flags_returns_unchanged_context(): + with tempfile.TemporaryDirectory() as tmp: + ctx = resolve_workspace_context(_make_workspace_root(tmp)) + conn = _open_global_db(tmp) + conn.commit() + try: + result = enrich_workspace_context_from_global_db(ctx, conn) + finally: + conn.close() + assert result is ctx