Skip to content

Commit edaf3b9

Browse files
committed
fix: review comments
### Must fix #1 — **Fixed** `GET /api/workspaces` always returns `{"projects": [...], "warnings"?: [...]}` so the shape no longer flips at runtime. ### Should fix #2 — **Fixed** Removed dead `attach_warnings` from `models/__init__.py` and deleted the unused module-level helper. ### Should fix #3 — **Fixed** Added `AND value IS NOT NULL` to bubble and composer queries in `workspace_tabs.py`; removed NULL branches that called `record_*_skipped()` (tombstones are not `parse_error`). ### Nice to have #4 — **Fixed** Renamed/updated `test_workspaces_api_object_when_clean` and adjusted `test_api_endpoints.py` `TestListWorkspaces` + exclusion tests for the object shape. ### Nice to have #5 — **Skipped** `if count > 0` guards — style-only; no behavior change for normal callers (`count` defaults to 1). ### Extra (follow-on from #1) - `tests/web-ui-smoke.sh` — parse `body["projects"]` when the response is an object (smoke would otherwise fail). - `static/js/app.js` — doc comment only; `normalizeWorkspacesResponse()` still accepts a legacy array. ### Validation - `pytest tests/test_parse_warnings.py tests/test_api_endpoints.py` — **25 passed** - `tests/test_workspace_tabs_null_bubble.py` — included; NULL row still must not crash (filtered in SQL, no false `parse_error` banner)
1 parent 62b007c commit edaf3b9

9 files changed

Lines changed: 31 additions & 49 deletions

File tree

api/workspaces.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ def list_workspaces():
5656
workspace_path = resolve_workspace_path()
5757
rules = current_app.config.get("EXCLUSION_RULES") or []
5858
projects, warnings = list_workspace_projects(workspace_path, rules)
59+
payload: dict = {"projects": projects}
5960
if warnings:
60-
return jsonify({"projects": projects, "warnings": warnings})
61-
return jsonify(projects)
61+
payload["warnings"] = warnings
62+
return jsonify(payload)
6263
except Exception:
6364
_logger.exception("Failed to get workspaces")
6465
return jsonify({"error": "Failed to get workspaces"}), 500

models/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from models.cli_session import CliSessionMeta
22
from models.conversation import Bubble, Composer, WorkspaceLocalComposer
33
from models.errors import SchemaError
4-
from models.parse_warnings import ParseWarningCollector, attach_warnings
4+
from models.parse_warnings import ParseWarningCollector
55
from models.export import ExportEntry
66
from models.workspace import Workspace
77

@@ -14,5 +14,4 @@
1414
"SchemaError",
1515
"Workspace",
1616
"WorkspaceLocalComposer",
17-
"attach_warnings",
1817
]

models/parse_warnings.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,3 @@ def attach_to(self, payload: dict) -> dict:
7272
if self.has_warnings:
7373
payload = {**payload, "warnings": self.to_api_list()}
7474
return payload
75-
76-
77-
def attach_warnings(payload: dict, warnings: list[dict]) -> dict:
78-
"""Merge pre-built warnings into a response dict."""
79-
if warnings:
80-
return {**payload, "warnings": warnings}
81-
return payload

services/workspace_tabs.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,13 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
134134
return []
135135

136136
# Load bubbles
137-
for row in _safe_fetchall("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"):
137+
for row in _safe_fetchall(
138+
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'"
139+
" AND value IS NOT NULL"
140+
):
138141
parts = row["key"].split(":")
139142
if len(parts) >= 3:
140143
bid = parts[2]
141-
if row["value"] is None:
142-
_logger.warning(
143-
"Skipping Bubble cursorDiskKV row with NULL value: key=%r",
144-
row["key"],
145-
)
146-
parse_warnings.record_bubble_skipped()
147-
continue
148144
try:
149145
parsed = json.loads(row["value"])
150146

@@ -214,6 +210,7 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
214210
# Get composer data entries with conversations
215211
composer_rows = _safe_fetchall(
216212
"SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"
213+
" AND value IS NOT NULL"
217214
" AND value LIKE '%fullConversationHeadersOnly%'"
218215
" AND value NOT LIKE '%fullConversationHeadersOnly\":[]%'"
219216
)
@@ -231,13 +228,6 @@ def _safe_fetchall(query: str, params: tuple = ()) -> list:
231228

232229
for row in composer_rows:
233230
composer_id = row["key"].split(":")[1]
234-
if row["value"] is None:
235-
_logger.warning(
236-
"Skipping Composer cursorDiskKV row with NULL value: key=%r",
237-
row["key"],
238-
)
239-
parse_warnings.record_composer_skipped()
240-
continue
241231
try:
242232
parsed = json.loads(row["value"])
243233
except (json.JSONDecodeError, TypeError, ValueError) as e:

static/js/app.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ function sanitizeFilename(name) {
8888
}
8989

