-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(api): split api/workspaces.py 1,407 → 142 LOC into services/ (closes #25) #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
cb28c16
refactor(api): split api/workspaces.py 1,407 → 142 LOC into services/…
timon0305 349a5e5
review: address CodeRabbit findings on PR #31 (5 fixes + tests)
timon0305 ff25de3
review: harden malformed-record paths across services (#31, round 2)
timon0305 de43d22
review: three more malformed-record + SQLite-error guards (#31, round 3)
timon0305 b111439
review: wrap cursorDiskKV reads in _safe_fetchall (#31, round 4)
timon0305 ca2d597
review: narrow excepts + drop duplicate queries / synthetic bubbles (…
timon0305 cb32fca
Merge origin/master into feat/split-api-workspaces-25
timon0305 9126a9a
fix(types): close mypy gate on services/ after post-#35 strict enforc…
timon0305 7472580
Merge origin/master into feat/split-api-workspaces-25
timon0305 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime | ||
|
|
||
| from flask import current_app, jsonify | ||
|
|
||
| from utils.cli_chat_reader import list_cli_projects, messages_to_bubbles, traverse_blobs | ||
| from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules | ||
| from utils.workspace_path import get_cli_chats_path | ||
|
|
||
|
|
||
| def _get_cli_workspace_tabs(workspace_id: str): | ||
| """Return tabs for a Cursor CLI project (workspace_id starts with "cli:").""" | ||
| try: | ||
| project_id = workspace_id[4:] | ||
| cli_projects = list_cli_projects(get_cli_chats_path()) | ||
| project = next( | ||
| ( | ||
| cp for cp in cli_projects | ||
| if isinstance(cp, dict) and cp.get("project_id") == project_id | ||
| ), | ||
| None, | ||
| ) | ||
| if project is None: | ||
| return jsonify({"error": "CLI project not found"}), 404 | ||
|
|
||
| rules = current_app.config.get("EXCLUSION_RULES") or [] | ||
| ws_name = project.get("workspace_name") or project_id[:12] | ||
| sessions = project.get("sessions") or [] | ||
| if not isinstance(sessions, list): | ||
| sessions = [] | ||
| tabs = [] | ||
|
|
||
| for session in sessions: | ||
| if not isinstance(session, dict): | ||
| continue | ||
| session_id = session.get("session_id") | ||
| if not session_id: | ||
| continue | ||
| meta = session.get("meta") or {} | ||
| created_ms: int = meta.get("createdAt") or int(datetime.now().timestamp() * 1000) | ||
| session_name = meta.get("name") or f"Session {session_id[:8]}" | ||
|
|
||
| try: | ||
| messages = traverse_blobs(session["db_path"]) | ||
| except Exception as e: | ||
| print(f"CLI: could not read session {session_id}: {e}") | ||
| continue | ||
|
|
||
| try: | ||
| bubbles = messages_to_bubbles(messages, created_ms) | ||
| except Exception as e: | ||
| print(f"CLI: could not convert session {session_id} to bubbles: {e}") | ||
| continue | ||
| if not bubbles: | ||
| continue | ||
|
|
||
| # Derive title from first user bubble when name is generic | ||
| title = session_name | ||
| if not title or title.startswith("New Agent"): | ||
| for b in bubbles: | ||
| if b["type"] == "user" and b.get("text"): | ||
| first_lines = [ln for ln in b["text"].split("\n") if ln.strip()] | ||
| if first_lines: | ||
| title = first_lines[0][:100] | ||
| if len(title) == 100: | ||
| title += "..." | ||
| break | ||
|
|
||
| searchable = build_searchable_text(project_name=ws_name, chat_title=title) | ||
| if is_excluded_by_rules(rules, searchable): | ||
| continue | ||
|
|
||
| # Aggregate metadata | ||
| total_tool_calls = 0 | ||
| tool_breakdown: dict = {} | ||
| for b in bubbles: | ||
| tcs = (b.get("metadata") or {}).get("toolCalls") or [] | ||
| total_tool_calls += len(tcs) | ||
| for tc in tcs: | ||
| tn = tc.get("name", "unknown") | ||
| tool_breakdown[tn] = tool_breakdown.get(tn, 0) + 1 | ||
|
|
||
| tab_meta: dict | None = None | ||
| if total_tool_calls or tool_breakdown: | ||
| tab_meta = {"totalToolCalls": total_tool_calls or None} | ||
| if tool_breakdown: | ||
| tab_meta["toolBreakdown"] = tool_breakdown | ||
|
|
||
| tab = { | ||
| "id": session_id, | ||
| "title": title, | ||
| "timestamp": created_ms, | ||
| "bubbles": [ | ||
| { | ||
| "type": b["type"], | ||
| "text": b.get("text", ""), | ||
| "timestamp": b.get("timestamp", created_ms), | ||
| **({"metadata": b["metadata"]} if b.get("metadata") else {}), | ||
| } | ||
| for b in bubbles | ||
| ], | ||
| "source": "cli", | ||
| } | ||
| if tab_meta: | ||
| tab_meta_clean = {k: v for k, v in tab_meta.items() if v is not None} | ||
| if tab_meta_clean: | ||
| tab["metadata"] = tab_meta_clean | ||
|
|
||
| tabs.append(tab) | ||
|
|
||
| tabs.sort(key=lambda t: t.get("timestamp") or 0, reverse=True) | ||
| return jsonify({"tabs": tabs}) | ||
|
|
||
| except Exception as e: | ||
| print(f"Failed to get CLI workspace tabs: {e}") | ||
| return jsonify({"error": "Failed to get CLI workspace tabs"}), 500 |
|
timon0305 marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import sqlite3 | ||
| from contextlib import closing, contextmanager | ||
| from pathlib import Path | ||
|
|
||
| from utils.path_helpers import get_workspace_folder_paths | ||
| from utils.workspace_descriptor import _read_json_file | ||
|
|
||
|
|
||
| def _collect_workspace_entries(workspace_path: str) -> list[dict]: | ||
| """Scan workspace directory and return entries with workspace.json.""" | ||
| entries = [] | ||
| try: | ||
| for name in os.listdir(workspace_path): | ||
| full = os.path.join(workspace_path, name) | ||
| if os.path.isdir(full): | ||
| wj = os.path.join(full, "workspace.json") | ||
| if os.path.isfile(wj): | ||
| entries.append({"name": name, "workspaceJsonPath": wj}) | ||
| except OSError: | ||
| # workspace_path missing / not readable / not a directory — return what | ||
| # we have so far. OSError covers FileNotFoundError, PermissionError, | ||
| # and NotADirectoryError. | ||
| pass | ||
| return entries | ||
|
|
||
|
|
||
| def _collect_invalid_workspace_ids(workspace_entries: list[dict]) -> set[str]: | ||
| """Workspace IDs whose descriptors have no resolvable folder paths.""" | ||
| invalid: set[str] = set() | ||
| for entry in workspace_entries: | ||
| try: | ||
| wd = _read_json_file(entry["workspaceJsonPath"]) | ||
| folders = get_workspace_folder_paths(wd) | ||
| if not folders: | ||
| invalid.add(entry["name"]) | ||
| except (OSError, ValueError, KeyError, TypeError): | ||
| # OSError: workspace.json unreadable. ValueError covers | ||
| # json.JSONDecodeError. KeyError / TypeError: malformed entry | ||
| # dict. Any of these mean we can't resolve folders → mark invalid, | ||
| # matching the pre-narrowing behaviour. | ||
| invalid.add(entry["name"]) | ||
| return invalid | ||
|
|
||
|
|
||
| def _build_composer_id_to_workspace_id(workspace_path: str, workspace_entries: list) -> dict: | ||
| """Build mapping: composerId -> workspaceId from per-workspace state.vscdb.""" | ||
| mapping: dict = {} | ||
| for entry in workspace_entries: | ||
| db_path = os.path.join(workspace_path, entry["name"], "state.vscdb") | ||
| if not os.path.isfile(db_path): | ||
| continue | ||
| # closing() guarantees .close() on scope exit (issue #17). | ||
| # Path.as_uri() percent-encodes reserved chars; ``f"file:{path}"`` | ||
| # breaks sqlite URI parsing on paths with spaces, ``#``, etc. | ||
| db_uri = Path(db_path).resolve().as_uri() + "?mode=ro" | ||
| row: tuple | None = None | ||
| try: | ||
| with closing(sqlite3.connect(db_uri, uri=True)) as conn: | ||
| row = conn.execute( | ||
| "SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'" | ||
| ).fetchone() | ||
| except sqlite3.Error: | ||
| continue | ||
| if not (row and row[0]): | ||
| continue | ||
| try: | ||
| data = json.loads(row[0]) | ||
| except (json.JSONDecodeError, ValueError): | ||
| continue | ||
| all_composers = data.get("allComposers") if isinstance(data, dict) else None | ||
| if not isinstance(all_composers, list): | ||
| continue | ||
| for c in all_composers: | ||
| if not isinstance(c, dict): | ||
| continue | ||
| cid = c.get("composerId") | ||
| if cid: | ||
| mapping[cid] = entry["name"] | ||
| return mapping | ||
|
|
||
|
|
||
| @contextmanager | ||
| def _open_global_db(workspace_path: str): | ||
| """Yield (conn, path) for the global-storage SQLite db (read-only); (None, path) if the file is missing.""" | ||
| global_db_path = os.path.join(workspace_path, "..", "globalStorage", "state.vscdb") | ||
| global_db_path = os.path.normpath(global_db_path) | ||
| if not os.path.isfile(global_db_path): | ||
| yield None, global_db_path | ||
| return | ||
| db_uri = Path(global_db_path).resolve().as_uri() + "?mode=ro" | ||
| try: | ||
| conn = sqlite3.connect(db_uri, uri=True) | ||
| except sqlite3.Error: | ||
| yield None, global_db_path | ||
| return | ||
| conn.row_factory = sqlite3.Row | ||
| try: | ||
| yield conn, global_db_path | ||
| finally: | ||
| conn.close() | ||
|
timon0305 marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.