-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcomposers.py
More file actions
232 lines (208 loc) · 10.2 KB
/
Copy pathcomposers.py
File metadata and controls
232 lines (208 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
"""
API routes for composers — mirrors:
src/app/api/composers/route.ts GET /api/composers
src/app/api/composers/[id]/route.ts GET /api/composers/<id>
"""
import json
import logging
import os
import sqlite3
from contextlib import closing
from flask import Blueprint, jsonify
from utils.workspace_path import resolve_workspace_path
from utils.path_helpers import to_epoch_ms
from models import Composer, SchemaError, Workspace, WorkspaceLocalComposer
bp = Blueprint("composers", __name__)
_logger = logging.getLogger(__name__)
def _read_json_file(path: str):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
@bp.route("/api/composers")
def list_composers():
try:
workspace_path = resolve_workspace_path()
composers = []
for name in os.listdir(workspace_path):
full = os.path.join(workspace_path, name)
if not os.path.isdir(full):
continue
db_path = os.path.join(full, "state.vscdb")
wj_path = os.path.join(full, "workspace.json")
if not os.path.isfile(db_path):
continue
workspace_folder = None
try:
workspace = Workspace.from_dict(_read_json_file(wj_path), workspace_id=name)
workspace_folder = workspace.folder
except (SchemaError, OSError, ValueError):
# Missing / malformed workspace.json is non-fatal — the row still
# contributes its composer data, just without a folder hint.
pass
try:
# 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])
if not isinstance(data, dict):
raise SchemaError(
"WorkspaceComposers",
"composer.composerData",
hint=f"expected object, got {type(data).__name__}",
)
if "allComposers" not in data:
raise SchemaError("WorkspaceComposers", "allComposers")
all_composers = data.get("allComposers")
if not isinstance(all_composers, list):
raise SchemaError(
"WorkspaceComposers",
"allComposers",
hint=f"expected list, got {type(all_composers).__name__}",
)
for c in all_composers:
try:
local = WorkspaceLocalComposer.from_dict(c)
except SchemaError as e:
_logger.warning(
"Schema drift in %s: %s (%s)",
db_path,
e,
type(e).__name__,
)
continue
# Use the typed view downstream so the dataclass is
# load-bearing, not just a filter (Brad's review): the
# sort key and the JSON's composerId both read off the
# validated values, not the raw dict.
c["composerId"] = local.composer_id
c["lastUpdatedAt"] = local.last_updated_at
c["conversation"] = c.get("conversation") or []
c["workspaceId"] = name
c["workspaceFolder"] = workspace_folder
composers.append((local, c))
except SchemaError as e:
_logger.warning(
"Schema drift in %s: %s (%s)",
db_path,
e,
type(e).__name__,
)
except Exception as e:
_logger.error(
"Failed reading composers from %s: %s (%s)",
db_path,
e,
type(e).__name__,
exc_info=True,
)
composers.sort(key=lambda pair: to_epoch_ms(pair[0].last_updated_at), reverse=True)
return jsonify([c for _, c in composers])
except Exception:
_logger.exception("Failed to get composers")
return jsonify({"error": "Failed to get composers"}), 500
@bp.route("/api/composers/<composer_id>")
def get_composer(composer_id):
try:
workspace_path = resolve_workspace_path()
# Search per-workspace databases
for name in os.listdir(workspace_path):
full = os.path.join(workspace_path, name)
if not os.path.isdir(full):
continue
db_path = os.path.join(full, "state.vscdb")
if not os.path.isfile(db_path):
continue
try:
# 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])
# Mirror the envelope guards list_composers() applies at line 60–74
# so a drifted local row (data not a dict, or allComposers missing
# / non-list) surfaces as a logged SchemaError, not a 500.
if not isinstance(data, dict):
raise SchemaError(
"WorkspaceComposers",
"composer.composerData",
hint=f"expected object, got {type(data).__name__}",
)
if "allComposers" not in data:
raise SchemaError("WorkspaceComposers", "allComposers")
all_composers = data.get("allComposers")
if not isinstance(all_composers, list):
raise SchemaError(
"WorkspaceComposers",
"allComposers",
hint=f"expected list, got {type(all_composers).__name__}",
)
for c in all_composers:
if isinstance(c, dict) and c.get("composerId") == composer_id:
try:
local = WorkspaceLocalComposer.from_dict(c)
except SchemaError as e:
# Same drift list_composers() logs and skips at line ~78,
# so a single-composer fetch can't silently return malformed
# JSON the list endpoint hid.
_logger.warning(
"Schema drift in workspace-local composer %s: %s (%s)",
composer_id,
e,
type(e).__name__,
)
continue
# Match list_composers() at line 89 and the global
# fallback below: `conversation` is normalised to []
# whether it's absent or None, so the response shape
# is identical regardless of which branch resolved
# the composer (CodeRabbit on PR #30).
payload = dict(local.raw)
payload["conversation"] = payload.get("conversation") or []
return jsonify(payload)
except SchemaError as e:
_logger.warning(
"Schema drift in %s: %s (%s)",
db_path,
e,
type(e).__name__,
)
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
pass
# Fallback: global storage
global_db_path = os.path.normpath(os.path.join(workspace_path, "..", "globalStorage", "state.vscdb"))
if os.path.isfile(global_db_path):
try:
# 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")
try:
composer = Composer.from_dict(json.loads(raw), composer_id=composer_id)
except SchemaError as e:
# Don't return malformed JSON to the client — surface the drift
# as a 404 + log, matching the silent-skip behaviour of the
# list endpoints for the same row.
_logger.warning(
"Schema drift in composer %s: %s (%s)",
composer_id,
e,
type(e).__name__,
)
return jsonify({"error": "Composer schema drift"}), 404
payload = dict(composer.raw)
payload["conversation"] = payload.get("conversation") or []
return jsonify(payload)
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):
pass
return jsonify({"error": "Composer not found"}), 404
except Exception:
_logger.exception("Failed to get composer")
return jsonify({"error": "Failed to get composer"}), 500