9090
/**
91-
* Normalize GET /api/workspaces body: plain array (no warnings) or
92-
* { projects, warnings } when parse failures occurred (issue #67).
91+
* Normalize GET /api/workspaces body: { projects, warnings? } (issue #67).
92+
* Accepts a legacy plain array for backward compatibility.
9393
*/
9494
function normalizeWorkspacesResponse(body) {
9595
if (Array.isArray(body)) {

tests/test_api_endpoints.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,22 @@ def test_happy_path_returns_workspace_list(self, client):
1414
response = client.get("/api/workspaces")
1515
assert response.status_code == 200
1616
body = response.get_json()
17-
assert isinstance(body, list)
17+
assert isinstance(body, dict)
18+
projects = body["projects"]
19+
assert isinstance(projects, list)
1820

19-
ids = [p["id"] for p in body]
21+
ids = [p["id"] for p in projects]
2022
assert HAPPY_WORKSPACE_ID in ids, f"expected {HAPPY_WORKSPACE_ID} in {ids}"
2123

22-
ws = next(p for p in body if p["id"] == HAPPY_WORKSPACE_ID)
24+
ws = next(p for p in projects if p["id"] == HAPPY_WORKSPACE_ID)
2325
assert "name" in ws
2426
assert "conversationCount" in ws and isinstance(ws["conversationCount"], int)
2527
assert "lastModified" in ws and "T" in ws["lastModified"]
2628

2729
def test_empty_storage_returns_empty_list(self, empty_workspace_client):
2830
response = empty_workspace_client.get("/api/workspaces")
2931
assert response.status_code == 200
30-
assert response.get_json() == []
32+
assert response.get_json() == {"projects": []}
3133

3234

3335
# ---------------------------------------------------------------------------
@@ -169,7 +171,7 @@ def test_workspace_matching_rule_is_filtered_out_of_list(self, workspace_storage
169171
response = excluded_client.get("/api/workspaces")
170172
assert response.status_code == 200
171173
body = response.get_json()
172-
ids = [w["id"] for w in body]
174+
ids = [w["id"] for w in body["projects"]]
173175
assert HAPPY_WORKSPACE_ID not in ids, (
174176
f"exclusion rule did not filter {HAPPY_WORKSPACE_ID}; got {ids}"
175177
)
@@ -182,7 +184,7 @@ def test_workspace_not_matching_rule_still_listed(self, workspace_storage):
182184
response = kept_client.get("/api/workspaces")
183185
assert response.status_code == 200
184186
body = response.get_json()
185-
ids = [w["id"] for w in body]
187+
ids = [w["id"] for w in body["projects"]]
186188
assert HAPPY_WORKSPACE_ID in ids, (
187189
f"non-matching rule filtered the workspace; got {ids}"
188190
)

tests/test_parse_warnings.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def setUp(self) -> None:
246246
self.app.config["TESTING"] = True
247247
self.client = self.app.test_client()
248248

249-
def test_workspaces_api_array_when_clean(self) -> None:
249+
def test_workspaces_api_object_when_clean(self) -> None:
250250
tmp = tempfile.mkdtemp()
251251
try:
252252
ws_root = _seed_clean_workspace(tmp)
@@ -258,7 +258,9 @@ def test_workspaces_api_array_when_clean(self) -> None:
258258

259259
self.assertEqual(res.status_code, 200)
260260
data = res.get_json()
261-
self.assertIsInstance(data, list)
261+
self.assertIsInstance(data, dict)
262+
self.assertIn("projects", data)
263+
self.assertNotIn("warnings", data)
262264

263265
def test_workspaces_api_object_when_warnings(self) -> None:
264266
tmp = tempfile.mkdtemp()

tests/test_workspace_tabs_null_bubble.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,17 @@ def tearDown(self):
7070
self.tmp.cleanup()
7171

7272
def test_null_bubble_row_is_skipped_without_exception(self):
73-
"""assemble_workspace_tabs must not raise when a bubble row has NULL value."""
73+
"""assemble_workspace_tabs must not raise when a NULL bubble row exists in KV."""
7474
try:
75-
with self.assertLogs("services.workspace_tabs", level="WARNING") as cm:
76-
_payload, status = assemble_workspace_tabs(
77-
workspace_id="global",
78-
workspace_path=self.workspace_path,
79-
rules=[],
80-
)
75+
payload, status = assemble_workspace_tabs(
76+
workspace_id="global",
77+
workspace_path=self.workspace_path,
78+
rules=[],
79+
)
8180
except TypeError as exc:
8281
self.fail(f"NULL bubble row raised TypeError: {exc}")
8382

8483
self.assertEqual(status, 200, "NULL bubble row must not turn tabs load into an error response")
85-
messages = [r.getMessage() for r in cm.records]
86-
self.assertTrue(
87-
any("NULL value" in m and "bubble-null" in m for m in messages),
88-
f"expected NULL-value warning for bubble-null row, got: {messages}",
89-
)
9084

9185
def test_healthy_bubbles_still_load_when_null_row_present(self):
9286
"""The healthy bubble surfaces in a tab even when a NULL row is present."""

tests/web-ui-smoke.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ probe "/api/search (no q -> 400)" "/api/search" 400
109109
if WS_ID=$(curl "${CURL_FLAGS[@]}" "$BASE/api/workspaces" | python3 -c "
110110
import sys, json
111111
data = json.load(sys.stdin)
112-
for w in data:
112+
projects = data if isinstance(data, list) else data.get("projects", [])
113+
for w in projects:
113114
if w.get('id') and w['id'] != 'global':
114115
print(w['id'])
115116
break

0 commit comments

Comments
 (0)