Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ python app.py

Open <http://localhost:3000> in your browser.

## Tests

Run the full suite from the repository root (install `requirements.txt` first):

```bash
python -m unittest discover tests -v
```

Run a single module, for example:

```bash
python -m unittest tests.test_cli_args -v
```

## CLI Export

Export chat history to Markdown without starting the web server. Running with no arguments exports **everything** (all chats + composer logs) as a zip archive into the current directory.
Expand Down
33 changes: 17 additions & 16 deletions api/composers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import os
import sqlite3
from contextlib import closing

from flask import Blueprint, jsonify

Expand Down Expand Up @@ -45,11 +46,11 @@ def list_composers():
pass

try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
conn.close()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()

if row and row[0]:
data = json.loads(row[0])
Expand Down Expand Up @@ -86,11 +87,11 @@ def get_composer(composer_id):
continue

try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
conn.close()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()

if row and row[0]:
data = json.loads(row[0])
Expand All @@ -104,12 +105,12 @@ def get_composer(composer_id):
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
if os.path.isfile(global_db_path):
try:
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
row = conn.execute(
"SELECT value FROM cursorDiskKV WHERE key = ?",
(f"composerData:{composer_id}",),
).fetchone()
conn.close()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)) as conn:
row = conn.execute(
"SELECT value FROM cursorDiskKV WHERE key = ?",
(f"composerData:{composer_id}",),
).fetchone()

if row and row[0]:
raw = row[0] if isinstance(row[0], str) else row[0].decode("utf-8")
Expand Down
22 changes: 15 additions & 7 deletions api/export_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import sqlite3
import sys
import zipfile
from contextlib import closing
from datetime import datetime
from pathlib import Path

