From 2e6e470affda9049f7226e2c9159f411f49e1177 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 09:45:07 -0700 Subject: [PATCH] fix(annotations): answer a fixed coded message when the filtered image list fails A failed prompt-link or image-tag read under a Tags, category or rating filter returned the raw exception text (SQL or database messages) in the 500 body. The workspace listing now answers code listing-failed with a fixed sentence and logs the traceback. The legacy output listing and the inspector image-tag read now log with exc_info as well. --- py/api/images.py | 31 ++++++++++++++++++++----- tests/test_api_prompt_inspector.py | 6 ++++- tests/test_api_workspace_images.py | 36 ++++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/py/api/images.py b/py/api/images.py index 4898a995..6edfdc13 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -74,6 +74,7 @@ IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS # The header records the card parses (see utils.card_seed.header_params). _CARD_RECORDS = ("prompt", "workflow") _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_LISTING_FAILED = "The image list could not be filtered. Check server logs for details." def _card_header(path: Path) -> tuple: @@ -1173,7 +1174,7 @@ class ImageRoutesMixin: } ) except Exception as e: - self.logger.error(f"Output images error: {e}") + self.logger.error(f"Output images error: {e}", exc_info=True) return web.json_response( {"success": False, "error": _safe_error(e)}, status=500 ) @@ -1217,11 +1218,27 @@ class ImageRoutesMixin: for index, root in enumerate(output_dirs) if root_index is None or index == root_index ] - page, total, has_more, next_cursor, positive_count = ( - await self._run_in_executor( - self._select_workspace_page, listings, filters, after, limit, scope + try: + page, total, has_more, next_cursor, positive_count = ( + await self._run_in_executor( + self._select_workspace_page, listings, filters, after, limit, scope + ) + ) + except Exception: + # A filter that needs the prompt links or the image tags cannot be + # served without them. Never echo the exception: it can carry SQL + # or database text. The traceback goes to the server log. + self.logger.error( + "Workspace image list could not be filtered", exc_info=True + ) + return web.json_response( + { + "success": False, + "code": "listing-failed", + "error": _LISTING_FAILED, + }, + status=500, ) - ) images = await self._run_in_executor(self._format_workspace_page, page) result = { "success": True, @@ -2652,7 +2669,9 @@ class ImageRoutesMixin: try: found = self.db.current_image_tags({locator: (size, mtime_ns)}) except Exception: - self.logger.warning("Inspection image tags could not be read") + self.logger.warning( + "Inspection image tags could not be read", exc_info=True + ) return {"state": "unavailable", "tags": []} tags = found.get(locator) if tags is None: diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index 027b1453..4163b434 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -1696,8 +1696,12 @@ class TestInspectorImageTags(ImageAPITestCase): with patch.object( self.api.db, "current_image_tags", side_effect=RuntimeError("boom") ): - with self.assertLogs("prompt_manager", level="WARNING"): + with self.assertLogs("prompt_manager", level="WARNING") as logged: response, failed = await self.inspect("sub/tagged.png") + self.assertTrue( + any(r.exc_info and "image tags" in r.getMessage() for r in logged.records), + logged.output, + ) self.assertEqual(response.status, 200, failed) self.assertEqual(failed.get("image_tags"), {"state": "unavailable", "tags": []}) self.assertEqual(failed["prompts"], envelope["prompts"]) diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 80d26558..1b1113bc 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -4,6 +4,7 @@ import asyncio import json import os import shutil +import sqlite3 import subprocess # nosec B404 import sys import unittest @@ -1232,15 +1233,21 @@ class TestWorkspaceImages(ImageAPITestCase): {"q": "zebra", "category": "Night"}, {"sort": "rating"}, ): + leak = 'near "gi": syntax error FROM generation_images' with ( self.subTest(failing=params), patch.object( - db, "get_image_link_matches", side_effect=RuntimeError("gone") + db, "get_image_link_matches", side_effect=RuntimeError(leak) ), + self.assertLogs("prompt_manager", level="ERROR") as logged, ): response, data = await self.get(**params) self.assertEqual(response.status, 500, data) self.assertNotIn("images", data) + self.assertEqual(data["code"], "listing-failed") + self.assertNotIn(leak, json.dumps(data)) + self.assertNotIn("syntax error", json.dumps(data)) + self.assertTrue(any(r.exc_info for r in logged.records)) async def test_filename_search_survives_a_database_failure(self): for name, mtime in (("zebra-1.png", 300), ("zebra-2.png", 200)): @@ -1989,18 +1996,39 @@ class TestWorkspaceImageTags(ImageAPITestCase): tagged = self.media("unlinked_tagged.png", mtime=102) self.media("dunes_by_name.png", mtime=101) self.tag(tagged, ["dunes"]) + leak = "no such table: media_annotation_files" with patch.object( - self.api.db, "iter_image_tag_records", side_effect=RuntimeError("boom") + self.api.db, + "iter_image_tag_records", + side_effect=sqlite3.OperationalError(leak), ): - # As a failed link read does under a Tags filter. - response, body = await self.get(tags="dunes") + # As a failed link read does under a Tags filter: a fixed coded + # sentence, never the exception or SQL text; the traceback is logged. + with self.assertLogs("prompt_manager", level="ERROR") as failed: + response, body = await self.get(tags="dunes") self.assertEqual((response.status, body["success"]), (500, False)) + self.assertEqual(body["code"], "listing-failed") + self.assertNotIn(leak, body["error"]) + self.assertNotIn("media_annotation_files", json.dumps(body)) + self.assertTrue(any(r.exc_info for r in failed.records)) with self.assertLogs("prompt_manager", level="WARNING") as logged: response, body = await self.get(q="dunes") self.assertEqual(response.status, 200, body) self.assertEqual(self.names(body), ["dunes_by_name.png"]) self.assertIn("image tags", "\n".join(logged.output)) + async def test_legacy_output_failure_logs_its_traceback(self): + self.media("legacy.png", mtime=100) + with ( + patch.object( + self.api, "_collect_output_images", side_effect=RuntimeError("x") + ), + self.assertLogs("prompt_manager", level="ERROR") as logged, + ): + response = await self.client.get("/prompt_manager/images/output") + self.assertEqual(response.status, 500) + self.assertTrue(any(r.exc_info for r in logged.records), logged.output) + async def test_legacy_gallery_and_search_shapes_unchanged(self): path = self.media("legacy.png", mtime=100) self.media("other.png", mtime=99)