From 70581d6bca78161b47c81a99cc04871fe6e19bd7 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Fri, 23 Oct 2026 08:01:50 -0700 Subject: [PATCH] feat(system): report configured, effective and watched sources GET /prompt_manager/settings adds settings.monitoring_enabled, a sources block (configured roots with valid/code/reason, effective roots with their listing index and watch state, auto_detected) and the monitor state (present, observer_alive, scheduled). Every path is a public path. A source is "watching" only while a live observer scheduled it, "manual" when monitoring is off or the process has no monitor, and otherwise "restart_pending". POST accepts monitoring_enabled as a strict bool, saved under gallery.monitoring.enabled; restart_required_fields names it when the running observer does not already match. A save that changes the sources runs as a "Source change" records hold, so it is refused with 409 busy while a scan runs. Case variants of one folder are saved once. POST /prompt_manager/settings/validate-source {path} checks a path lexically (no "..", no control characters, native path forms only) and then against the gallery-root rules, answering a code and reason, and writes nothing. --- py/api/settings_routes.py | 187 ++++++++++- py/api/sources.py | 235 ++++++++++++++ py/config.py | 34 +- tests/test_settings_sources.py | 548 +++++++++++++++++++++++++++++++++ 4 files changed, 978 insertions(+), 26 deletions(-) create mode 100644 py/api/sources.py create mode 100644 tests/test_settings_sources.py diff --git a/py/api/settings_routes.py b/py/api/settings_routes.py index 258ad2ae..8faa4e83 100644 --- a/py/api/settings_routes.py +++ b/py/api/settings_routes.py @@ -4,8 +4,15 @@ A save validates the payload, commits it to config.json through the config store and publishes it at runtime inside that commit; a save that cannot be written changes nothing at runtime and says so. ``_config_commit_failure`` is shared with the LoRA integration's save. + +GET also reports the image sources and the monitor (``sources.py``). A save +that changes the sources runs as a ``source_change`` registry hold, so it is +refused while a record-changing job (a scan) runs. ``monitoring_enabled`` +takes effect at the next restart. ``POST /prompt_manager/settings/validate-source`` +checks one path and writes nothing. """ +import contextlib import os from aiohttp import web @@ -15,7 +22,10 @@ from ..config_store import ( RevisionConflict, get_config_store, ) +from . import sources from .admin import _current_image_monitor +from .job_routes import busy_response +from .jobs import JobBusy try: from ...utils.validators import validate_result_timeout, validate_worker_threads @@ -23,13 +33,36 @@ except ImportError: from utils.validators import validate_result_timeout, validate_worker_threads -# Settings owned by POST /settings: config attribute -> (section, key). +# Settings owned by POST /settings: config attribute -> its keys in config.json. _SETTINGS_FILE_KEYS = { "RESULT_TIMEOUT": ("web_ui", "result_timeout"), "WEBUI_DISPLAY_MODE": ("web_ui", "webui_display_mode"), "INFINITE_SCROLL": ("web_ui", "infinite_scroll"), "WORKER_THREADS": ("performance", "worker_threads"), + "MONITORING_ENABLED": ("gallery", "monitoring", "enabled"), } +# Attributes that live on GalleryConfig; the others on PromptManagerConfig. +_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"}) +SOURCE_CHANGE_JOB_KIND = "source_change" +SOURCE_CHANGE_JOB_TITLE = "Source change" + + +def _source_identity(path): + """The case-folded spelling two roots are the same directory by (Windows).""" + return os.path.normcase(path) + + +def _stat_identity(path): + """``(st_dev, st_ino)`` of an existing directory, or None. + + Catches case variants on a case-insensitive filesystem whose spelling + normcase keeps (macOS). + """ + try: + stat = os.stat(path) + except (OSError, ValueError): + return None + return (stat.st_dev, stat.st_ino) if stat.st_ino else None def _section(config, name): @@ -47,10 +80,8 @@ def _settings_mutation(updates, new_roots): def mutate(config): for name, value in updates.items(): - section, key = _SETTINGS_FILE_KEYS[name] - merged = _section(config, section) - merged[key] = value - config[section] = merged + *sections, key = _SETTINGS_FILE_KEYS[name] + _set_nested(config, sections, key, value) if new_roots is not None: gallery = _section(config, "gallery") monitoring = _section(gallery, "monitoring") @@ -62,6 +93,16 @@ def _settings_mutation(updates, new_roots): return mutate +def _set_nested(config, sections, key, value): + """``config[s1][s2]...[key] = value``, each section a fresh dict.""" + if not sections: + config[key] = value + return + merged = _section(config, sections[0]) + _set_nested(merged, sections[1:], key, value) + config[sections[0]] = merged + + async def _read_json(request): """Parse a JSON object body through the package's capped reader.""" from . import _read_json_body @@ -81,6 +122,10 @@ class SettingsRoutesMixin: async def save_settings_route(request): return await self.save_settings(request) + @routes.post("/prompt_manager/settings/validate-source") + async def validate_source_route(request): + return await self.validate_source(request) + async def get_settings(self, request): """Get current settings.""" try: @@ -90,11 +135,15 @@ class SettingsRoutesMixin: self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES ] revision = await self._run_in_executor(self._config_revision) + source_status, monitor = await self._run_in_executor(self._source_status) return web.json_response( { "success": True, **revision, + "sources": source_status, + "monitor": monitor, "settings": { + "monitoring_enabled": GalleryConfig.MONITORING_ENABLED, "result_timeout": PromptManagerConfig.RESULT_TIMEOUT, "webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE, "infinite_scroll": PromptManagerConfig.INFINITE_SCROLL, @@ -115,6 +164,61 @@ class SettingsRoutesMixin: status=500, ) + def _source_status(self): + """``(sources, monitor)`` of GET /settings (blocking).""" + from ..config import GalleryConfig + + output_dirs, auto_detected = self._output_dirs_with_origin() + return sources.describe_sources( + configured=list(GalleryConfig.MONITORING_DIRECTORIES), + output_dirs=output_dirs, + auto_detected=auto_detected, + monitor=_current_image_monitor(), + enabled=GalleryConfig.MONITORING_ENABLED, + ) + + async def validate_source(self, request): + """Check one source path ``{path}``; writes nothing. + + 200 ``{success, valid, label, path, code, reason}``: a valid source + answers its label and public path, an invalid one its code and reason + only. A body without a string ``path`` is a 400 ``invalid-request``. + """ + data, error_response = await _read_json(request) + if error_response is not None: + too_large = error_response.status == 413 + return web.json_response( + { + "success": False, + "code": "too-large" if too_large else "invalid-request", + "error": ( + "Request body too large" + if too_large + else "Request body must be a JSON object" + ), + }, + status=error_response.status, + ) + path = data.get("path") + if not isinstance(path, str): + return web.json_response( + { + "success": False, + "code": "invalid-request", + "error": "path must be a string", + }, + status=400, + ) + try: + result = await self._run_in_executor(sources.validate_source, path) + except Exception: + self.logger.exception("Source validation failed") + return web.json_response( + {"success": False, "code": None, "error": "Failed to check the source"}, + status=500, + ) + return web.json_response({"success": True, **result}) + def _monitored_directories(self): """Directories the running image monitor watches, else the configured roots.""" from ..config import GalleryConfig @@ -162,15 +266,30 @@ class SettingsRoutesMixin: # they were written, so the runtime ends on the last one. published.update(self._publish_settings(updates, new_roots)) - try: - _saved, revision = await self._run_in_executor( - get_config_store().commit, - _settings_mutation(updates, new_roots), - expected_revision=expected_revision, - publish=publish, - ) - except (ConfigUnreadable, RevisionConflict, OSError) as exc: - return self._config_commit_failure(exc) + async with contextlib.AsyncExitStack() as stack: + hold = None + if self._roots_change(new_roots): + try: + hold = await stack.enter_async_context( + self._jobs.hold( + SOURCE_CHANGE_JOB_KIND, + SOURCE_CHANGE_JOB_TITLE, + admission="records", + ) + ) + except JobBusy as busy: + return busy_response(busy) + try: + _saved, revision = await self._run_in_executor( + get_config_store().commit, + _settings_mutation(updates, new_roots), + expected_revision=expected_revision, + publish=publish, + ) + except (ConfigUnreadable, RevisionConflict, OSError) as exc: + if hold is not None: + hold.finish("failed") + return self._config_commit_failure(exc) return web.json_response( { @@ -196,10 +315,11 @@ class SettingsRoutesMixin: as ``runtime_applied: false`` with the fields that need a restart; nothing is retried or rolled back. Never raises. """ - from ..config import PromptManagerConfig + from ..config import GalleryConfig, PromptManagerConfig for name, value in updates.items(): - setattr(PromptManagerConfig, name, value) + owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig + setattr(owner, name, value) restart_fields = [] runtime_applied = True if new_roots is not None: @@ -212,11 +332,33 @@ class SettingsRoutesMixin: ) runtime_applied = False restart_fields.append("gallery_root_paths") + if "MONITORING_ENABLED" in updates: + # The monitor starts with the server: a restart applies the setting + # unless the observer already matches it. + if updates["MONITORING_ENABLED"] != self._observer_alive(): + restart_fields.append("monitoring_enabled") return { "runtime_applied": runtime_applied, "restart_required_fields": restart_fields, } + @staticmethod + def _roots_change(new_roots): + """Whether a save changes the sources (the legacy page resends them).""" + from ..config import GalleryConfig + + return new_roots is not None and new_roots != list( + GalleryConfig.MONITORING_DIRECTORIES + ) + + @staticmethod + def _observer_alive(): + """Whether the image monitor's observer is running now. Never raises.""" + try: + return sources.monitor_state(_current_image_monitor())[1] + except Exception: + return False + def _config_commit_failure(self, exc): """The response for a config commit that wrote nothing.""" not_saved = {"success": False, "persisted": False, "runtime_applied": False} @@ -283,6 +425,10 @@ class SettingsRoutesMixin: if not isinstance(data["infinite_scroll"], bool): raise ValueError("infinite_scroll must be true or false") updates["INFINITE_SCROLL"] = data["infinite_scroll"] + if "monitoring_enabled" in data: + if not isinstance(data["monitoring_enabled"], bool): + raise ValueError("monitoring_enabled must be true or false") + updates["MONITORING_ENABLED"] = data["monitoring_enabled"] if "worker_threads" in data: updates["WORKER_THREADS"] = validate_worker_threads( data["worker_threads"], PromptManagerConfig.max_worker_threads() @@ -312,6 +458,7 @@ class SettingsRoutesMixin: from ..config import GalleryConfig roots = [] + seen = set() for entry in raw: if not isinstance(entry, str): return None, "Gallery root paths must be strings" @@ -324,7 +471,13 @@ class SettingsRoutesMixin: None, f"Invalid gallery root '{os.path.basename(entry)}': {reason}", ) - roots.append(GalleryConfig.resolve_gallery_root(entry)) + root = GalleryConfig.resolve_gallery_root(entry) + keys = {("path", _source_identity(root)), ("file", _stat_identity(root))} + keys.discard(("file", None)) + if keys & seen: + continue # another spelling of a root already listed + seen |= keys + roots.append(root) return roots, None def _apply_gallery_roots(self, roots): diff --git a/py/api/sources.py b/py/api/sources.py new file mode 100644 index 00000000..1f0e6f7f --- /dev/null +++ b/py/api/sources.py @@ -0,0 +1,235 @@ +"""Image sources as System › Sources & scans shows them (#249 S4). + +- ``source_form_error`` is the lexical check a submitted source path passes + before any filesystem access: no control characters, no ``..`` part, and + only the native path forms (on Windows a drive or a UNC share, never the + ``\\\\?\\`` and ``\\\\.\\`` namespaces, a drive-relative path, an alternate + data stream, a device name or a part Windows would silently trim; on POSIX + no Windows drive or backslash form). +- ``describe_sources`` (blocking: it resolves paths) reports the configured + sources with their validity, the effective sources with their listing + index and watch state, and the image monitor's state. Every path in it is + a public path (``_public_path``), never an absolute one. + +A source's watch state is ``watching`` when the monitor's observer is alive +and scheduled that directory; ``manual`` when monitoring is off or this +process has no monitor; else ``restart_pending`` (a source added since the +monitor started, monitoring switched on, or an observer that stopped). +""" + +import ntpath +import os +import re + +MAX_SOURCE_PATH = 4096 # characters +FORM_REASONS = { + "empty": "Gallery root must be a non-empty path", + "invalid-form": "Gallery root is not a folder path this server can use", + "traversal": "Gallery root must not contain '..'", +} +_DRIVE = re.compile(r"[A-Za-z]:") +_POSIX_FOREIGN = re.compile(r"^[A-Za-z]:") +# Reserved device names: Windows opens the device for any folder of that name. +_DEVICE_NAME = re.compile(r"^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$", re.I) +# folder_paths getters and the label of the directory each one reports. +_COMFYUI_LABELS = ( + ("get_output_directory", "ComfyUI output"), + ("get_input_directory", "ComfyUI input"), + ("get_temp_directory", "ComfyUI temp"), + ("get_user_directory", "ComfyUI user"), +) +WATCH_STATES = ("watching", "manual", "restart_pending") + + +def source_form_error(text, *, windows=None): + """The form code that refuses ``text`` as a source path, or None. + + ``windows`` selects the path rules (default: this OS). No filesystem + access happens here. + """ + if windows is None: + windows = os.name == "nt" + if not isinstance(text, str) or len(text) > MAX_SOURCE_PATH: + return "invalid-form" + if any(ord(char) < 32 or ord(char) == 127 for char in text): + return "invalid-form" + text = text.strip() + if not text: + return "empty" + return _windows_form_error(text) if windows else _posix_form_error(text) + + +def _posix_form_error(text): + if "\\" in text or _POSIX_FOREIGN.match(text): + return "invalid-form" # a Windows path names nothing on this server + return "traversal" if ".." in text.split("/") else None + + +def _windows_form_error(text): + portable = text.replace("/", "\\") + if portable.startswith(("\\\\?\\", "\\\\.\\")): + return "invalid-form" # verbatim and device namespaces + if portable.startswith("\\\\"): + drive, rest = ntpath.splitdrive(portable) + share = drive[2:].split("\\") + if len(share) != 2 or any(part in ("", ".", "..") for part in share): + return "invalid-form" + else: + drive, rest = ntpath.splitdrive(portable) + if drive and (not _DRIVE.fullmatch(drive) or not rest.startswith("\\")): + return "invalid-form" # "C:folder" is relative to that drive's cwd + if ":" in rest: + return "invalid-form" # an alternate data stream + parts = rest.split("\\") + if ".." in parts: + return "traversal" + for part in parts: + if part in ("", "."): + continue + if part[-1] in ". " or _DEVICE_NAME.match(part): + return "invalid-form" + return None + + +def identity(path): + """The comparison key of a directory: resolved and case-folded (Windows).""" + return os.path.normcase(os.path.realpath(str(path))) + + +def _comfyui_labels(): + """``{identity: label}`` of the directories ComfyUI reports.""" + try: + import folder_paths + except ImportError: + return {} + labels = {} + for getter_name, label in _COMFYUI_LABELS: + getter = getattr(folder_paths, getter_name, None) + if getter is None: + continue + try: + directory = getter() + except Exception: + continue + if isinstance(directory, str) and directory: + labels.setdefault(identity(directory), label) + return labels + + +def source_label(path, labels=None): + """ "ComfyUI output" for ComfyUI's own directories, else the folder name.""" + if labels is None: + labels = _comfyui_labels() + label = labels.get(identity(path)) + if label: + return label + return os.path.basename(os.path.normpath(str(path))) or str(path) + + +def _configured(entry, labels, public_path): + from ..config import GalleryConfig + + code, reason = GalleryConfig.gallery_root_problem(entry) + try: + resolved = GalleryConfig.resolve_gallery_root(entry) + except (OSError, TypeError, ValueError): + resolved = str(entry) + return { + "label": source_label(resolved, labels), + "path": public_path(resolved), + "valid": code is None, + "code": code, + "reason": reason or None, + } + + +def monitor_state(monitor): + """``(present, observer_alive, scheduled directories)`` of the image monitor.""" + if monitor is None: + return False, False, [] + status = monitor.get_status() + scheduled = [str(d) for d in status.get("monitored_directories") or []] + return True, bool(status.get("observer_alive")), scheduled + + +def watch_state(path, *, present, alive, scheduled_keys, enabled): + """``watching``, ``manual`` or ``restart_pending`` (see the module docstring).""" + if alive and identity(path) in scheduled_keys: + return "watching" + if not present or not enabled: + return "manual" + return "restart_pending" + + +def describe_sources(*, configured, output_dirs, auto_detected, monitor, enabled): + """``(sources, monitor)`` for GET /settings (blocking; public paths only). + + ``configured`` is the configured roots, ``output_dirs`` and + ``auto_detected`` what ``_output_dirs_with_origin`` returned, ``monitor`` + the running ImageMonitor or None, ``enabled`` the monitoring setting. + """ + from . import _public_path + + labels = _comfyui_labels() + present, alive, scheduled = monitor_state(monitor) + scheduled_keys = {identity(path) for path in scheduled} + effective = [ + { + "label": source_label(path, labels), + "path": _public_path(path), + "root_index": index, + "watch": watch_state( + path, + present=present, + alive=alive, + scheduled_keys=scheduled_keys, + enabled=enabled, + ), + } + for index, path in enumerate(output_dirs) + ] + return ( + { + "configured": [ + _configured(entry, labels, _public_path) for entry in configured + ], + "effective": effective, + "auto_detected": bool(auto_detected), + }, + { + "present": present, + "observer_alive": alive, + "scheduled": [_public_path(path) for path in scheduled], + }, + ) + + +def validate_source(text): + """``{valid, label, path, code, reason}`` for one submitted path (blocking). + + Writes nothing. An invalid path answers its code and reason with no + label or path, so a response never echoes what was typed. + """ + from ..config import GalleryConfig + from . import _public_path + + code = source_form_error(text) + reason = FORM_REASONS.get(code) + if code is None: + code, reason = GalleryConfig.gallery_root_problem(text) + if code is not None: + return { + "valid": False, + "label": None, + "path": None, + "code": code, + "reason": reason, + } + resolved = GalleryConfig.resolve_gallery_root(text) + return { + "valid": True, + "label": source_label(resolved), + "path": _public_path(resolved), + "code": None, + "reason": None, + } diff --git a/py/config.py b/py/config.py index a2285246..d06bf9c4 100644 --- a/py/config.py +++ b/py/config.py @@ -268,7 +268,12 @@ class GalleryConfig: """ monitoring = new_config.get("monitoring", {}) if "enabled" in monitoring: - cls.MONITORING_ENABLED = monitoring["enabled"] + if isinstance(monitoring["enabled"], bool): + cls.MONITORING_ENABLED = monitoring["enabled"] + else: # a hand-edited "false" would otherwise switch it on + config_logger.warning( + "Ignoring gallery monitoring enabled: expected true or false" + ) if "directories" in monitoring: directories = cls._validated_directories(monitoring["directories"]) if directories is not None: @@ -399,30 +404,41 @@ class GalleryConfig: (True, "") when the directory lies inside one of :meth:`allowed_gallery_parents`; otherwise (False, reason). """ + code, reason = cls.gallery_root_problem(path) + return code is None, reason + + @classmethod + def gallery_root_problem(cls, path: Any) -> Tuple[Optional[str], str]: + """``(code, reason)`` why ``path`` may not be a gallery root, else (None, ""). + + Codes: ``empty``, ``unresolvable``, ``filesystem-root``, + ``home-directory``, ``not-found``, ``not-directory`` and + ``outside-allowed``. No reason names the path. + """ if not isinstance(path, str) or not path.strip(): - return False, "Gallery root must be a non-empty path" + return "empty", "Gallery root must be a non-empty path" try: canonical = cls.resolve_gallery_root(path) except (OSError, ValueError): - return False, "Gallery root could not be resolved" + return "unresolvable", "Gallery root could not be resolved" if _is_filesystem_root(canonical): - return False, "Gallery root cannot be a filesystem root" + return "filesystem-root", "Gallery root cannot be a filesystem root" if canonical == _canonical_path(os.path.expanduser("~")): - return False, "Gallery root cannot be the home directory" + return "home-directory", "Gallery root cannot be the home directory" if not os.path.exists(canonical): - return False, "Gallery root does not exist" + return "not-found", "Gallery root does not exist" if not os.path.isdir(canonical): - return False, "Gallery root is not a directory" + return "not-directory", "Gallery root is not a directory" candidate = Path(canonical) for parent in cls.allowed_gallery_parents(): if candidate.is_relative_to(Path(parent)): - return True, "" + return None, "" return ( - False, + "outside-allowed", "Gallery root must be inside a ComfyUI directory " f"(output, input, temp, user) or one listed in {EXTRA_GALLERY_ROOTS_ENV}", ) diff --git a/tests/test_settings_sources.py b/tests/test_settings_sources.py new file mode 100644 index 00000000..b32d4669 --- /dev/null +++ b/tests/test_settings_sources.py @@ -0,0 +1,548 @@ +"""System › Sources & scans backend (#249 S4 Task 9). + +``GET /prompt_manager/settings`` reports the configured, effective and +watched sources and the monitoring setting; ``POST`` saves +``monitoring_enabled`` (a restart applies it) and refuses a source change +while a record-changing job runs; ``POST /prompt_manager/settings/validate-source`` +checks one path and writes nothing. No response carries an absolute path. +""" + +import asyncio +import json +import ntpath +import os +import unittest +from unittest.mock import patch + +try: + from tests.test_api_admin import AdminAPITestCase +except ImportError: # discovered with tests/ as the top-level directory + from test_api_admin import AdminAPITestCase + +from py.api import settings_routes, sources # noqa: E402 +from py.config import GalleryConfig # noqa: E402 + +VALIDATE = "/prompt_manager/settings/validate-source" + + +class FakeMonitor: + """The image monitor as get_settings sees it: status and scheduled roots.""" + + def __init__(self, scheduled, alive=True): + self.monitored_directories = [str(path) for path in scheduled] + self.alive = alive + + def get_status(self): + return { + "running": True, + "monitored_directories": self.monitored_directories, + "handler_active": True, + "observer_alive": self.alive, + } + + +class SourcesTestCase(AdminAPITestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + self.a = self.output_dir / "a" + self.b = self.output_dir / "b" + self.a.mkdir() + self.b.mkdir() + enabled = GalleryConfig.MONITORING_ENABLED + self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled) + GalleryConfig.MONITORING_ENABLED = True + self._install_monitor(None) + + def _install_monitor(self, monitor): + import utils.image_monitor as im_mod + + orig = im_mod._monitor_instance + im_mod._monitor_instance = monitor + self.addCleanup(setattr, im_mod, "_monitor_instance", orig) + + async def _get(self): + resp = await self.client.request("GET", "/prompt_manager/settings") + body = await resp.text() + self.assertEqual(resp.status, 200, body) + self.assertNotIn(self.tmpdir, body) + return json.loads(body) + + async def _post(self, url, payload): + resp = await self.client.request("POST", url, json=payload) + body = await resp.text() + self.assertNotIn(self.tmpdir, body) + return resp.status, json.loads(body) + + def _config_bytes(self): + if not os.path.exists(self.config_path): + return None + with open(self.config_path, "rb") as f: + return f.read() + + def _states(self, data): + return {row["path"]: row["watch"] for row in data["sources"]["effective"]} + + +class TestSourceStatus(SourcesTestCase): + async def test_sources_list_configured_effective_and_auto_detected(self): + missing = self.output_dir / "gone" + missing.mkdir() + GalleryConfig.MONITORING_DIRECTORIES = [ + self._root(self.a), + self._root(missing), + ] + missing.rmdir() # an unmounted drive: configured, no longer a directory + + data = await self._get() + + self.assertEqual( + data["sources"]["configured"], + [ + { + "label": "a", + "path": "output/a", + "valid": True, + "code": None, + "reason": None, + }, + { + "label": "gone", + "path": "output/gone", + "valid": False, + "code": "not-found", + "reason": "Gallery root does not exist", + }, + ], + ) + self.assertEqual( + data["sources"]["effective"], + [{"label": "a", "path": "output/a", "root_index": 0, "watch": "manual"}], + ) + self.assertFalse(data["sources"]["auto_detected"]) + # The legacy keys are unchanged. + self.assertEqual( + data["settings"]["gallery_root_paths"], ["output/a", "output/gone"] + ) + + async def test_empty_list_means_auto_detect(self): + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)] + self._write_config({"gallery": {"monitoring": {"directories": ["x"]}}}) + # Detection (the upward search for a ComfyUI tree) finds the output. + self.api._find_comfyui_output_dir = lambda: str(self.output_dir) + + status, saved = await self._post( + "/prompt_manager/settings", {"gallery_root_paths": []} + ) + + self.assertEqual(status, 200, saved) + self.assertEqual( + self._read_config()["gallery"]["monitoring"]["directories"], [] + ) + data = await self._get() + self.assertEqual(data["sources"]["configured"], []) + self.assertTrue(data["sources"]["auto_detected"]) + self.assertEqual( + data["sources"]["effective"], + [ + { + "label": "ComfyUI output", + "path": "output", + "root_index": 0, + "watch": "manual", + } + ], + ) + + async def test_watching_requires_live_observer_with_root(self): + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a), self._root(self.b)] + + # No monitor in this process: nothing is watched, a restart cannot help. + data = await self._get() + self.assertEqual( + data["monitor"], + {"present": False, "observer_alive": False, "scheduled": []}, + ) + self.assertEqual( + self._states(data), {"output/a": "manual", "output/b": "manual"} + ) + + # A dead observer watches nothing, even the roots it scheduled. + self._install_monitor(FakeMonitor([self.a], alive=False)) + data = await self._get() + self.assertEqual( + data["monitor"], + {"present": True, "observer_alive": False, "scheduled": ["output/a"]}, + ) + self.assertEqual( + self._states(data), + {"output/a": "restart_pending", "output/b": "restart_pending"}, + ) + + # A live observer watches its scheduled root; a root configured since + # it started waits for a restart. + self._install_monitor(FakeMonitor([self.a])) + data = await self._get() + self.assertTrue(data["monitor"]["observer_alive"]) + self.assertEqual( + self._states(data), {"output/a": "watching", "output/b": "restart_pending"} + ) + + # Monitoring switched off: the live observer still watches until the + # restart, and nothing else is pending. + GalleryConfig.MONITORING_ENABLED = False + data = await self._get() + self.assertFalse(data["settings"]["monitoring_enabled"]) + self.assertEqual( + self._states(data), {"output/a": "watching", "output/b": "manual"} + ) + + async def test_scheduled_root_matches_through_its_spelling(self): + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)] + spelled = os.path.join(str(self.output_dir), "b", "..", "a") + self._install_monitor(FakeMonitor([spelled])) + + data = await self._get() + + self.assertEqual(self._states(data), {"output/a": "watching"}) + self.assertEqual(data["monitor"]["scheduled"], ["output/a"]) + + async def test_status_failure_is_500_without_paths(self): + def boom(*args, **kwargs): + raise OSError(2, "No such file", f"{self.tmpdir}/secret") + + with patch.object(sources, "describe_sources", boom): + resp = await self.client.request("GET", "/prompt_manager/settings") + self.assertEqual(resp.status, 500) + self.assertNotIn(self.tmpdir, await resp.text()) + + +class TestMonitoringSetting(SourcesTestCase): + async def test_monitoring_enabled_strict_bool_and_restart_flag(self): + self._write_config({"custom": {"keep": True}}) + before = self._config_bytes() + for value in ("false", 0, 1, None, [], {"on": True}): + with self.subTest(value=value): + status, data = await self._post( + "/prompt_manager/settings", {"monitoring_enabled": value} + ) + self.assertEqual(status, 400, data) + self.assertFalse(data["success"]) + self.assertEqual(self._config_bytes(), before) + self.assertTrue(GalleryConfig.MONITORING_ENABLED) + + self._install_monitor(FakeMonitor([self.output_dir])) + status, data = await self._post( + "/prompt_manager/settings", {"monitoring_enabled": False} + ) + self.assertEqual(status, 200, data) + self.assertTrue(data["persisted"]) + self.assertTrue(data["restart_required"]) + self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"]) + saved = self._read_config() + self.assertIs(saved["gallery"]["monitoring"]["enabled"], False) + self.assertEqual(saved["custom"], {"keep": True}) + self.assertFalse(GalleryConfig.MONITORING_ENABLED) + self.assertFalse((await self._get())["settings"]["monitoring_enabled"]) + + # Back on while the observer still runs: nothing waits for a restart. + status, data = await self._post( + "/prompt_manager/settings", {"monitoring_enabled": True} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["restart_required_fields"], []) + self.assertIs(self._read_config()["gallery"]["monitoring"]["enabled"], True) + + async def test_enabling_without_a_live_observer_needs_a_restart(self): + GalleryConfig.MONITORING_ENABLED = False + status, data = await self._post( + "/prompt_manager/settings", {"monitoring_enabled": True} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"]) + self.assertTrue(GalleryConfig.MONITORING_ENABLED) + + def test_config_file_enabled_must_be_a_bool(self): + GalleryConfig.MONITORING_ENABLED = True + with self.assertLogs("prompt_manager.config", level="WARNING"): + GalleryConfig.update_config({"monitoring": {"enabled": "false"}}) + self.assertIs(GalleryConfig.MONITORING_ENABLED, True) + GalleryConfig.update_config({"monitoring": {"enabled": False}}) + self.assertIs(GalleryConfig.MONITORING_ENABLED, False) + + +class TestSourceChangeHold(SourcesTestCase): + async def test_root_save_refused_while_scan_job_runs(self): + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)] + self._write_config({"gallery": {"monitoring": {"directories": ["kept"]}}}) + before = self._config_bytes() + gate = asyncio.Event() + + async def runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", "Scan for metadata", record_changing=True, runner=runner + ) + try: + status, data = await self._post( + "/prompt_manager/settings", + {"gallery_root_paths": [str(self.b)]}, + ) + self.assertEqual(status, 409, data) + self.assertEqual(data["code"], "busy") + self.assertEqual( + data["running"], {"id": scan.id, "title": "Scan for metadata"} + ) + self.assertEqual(self._config_bytes(), before) + self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, [self._root(self.a)]) + + # A save without sources, or one resending the current sources + # (the legacy page always does), is not a source change. + status, data = await self._post( + "/prompt_manager/settings", {"result_timeout": 9} + ) + self.assertEqual(status, 200, data) + status, data = await self._post( + "/prompt_manager/settings", + {"result_timeout": 8, "gallery_root_paths": [str(self.a)]}, + ) + self.assertEqual(status, 200, data) + finally: + gate.set() + await asyncio.wait_for(scan.task, 5) + + async def test_root_save_runs_as_a_source_change_hold(self): + seen = [] + original = settings_routes.get_config_store + + def store(): + seen.extend( + (job.kind, job.title, job.admission, job.cancellable) + for job in self.api._jobs.list() + if job.running + ) + return original() + + with patch.object(settings_routes, "get_config_store", store): + status, data = await self._post( + "/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]} + ) + self.assertEqual(status, 200, data) + self.assertIn(("source_change", "Source change", "records", False), seen) + self.assertFalse(any(job.running for job in self.api._jobs.list())) + + async def test_failed_root_save_ends_the_hold_failed(self): + os.makedirs(self.config_path) # config.json is a directory: unreadable + status, data = await self._post( + "/prompt_manager/settings", {"gallery_root_paths": [str(self.b)]} + ) + self.assertGreaterEqual(status, 400, data) + held = [j for j in self.api._jobs.list() if j.kind == "source_change"] + self.assertEqual([job.state for job in held], ["failed"]) + + async def test_mixed_case_windows_paths_dedupe(self): + """Two spellings of one Windows folder are saved once (normcase).""" + with ( + patch.object( + GalleryConfig, "validate_gallery_root", return_value=(True, "") + ), + patch.object( + GalleryConfig, "resolve_gallery_root", side_effect=lambda path: path + ), + patch.object(settings_routes, "_source_identity", ntpath.normcase), + patch.object(self.api, "_apply_gallery_roots", return_value=True), + ): + roots, error = self.api._parse_gallery_roots( + { + "gallery_root_paths": [ + "C:\\ComfyUI\\Output\\Renders", + "c:/comfyui/output/renders", + "C:\\ComfyUI\\output\\other", + ] + } + ) + self.assertIsNone(error) + self.assertEqual( + roots, ["C:\\ComfyUI\\Output\\Renders", "C:\\ComfyUI\\output\\other"] + ) + + async def test_case_variant_of_one_folder_is_saved_once_natively(self): + variant = str(self.a)[:-1] + "A" + if not os.path.isdir(variant): + self.skipTest("case-sensitive filesystem") + status, data = await self._post( + "/prompt_manager/settings", {"gallery_root_paths": [str(self.a), variant]} + ) + self.assertEqual(status, 200, data) + self.assertEqual(len(GalleryConfig.MONITORING_DIRECTORIES), 1) + + +class TestValidateSource(SourcesTestCase): + async def test_validate_source_writes_nothing(self): + self._write_config({"gallery": {"monitoring": {"directories": []}}}) + before = self._config_bytes() + roots = list(GalleryConfig.MONITORING_DIRECTORIES) + + status, data = await self._post(VALIDATE, {"path": str(self.a)}) + + self.assertEqual(status, 200, data) + self.assertEqual( + data, + { + "success": True, + "valid": True, + "label": "a", + "path": "output/a", + "code": None, + "reason": None, + }, + ) + self.assertEqual(self._config_bytes(), before) + self.assertEqual(GalleryConfig.MONITORING_DIRECTORIES, roots) + self.assertEqual(self.api._jobs.list(), []) + + async def test_relative_path_resolves_against_comfyui(self): + status, data = await self._post(VALIDATE, {"path": "output/b"}) + self.assertEqual((status, data["valid"], data["path"]), (200, True, "output/b")) + status, data = await self._post(VALIDATE, {"path": "output"}) + self.assertEqual(data["label"], "ComfyUI output") + + async def test_invalid_sources_answer_a_code(self): + a_file = self.output_dir / "file.png" + a_file.write_bytes(b"x") + cases = { + "": "empty", + " ": "empty", + str(self.output_dir / "missing"): "not-found", + str(a_file): "not-directory", + str(self.outside_dir): "outside-allowed", + os.path.abspath(os.sep): "filesystem-root", + os.path.expanduser("~"): "home-directory", + os.path.join(str(self.output_dir), "b", "..", "a"): "traversal", + "output/../output/a": "traversal", + str(self.a) + "\x00": "invalid-form", + "\\\\?\\C:\\ComfyUI\\output": "invalid-form", + "x" * 5000: "invalid-form", + } + if os.name != "nt": + cases["C:\\ComfyUI\\output"] = "invalid-form" + for path, code in cases.items(): + with self.subTest(code=code, path=path[:40]): + status, data = await self._post(VALIDATE, {"path": path}) + self.assertEqual(status, 200, data) + self.assertFalse(data["valid"]) + self.assertEqual(data["code"], code) + self.assertIsInstance(data["reason"], str) + self.assertIsNone(data["label"]) + self.assertIsNone(data["path"]) + self.assertNotIn(os.path.expanduser("~"), json.dumps(data)) + + async def test_malformed_request_is_400(self): + for body in ({}, {"path": 42}, {"path": None}, ["x"]): + with self.subTest(body=body): + status, data = await self._post(VALIDATE, body) + self.assertEqual(status, 400, data) + self.assertEqual(data["code"], "invalid-request") + self.assertFalse(data["success"]) + + +class TestSourceEdges(SourcesTestCase): + async def test_validation_failure_is_500_without_paths(self): + def boom(path): + raise OSError(13, "Permission denied", f"{self.tmpdir}/secret") + + with patch.object(sources, "validate_source", boom): + status, data = await self._post(VALIDATE, {"path": str(self.a)}) + self.assertEqual(status, 500) + self.assertEqual(data["error"], "Failed to check the source") + + async def test_oversized_body_is_413_too_large(self): + resp = await self.client.request( + "POST", VALIDATE, data=json.dumps({"path": "x" * (2 << 20)}) + ) + self.assertEqual(resp.status, 413) + self.assertEqual((await resp.json())["code"], "too-large") + + async def test_unreadable_monitor_counts_as_no_live_observer(self): + class Broken: + def get_status(self): + raise RuntimeError("observer gone") + + self._install_monitor(Broken()) + status, data = await self._post( + "/prompt_manager/settings", {"monitoring_enabled": True} + ) + self.assertEqual(status, 200, data) + self.assertEqual(data["restart_required_fields"], ["monitoring_enabled"]) + + def test_labels_skip_getters_that_fail_or_are_missing(self): + import sys + import types + + def fails(): + raise RuntimeError("no input dir") + + fake = types.SimpleNamespace( + get_output_directory=lambda: str(self.output_dir), + get_input_directory=fails, + get_temp_directory=lambda: None, + ) + with patch.dict(sys.modules, {"folder_paths": fake}): + self.assertEqual(sources.source_label(self.output_dir), "ComfyUI output") + self.assertEqual(sources.source_label(self.a), "a") + with patch.dict(sys.modules, {"folder_paths": None}): + self.assertEqual(sources.source_label(self.output_dir), "output") + + +class TestSourceFormError(unittest.TestCase): + """The lexical check that runs before any filesystem access.""" + + def test_windows_forms(self): + err = sources.source_form_error + for path in ( + "C:\\ComfyUI\\output", + "C:/ComfyUI/output", + "\\\\server\\share\\output", + "D:\\images", + ): + with self.subTest(path=path): + self.assertIsNone(err(path, windows=True)) + for path, code in { + "C:output": "invalid-form", # drive-relative + "C:\\out\\a:stream": "invalid-form", # alternate data stream + "C:\\out\\CON": "invalid-form", + "C:\\out\\nul.txt": "invalid-form", + "C:\\out\\folder.": "invalid-form", + "C:\\out\\folder \\sub": "invalid-form", + "\\\\?\\C:\\out": "invalid-form", + "//?/C:/out": "invalid-form", + "\\\\.\\pipe\\x": "invalid-form", + "\\\\server": "invalid-form", + "C:\\out\\..\\in": "traversal", + "output\\..\\input": "traversal", + }.items(): + with self.subTest(path=path): + self.assertEqual(err(path, windows=True), code) + + def test_posix_forms(self): + err = sources.source_form_error + for path in ("/srv/comfy/output", "output/renders", "out/CON", "a b/c."): + with self.subTest(path=path): + self.assertIsNone(err(path, windows=False)) + for path, code in { + "C:\\ComfyUI\\output": "invalid-form", + "C:/ComfyUI/output": "invalid-form", + "\\\\server\\share": "invalid-form", + "out\\renders": "invalid-form", + "/srv/../etc": "traversal", + "../output": "traversal", + "/srv/out\x00": "invalid-form", + "/srv/out\n": "invalid-form", + "": "empty", + }.items(): + with self.subTest(path=path): + self.assertEqual(err(path, windows=False), code) + + +if __name__ == "__main__": + unittest.main()