Expand Down Expand Up @@ -78,6 +79,10 @@ def export_chats():
application startup; an app restart is required to pick up changes to the
exclusion rules file.
"""
# Outer try/finally guarantees the global-storage connection is closed
# on every exit path including unexpected exceptions (issue #17). Keeps
# the existing function body shape; just ensures cleanup.
conn = None
try:
body = request.get_json(silent=True) or {}
since = "last" if body.get("since") == "last" else "all"
Expand Down Expand Up @@ -131,17 +136,17 @@ def export_chats():
if not os.path.isfile(db_path):
continue
try:
wconn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
row = wconn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as wconn:
row = wconn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
if row and row[0]:
data = json.loads(row[0])
for c in (data.get("allComposers") or []):
cid = c.get("composerId") if isinstance(c, dict) else None
if cid:
composer_id_to_ws[cid] = entry["name"]
wconn.close()
except Exception:
pass

Expand Down Expand Up @@ -402,8 +407,6 @@ def export_chats():
except Exception as e:
print(f"Error processing composer {composer_id} for export: {e}")

conn.close()

count = len(exported)
if count == 0:
return jsonify({"error": "No conversations to export" + (
Expand Down Expand Up @@ -436,3 +439,8 @@ def export_chats():
import traceback
traceback.print_exc()
return jsonify({"error": f"Export failed: {str(e)}"}), 500
finally:
# Guaranteed close — fires on success, exception, AND on any
# in-body return that doesn't go through except (issue #17).
if conn is not None:
conn.close()
82 changes: 41 additions & 41 deletions api/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import re
import sqlite3
from contextlib import closing
from datetime import datetime

from flask import Blueprint, jsonify
Expand All @@ -32,9 +33,10 @@ def get_logs():
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
if os.path.isfile(global_db_path):
try:
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").fetchall()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'").fetchall()

chat_map: dict[str, list] = {}
for row in rows:
Expand Down Expand Up @@ -67,7 +69,6 @@ def get_logs():
"type": "chat",
"messageCount": len(bubbles),
})
conn.close()
except Exception as e:
print(f"Error reading global storage: {e}")

Expand All @@ -91,43 +92,42 @@ def get_logs():
pass

try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)

# Chat logs
chat_row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata'"
).fetchone()
if chat_row and chat_row[0]:
data = json.loads(chat_row[0])
tabs = data.get("tabs") or []
for tab in tabs:
logs.append({
"id": tab.get("id", ""),
"workspaceId": name,
"workspaceFolder": workspace_folder,
"title": tab.get("title") or f"Chat {(tab.get('id') or '')[:8]}",
"timestamp": tab.get("timestamp", 0),
"type": "chat",
"messageCount": len(tab.get("bubbles") or []),
})

# Composer logs
comp_row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
if comp_row and comp_row[0]:
data = json.loads(comp_row[0])
for c in (data.get("allComposers") or []):
logs.append({
"id": c.get("composerId", ""),
"workspaceId": name,
"workspaceFolder": workspace_folder,
"title": c.get("text") or f"Composer {(c.get('composerId') or '')[:8]}",
"timestamp": to_epoch_ms(c.get("lastUpdatedAt")) or to_epoch_ms(c.get("createdAt")) or 0,
"type": "composer",
"messageCount": len(c.get("conversation") or []),
})
conn.close()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn:
# Chat logs
chat_row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'workbench.panel.aichat.view.aichat.chatdata'"
).fetchone()
if chat_row and chat_row[0]:
data = json.loads(chat_row[0])
tabs = data.get("tabs") or []
for tab in tabs:
logs.append({
"id": tab.get("id", ""),
"workspaceId": name,
"workspaceFolder": workspace_folder,
"title": tab.get("title") or f"Chat {(tab.get('id') or '')[:8]}",
"timestamp": tab.get("timestamp", 0),
"type": "chat",
"messageCount": len(tab.get("bubbles") or []),
})

# Composer logs
comp_row = conn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
if comp_row and comp_row[0]:
data = json.loads(comp_row[0])
for c in (data.get("allComposers") or []):
logs.append({
"id": c.get("composerId", ""),
"workspaceId": name,
"workspaceFolder": workspace_folder,
"title": c.get("text") or f"Composer {(c.get('composerId') or '')[:8]}",
"timestamp": to_epoch_ms(c.get("lastUpdatedAt")) or to_epoch_ms(c.get("createdAt")) or 0,
"type": "composer",
"messageCount": len(c.get("conversation") or []),
})
except Exception:
pass
except Exception:
Expand Down
26 changes: 19 additions & 7 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import re
import sqlite3
from contextlib import closing
from datetime import datetime
from urllib.parse import unquote as _url_unquote

Expand Down Expand Up @@ -83,6 +84,11 @@ def search():
# Search global cursorDiskKV (new Cursor format — primary source)
# ---------------------------------------------------------------
if os.path.isfile(global_db_path):
# try/finally guarantees .close() on every exit path including
# exception (issue #17). Equivalent to wrapping the body in
# `with closing(sqlite3.connect(...))`, without the 160-line
# indent shift over the search logic that follows.
conn = None
try:
conn = sqlite3.connect(f"file:{global_db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
Expand Down Expand Up @@ -117,10 +123,11 @@ def search():
if not os.path.isfile(db_path):
continue
try:
wconn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
row = wconn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
# closing() guarantees .close() on scope exit (issue #17).
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as wconn:
row = wconn.execute(
"SELECT value FROM ItemTable WHERE [key] = 'composer.composerData'"
).fetchone()
if row and row[0]:
data = json.loads(row[0])
all_composers = data.get("allComposers")
Expand All @@ -129,7 +136,6 @@ def search():
cid = c.get("composerId") if isinstance(c, dict) else None
if cid:
composer_id_to_ws[cid] = entry["name"]
wconn.close()
except Exception:
pass

Expand Down Expand Up @@ -244,9 +250,11 @@ def search():
except Exception:
pass

conn.close()
except Exception as e:
print(f"Error searching global storage: {e}")
finally:
if conn is not None:
conn.close()

# ---------------------------------------------------------------
# Search per-workspace ItemTable (legacy format — fallback)
Expand All @@ -270,6 +278,8 @@ def search():
pass
workspace_name = _workspace_display_name_from_folder(workspace_folder, fallback=name)

# try/finally guarantees .close() on every exit path (issue #17).
conn = None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)

Expand Down Expand Up @@ -338,9 +348,11 @@ def search():
"type": "chat",
})

conn.close()
except Exception:
pass
finally:
if conn is not None:
conn.close()
except Exception:
pass

Expand Down
Loading