Merge remote-tracking branch 'origin/main' into feat/249-inspect-local-roles

This commit is contained in:
Vito Sansevero
2026-10-23 01:13:09 -07:00
36 changed files with 3188 additions and 403 deletions
+21
View File
@@ -32,6 +32,7 @@ from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .jobs import JobRegistry
@@ -265,6 +266,7 @@ class PromptManagerAPI(
PromptRoutesMixin,
ImageRoutesMixin,
AdminRoutesMixin,
SettingsRoutesMixin,
JobRoutesMixin,
ImageTagJobsMixin,
LoggingRoutesMixin,
@@ -365,6 +367,24 @@ class PromptManagerAPI(
"""409 envelope for a long job that is already running."""
return _json_error(f"{label} already running", 409)
async def _run_held(self, kind, title, admission, operation):
"""Run a legacy-page operation under a registry hold (D80).
While ``operation()`` runs, the registry lists it as a running,
non-cancellable job, so workspace jobs its admission class conflicts
with are refused, naming it. When the registry refuses the hold, the
legacy 409 envelope names the running job and nothing is done. A
response of 400 or above ends the hold ``failed``.
"""
busy = self._jobs.blocker(kind, admission=admission)
if busy is not None:
return self._long_job_busy_response(busy.title)
async with self._jobs.hold(kind, title, admission=admission) as job:
response = await operation()
if getattr(response, "status", 200) >= 400:
job.finish("failed")
return response
async def _run_in_executor(self, func, *args, **kwargs):
"""Run a blocking function in the default thread pool executor.
@@ -460,6 +480,7 @@ class PromptManagerAPI(
self._register_prompt_routes(routes)
self._register_image_routes(routes)
self._register_admin_routes(routes)
self._register_settings_routes(routes)
self._register_job_routes(routes)
self._register_logging_routes(routes)
self._register_autotag_routes(routes)
+78 -231
View File
@@ -22,16 +22,17 @@ from .jobs import (
SCAN_GROUP_NAMES,
SCAN_GROUPS,
SCAN_SCOPES,
DUPLICATE_CLEANUP_JOB_KIND,
MAINTENANCE_JOB_KIND,
RESTORE_JOB_KIND,
Job,
JobBusy,
)
try:
from ...utils.parallel import map_parallel
from ...utils.validators import validate_result_timeout, validate_worker_threads
except ImportError:
from utils.parallel import map_parallel
from utils.validators import validate_result_timeout, validate_worker_threads
try:
from ...database.operations import PromptDatabase
@@ -502,14 +503,6 @@ class AdminRoutesMixin:
async def get_stats_route(request):
return await self.get_statistics(request)
@routes.get("/prompt_manager/settings")
async def get_settings_route(request):
return await self.get_settings(request)
@routes.post("/prompt_manager/settings")
async def save_settings_route(request):
return await self.save_settings(request)
@routes.get("/prompt_manager/backup")
async def backup_database_route(request):
return await self.backup_database(request)
@@ -651,7 +644,15 @@ class AdminRoutesMixin:
job.finish()
async def cleanup_duplicates_endpoint(self, request):
"""Cleanup duplicate prompts endpoint."""
"""Cleanup duplicate prompts endpoint (holds the registry: records)."""
return await self._run_held(
MAINTENANCE_JOB_KIND,
"Prompt cleanup (admin page)",
"records",
self._cleanup_duplicates,
)
async def _cleanup_duplicates(self):
try:
removed_count = await self._run_in_executor(self.db.cleanup_duplicates)
@@ -766,26 +767,39 @@ class AdminRoutesMixin:
return hash_sha256.hexdigest()
async def delete_duplicate_images_endpoint(self, request):
"""Delete duplicate image files from disk (single flight with the scan)."""
job = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
if job is None:
"""Delete duplicate image files from disk.
Single flight with the duplicate scan, and a records hold on the
registry, so no scan, restore or tagging job runs meanwhile. A body
without paths is refused before the hold (it is no job).
"""
data, error_response = await _read_json(request)
if error_response is not None:
return error_response
image_paths = data.get("image_paths", [])
if not image_paths or not isinstance(image_paths, list):
return web.json_response(
{"success": False, "error": "No image paths provided"},
status=400,
)
guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
if guard is None:
return self._long_job_busy_response("Duplicate scan or delete")
async with job:
return await self._delete_duplicate_images(request)
async def _delete_duplicate_images(self, request):
async def delete():
# No await since the check above: the lock is still free.
async with guard:
return await self._delete_duplicate_images(image_paths)
return await self._run_held(
DUPLICATE_CLEANUP_JOB_KIND,
"Duplicate image delete (gallery page)",
"records",
delete,
)
async def _delete_duplicate_images(self, image_paths):
try:
data, error_response = await _read_json(request)
if error_response is not None:
return error_response
image_paths = data.get("image_paths", [])
if not image_paths or not isinstance(image_paths, list):
return web.json_response(
{"success": False, "error": "No image paths provided"},
status=400,
)
output_dir = await self._run_in_executor(self._find_comfyui_output_dir)
result = await self._run_in_executor(
self._delete_duplicate_images_sync, image_paths, output_dir
@@ -882,208 +896,6 @@ class AdminRoutesMixin:
status=500,
)
async def get_settings(self, request):
"""Get current settings."""
try:
from ..config import PromptManagerConfig, GalleryConfig
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
]
return web.json_response(
{
"success": True,
"settings": {
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
"worker_threads": PromptManagerConfig.WORKER_THREADS,
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
"monitored_directories": [
self._public_path(d) for d in self._monitored_directories()
],
},
}
)
except Exception as e:
self.logger.error(f"Get settings error: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": "Failed to get settings"},
status=500,
)
def _monitored_directories(self):
"""Directories the running image monitor watches, else the configured roots."""
from ..config import GalleryConfig
monitor = _current_image_monitor()
if monitor is not None:
return list(getattr(monitor, "monitored_directories", []))
return list(GalleryConfig.MONITORING_DIRECTORIES)
async def save_settings(self, request):
"""Save settings: the whole payload is validated before anything changes."""
try:
from ..config import PromptManagerConfig
data, error_response = await _read_json(request)
if error_response is not None:
return error_response
try:
updates = self._validate_settings(data)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
new_roots, error = self._parse_gallery_roots(data)
if error:
return web.json_response({"success": False, "error": error}, status=400)
for name, value in updates.items():
setattr(PromptManagerConfig, name, value)
restart_required = False
if new_roots is not None:
restart_required = self._apply_gallery_roots(new_roots)
self._persist_settings()
return web.json_response(
{
"success": True,
"message": "Settings saved successfully",
"restart_required": restart_required,
}
)
except Exception as e:
self.logger.error(f"Save settings error: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": "Failed to save settings"},
status=500,
)
@staticmethod
def _validate_settings(data):
"""Scalar settings of a payload as ``{CONFIG_ATTR: value}``.
Raises ValueError with a user-facing message on the first bad value;
nothing is applied until every value passed.
"""
from ..config import PromptManagerConfig
updates = {}
if "result_timeout" in data:
updates["RESULT_TIMEOUT"] = validate_result_timeout(data["result_timeout"])
if "webui_display_mode" in data:
mode = data["webui_display_mode"]
if mode not in ("popup", "newtab"):
raise ValueError("webui_display_mode must be 'popup' or 'newtab'")
updates["WEBUI_DISPLAY_MODE"] = mode
if "infinite_scroll" in data:
if not isinstance(data["infinite_scroll"], bool):
raise ValueError("infinite_scroll must be true or false")
updates["INFINITE_SCROLL"] = data["infinite_scroll"]
if "worker_threads" in data:
updates["WORKER_THREADS"] = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
)
return updates
def _parse_gallery_roots(self, data):
"""Extract and validate gallery roots from a settings payload.
Accepts ``gallery_root_paths`` (list, preferred) or the legacy single
``gallery_root_path`` string. Blank entries are dropped.
Returns:
(roots, error): ``roots`` is the validated list, or ``None`` when
the payload carries no gallery root key; ``error`` is a message
when validation failed (and ``roots`` is then ``None``).
"""
if "gallery_root_paths" in data:
raw = data["gallery_root_paths"]
if not isinstance(raw, list):
return None, "gallery_root_paths must be a list"
elif "gallery_root_path" in data:
raw = [data["gallery_root_path"]]
else:
return None, None
from ..config import GalleryConfig
roots = []
for entry in raw:
if not isinstance(entry, str):
return None, "Gallery root paths must be strings"
entry = entry.strip()
if not entry:
continue
ok, reason = GalleryConfig.validate_gallery_root(entry)
if not ok:
return (
None,
f"Invalid gallery root '{os.path.basename(entry)}': {reason}",
)
roots.append(GalleryConfig.resolve_gallery_root(entry))
return roots, None
def _apply_gallery_roots(self, roots):
"""Install validated gallery roots; returns True when they changed."""
from ..config import GalleryConfig
if roots == list(GalleryConfig.MONITORING_DIRECTORIES):
return False
GalleryConfig.MONITORING_DIRECTORIES = roots
self._cached_output_dir = None
self.reset_gallery_roots_cache()
return True
def _persist_settings(self):
"""Merge the user-editable settings into the configured config.json.
Only the keys this endpoint owns are rewritten; every other section
(integrations, database, performance, ...) and every other key in
``web_ui``/``gallery`` is kept as it was. A config file that cannot
be parsed is replaced.
"""
from ..config import GalleryConfig, PromptManagerConfig, write_private_json
config_file = PromptManagerConfig.get_config_path()
existing = self._read_config_file(config_file)
web_ui = dict(existing.get("web_ui") or {})
web_ui["result_timeout"] = PromptManagerConfig.RESULT_TIMEOUT
web_ui["webui_display_mode"] = PromptManagerConfig.WEBUI_DISPLAY_MODE
web_ui["infinite_scroll"] = PromptManagerConfig.INFINITE_SCROLL
gallery = dict(existing.get("gallery") or {})
monitoring = dict(gallery.get("monitoring") or {})
monitoring["directories"] = list(GalleryConfig.MONITORING_DIRECTORIES)
gallery["monitoring"] = monitoring
performance = dict(existing.get("performance") or {})
performance["worker_threads"] = PromptManagerConfig.WORKER_THREADS
config_data = {
**existing,
"web_ui": web_ui,
"gallery": gallery,
"performance": performance,
}
try:
write_private_json(config_file, config_data)
self.logger.info(f"Settings saved to {config_file}")
except OSError as save_err:
self.logger.warning(f"Could not save config file: {save_err}")
def _read_config_file(self, config_file):
"""Current contents of config.json as a dict, or {} when unusable."""
try:
with open(config_file, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
async def run_diagnostics(self, request):
"""Run comprehensive system diagnostics and health checks."""
try:
@@ -1176,6 +988,11 @@ class AdminRoutesMixin:
"vacuum",
"cleanup_orphaned_images",
)
# Operations that only read; a run of only these holds the registry as
# "read", every other run as "records".
READ_ONLY_MAINTENANCE_OPERATIONS = frozenset(
{"check_hash_duplicates", "statistics", "check_consistency"}
)
async def run_maintenance(self, request):
"""Perform database maintenance operations and report each outcome."""
@@ -1188,7 +1005,27 @@ class AdminRoutesMixin:
operations = data.get(
"operations", list(self.DEFAULT_MAINTENANCE_OPERATIONS)
)
except Exception as e:
self.logger.error(f"Maintenance error: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": "Maintenance failed"}, status=500
)
writes = not (
isinstance(operations, list)
and all(
isinstance(name, str) and name in self.READ_ONLY_MAINTENANCE_OPERATIONS
for name in operations
)
)
return await self._run_held(
MAINTENANCE_JOB_KIND,
"Maintenance (admin page)",
"records" if writes else "read",
lambda: self._run_maintenance(operations),
)
async def _run_maintenance(self, operations):
try:
results = await self._run_in_executor(
self._run_maintenance_operations, operations
)
@@ -1342,8 +1179,18 @@ class AdminRoutesMixin:
The upload is streamed to a temp file (capped at restore_max_bytes),
verified with PromptModel.verify_database_file and only then copied
over the live database, which is backed up first.
over the live database, which is backed up first. The whole request
holds the registry (records): it is refused while a scan or another
record-changing job runs, and those are refused while it runs.
"""
return await self._run_held(
RESTORE_JOB_KIND,
"Database restore (admin page)",
"records",
lambda: self._restore_database(request),
)
async def _restore_database(self, request):
max_bytes = getattr(self, "restore_max_bytes", 100 * 1024 * 1024)
temp_path = None
try:
+35
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Optional
from urllib.parse import quote
from .jobs import AUTOTAG_BATCH_JOB_KIND
from .prompts import thumbnail_url_for
from .. import model_gate
@@ -63,6 +64,12 @@ def path_is_within(candidate, root) -> bool:
return child != parent and child.is_relative_to(parent)
async def _refusal(title):
"""The single error event of an auto-tag batch refused by another job."""
message = f"{title} already running"
yield {"type": "error", "status": "error", "message": message}
class AutotagRoutesMixin:
"""Mixin providing auto-tagging API endpoints."""
@@ -425,6 +432,8 @@ class AutotagRoutesMixin:
"general_threshold and character_threshold must be numeric"
)
errors = [] # an error event ends the run; Activity lists it failed
async def events():
try:
from ..autotag import get_autotag_service
@@ -432,14 +441,40 @@ class AutotagRoutesMixin:
async for event in self._autotag_batch_events(
get_autotag_service(), opts
):
if event.get("type") == "error":
errors.append(event)
yield event
except Exception:
self.logger.exception("AutoTag error")
errors.append(_INTERNAL_ERROR)
yield {
"type": "error",
"message": _INTERNAL_ERROR,
}
# The batch writes prompt tags: a records hold on the registry, so it
# never runs beside a scan, restore or the workspace tagging job.
busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records")
if busy is not None:
if busy.kind == AUTOTAG_BATCH_JOB_KIND: # unchanged payload
return web.json_response(
{"success": False, "error": _BUSY_ERROR}, status=409
)
# Both legacy pages read this route as a stream, where a 409 reads
# as a lost connection: refuse with one error frame instead (the
# admin page keys on ``type``, the gallery on ``status``).
return await self._stream_sse(request, _refusal(busy.title))
async with self._jobs.hold(
AUTOTAG_BATCH_JOB_KIND,
"Auto-tag images (legacy page)",
admission="records",
) as job:
response = await self._start_autotag_batch(request, opts, events)
if response.status >= 400 or errors:
job.finish("failed")
return response
async def _start_autotag_batch(self, request, opts, events):
if not _autotag_batch_lock.acquire(blocking=False):
return web.json_response(
{"success": False, "error": _BUSY_ERROR}, status=409
+54 -8
View File
@@ -19,6 +19,7 @@ from typing import Callable, NamedTuple
from aiohttp import web
from PIL import Image, UnidentifiedImageError
from .jobs import THUMBNAILS_JOB_KIND
from .prompts import bad_request, parse_page_params, publish_image_paths
from .prompts import thumbnail_url_for
from .prompts import safe_error_message as _safe_error
@@ -104,6 +105,10 @@ def _card_header(path: Path) -> tuple:
return inspection.width, inspection.height, records
# The legacy gallery page's thumbnail generation, as Activity lists it (D80).
THUMBNAIL_GENERATION_TITLE = "Thumbnail generation (gallery page)"
class _CardFacts(NamedTuple):
"""One image file's card facts, derived from its header alone.
@@ -1736,11 +1741,24 @@ class ImageRoutesMixin:
return cls.THUMBNAIL_SIZES.get(quality, cls.THUMBNAIL_SIZES["medium"])
async def generate_thumbnails(self, request):
"""Generate every thumbnail in one executor job (POST, blocking)."""
"""Generate every thumbnail in one executor job (POST, blocking).
Holds the registry as guarded work: refused while a record-changing
job runs, and such jobs are refused while it runs. A body that cannot
be read is refused before the hold, so it is never listed as a job.
"""
data, error = await _json_body_or_error(request)
if error is not None:
return error
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_GENERATION_TITLE,
"guarded",
lambda: self._generate_thumbnails(data),
)
async def _generate_thumbnails(self, data):
try:
data, error = await _json_body_or_error(request)
if error is not None:
return error
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
output_dirs = self._get_all_output_dirs()
@@ -1935,14 +1953,32 @@ class ImageRoutesMixin:
"Connection": "keep-alive",
},
)
await response.prepare(request)
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if busy is not None:
# The gallery reads this stream: refuse with its error event.
await response.prepare(request)
message = f"{busy.title} already running"
await self._emit_sse(
response, "error", {"error": message, "message": message}
)
return response
async with self._jobs.hold(
THUMBNAILS_JOB_KIND, THUMBNAIL_GENERATION_TITLE, admission="guarded"
) as job:
await response.prepare(request)
if not await self._thumbnail_progress_stream(response, thumbnail_size):
job.finish("failed") # an error event ended the stream
return response
async def _thumbnail_progress_stream(self, response, thumbnail_size) -> bool:
"""Stream one generation run; False when it ended with an error event."""
try:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
await self._emit_sse(
response, "error", {"error": "ComfyUI output directory not found"}
)
return response
return False
await self._emit_sse(
response,
@@ -1960,6 +1996,7 @@ class ImageRoutesMixin:
remaining=plan["remaining"],
)
self.invalidate_gallery_cache()
return True
except Exception:
self.logger.exception("Thumbnail generation failed")
await self._emit_sse(
@@ -1970,7 +2007,7 @@ class ImageRoutesMixin:
"message": "Thumbnail generation failed. Check server logs.",
},
)
return response
return False
async def _stream_thumbnails(
self, response, targets, thumbnail_size, skipped=0, remaining=0
@@ -2179,8 +2216,17 @@ class ImageRoutesMixin:
"""Clear generated thumbnails across gallery roots, preserving originals.
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500.
return 404, and unexpected filesystem failures return 500. Holds the
registry as guarded work, like generation.
"""
return await self._run_held(
THUMBNAILS_JOB_KIND,
"Thumbnail clear (gallery page)",
"guarded",
self._clear_thumbnails,
)
async def _clear_thumbnails(self) -> web.Response:
try:
output_dirs = self._get_all_output_dirs()
if not output_dirs:
+161 -25
View File
@@ -9,9 +9,21 @@ as a snapshot and then every event published after it, until the job ends.
Jobs live only in server memory and are gone after a restart. The registry
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
ones. Record-changing jobs (the metadata scan, bulk image tagging, and later
maintenance, cleanup and restore) never run together, and one job of a kind
runs at a time.
ones. One job of a kind runs at a time, and each job has an admission class
(``ADMISSIONS``) that decides what may run beside it:
- ``records`` changes library records or files (the metadata scan, bulk image
tagging, and the legacy restore, maintenance, cleanup, duplicate delete,
LoRA import and batch auto-tag routes). It is refused while any
``records`` or ``guarded`` job runs.
- ``guarded`` must not overlap record changes but may overlap other guarded
work (thumbnail generation and clearing, later backup). It is refused while
a ``records`` job runs.
- ``read`` only reads; it is refused only by a running job of its own kind.
A short legacy-page operation takes a ``hold``: a running, non-cancellable
job that lasts as long as the request, so the Activity page lists it and a
workspace job is refused while it runs (and the other way round).
"""
import asyncio
@@ -60,6 +72,41 @@ SCAN_GROUPS = {
IMAGE_TAG_JOB_KIND = "image-tags"
IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors")
ADMISSIONS = ("read", "guarded", "records")
# What each admission class is refused beside (besides its own kind).
_REFUSED_BESIDE = {
"read": frozenset(),
"guarded": frozenset({"records"}),
"records": frozenset({"records", "guarded"}),
}
# Kinds of the legacy-page operations that hold the registry (D80; titles in
# their handlers end in "(admin page)", "(gallery page)" or "(legacy page)").
RESTORE_JOB_KIND = "restore"
MAINTENANCE_JOB_KIND = "maintenance"
DUPLICATE_CLEANUP_JOB_KIND = "duplicate_cleanup"
THUMBNAILS_JOB_KIND = "thumbnails"
LORA_IMPORT_JOB_KIND = "lora_import"
AUTOTAG_BATCH_JOB_KIND = "autotag"
def resolve_admission(admission=None, record_changing=None) -> str:
"""The admission class for a start call; ``record_changing`` is an alias.
``record_changing=True`` means ``records`` and ``False`` means ``read``;
with neither given the class is ``read``. Raises ValueError for an
unknown class or one that contradicts ``record_changing``.
"""
if admission is None:
return "records" if record_changing else "read"
if admission not in ADMISSIONS:
raise ValueError(f"Unknown job admission {admission!r}")
if record_changing is not None and record_changing != (admission == "records"):
raise ValueError(
f"record_changing={record_changing!r} contradicts admission {admission!r}"
)
return admission
def _relative_path(path) -> str:
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
@@ -85,13 +132,23 @@ class Job:
"""One tracked operation: state, counters, outcomes, log and subscribers."""
def __init__(
self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None
self,
kind,
title,
*,
admission=None,
record_changing=None,
cancellable=True,
scope=None,
groups=(),
clock=None,
):
self._clock = clock or time.time
self.id = uuid.uuid4().hex
self.kind = kind
self.title = title
self.record_changing = record_changing
self.admission = resolve_admission(admission, record_changing)
self.cancellable = bool(cancellable)
self.scope = dict(scope or {})
self.state = "running"
self.started_at = self._clock()
@@ -110,6 +167,10 @@ class Job:
self.task = None
self._subscribers = set()
@property
def record_changing(self):
return self.admission == "records"
@property
def running(self):
return self.state == "running"
@@ -240,6 +301,9 @@ class Job:
# What detail() lists, so a view re-reads it only when it changed.
"listed": {group: len(rows) for group, rows in self.outcomes.items()},
"log_length": len(self.log),
# A view offers Cancel only when the server can stop the job.
"cancellable": self.cancellable,
"admission": self.admission,
}
def detail(self):
@@ -291,33 +355,88 @@ class JobRegistry:
self._logger = logger_ or logger
self._jobs = {}
def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job:
def start(
self,
kind,
title,
*,
admission=None,
record_changing=None,
cancellable=True,
scope=None,
runner,
groups=(),
) -> Job:
"""Create a job and run ``runner(job)`` as a task.
Raises JobBusy, naming the job in the way, when a job of ``kind`` is
running or when ``record_changing`` and another record-changing job
runs. Must be called from the event loop.
``admission`` is one of ``ADMISSIONS``; ``record_changing=True`` is
the older spelling of ``records`` and ``False`` of ``read``. One of
the two is required (TypeError otherwise), so no new job runs as
``read`` beside a scan by omission. Raises JobBusy, naming the job in
the way, when the admission class refuses it (see the module
docstring). Must be called from the event loop.
"""
blocker = self.blocker(kind, record_changing=record_changing)
if blocker is not None:
raise JobBusy(blocker)
self._prune()
job = Job(
kind, title, record_changing=record_changing, scope=scope, groups=groups
if admission is None and record_changing is None:
raise TypeError(
f"JobRegistry.start({kind!r}) needs an admission class "
"(admission= or record_changing=)"
)
job = self._admit(
kind,
title,
admission=admission,
record_changing=record_changing,
cancellable=cancellable,
scope=scope,
groups=groups,
)
self._jobs[job.id] = job
job.task = asyncio.ensure_future(self._run(job, runner))
return job
def blocker(self, kind, *, record_changing):
"""The running job a new ``kind`` job would be refused for, or None."""
def hold(self, kind, title, *, admission, scope=None):
"""A running, non-cancellable job for a legacy-page operation.
Returns an async context manager. Entering it registers the job and
yields it, or raises JobBusy at once, without waiting, when the
admission class refuses it; nothing is registered until then, so a
hold that is never entered leaves no job behind. The job ends when
the block exits: ``finished``, or ``failed`` when it raised (unless
the body already ended it). A caller that answers a refusal in its
own way checks ``blocker(kind, admission=...)`` first, with no await
before the ``async with``.
"""
resolve_admission(admission) # an unknown class fails at the call
return _Hold(
lambda: self._admit(
kind, title, admission=admission, cancellable=False, scope=scope
)
)
def _admit(self, kind, title, *, admission, record_changing=None, **job_args):
resolved = resolve_admission(admission, record_changing)
blocker = self.blocker(kind, admission=resolved)
if blocker is not None:
raise JobBusy(blocker)
self._prune()
job = Job(kind, title, admission=resolved, **job_args)
self._jobs[job.id] = job
return job
def blocker(self, kind, *, admission=None, record_changing=None):
"""The running job a new ``kind`` job would be refused for, or None.
The same kind refuses first; then the oldest running job whose class
the new job's admission class may not run beside.
"""
same_kind = self.active(kind)
if same_kind is not None:
return same_kind
if record_changing:
for job in self._jobs.values():
if job.running and job.record_changing:
return job
refused = _REFUSED_BESIDE[resolve_admission(admission, record_changing)]
running = [job for job in self._jobs.values() if job.running]
running.sort(key=lambda job: job._started_seq)
for job in running:
if job.admission in refused:
return job
return None
async def _run(self, job, runner):
@@ -362,11 +481,28 @@ class JobRegistry:
def cancel(self, job_id) -> bool:
"""Ask a running job to stop at its next safe boundary.
Returns whether a cancellation was requested: False for a finished or
unknown job, so a cancel after the end is a no-op.
Returns whether a cancellation was requested: False for a finished,
unknown or non-cancellable job, so a cancel after the end is a no-op.
"""
job = self._jobs.get(job_id)
if job is None or job.finished:
if job is None or job.finished or not job.cancellable:
return False
job.cancelled = True
return True
class _Hold:
"""The async context manager ``JobRegistry.hold`` returns."""
def __init__(self, admit):
self._admit = admit
self._job = None
async def __aenter__(self):
self._job = self._admit()
return self._job
async def __aexit__(self, exc_type, exc, tb):
if self._job.running:
self._job.finish("finished" if exc_type is None else "failed")
return False
+104 -20
View File
@@ -5,12 +5,47 @@ from pathlib import Path
from aiohttp import web
from ..config_store import ConfigUnreadable, RevisionConflict, get_config_store
from .jobs import LORA_IMPORT_JOB_KIND
# Flags of POST /lora/enable; each must be a JSON boolean when present.
_LORA_FLAGS = ("enabled", "trigger_words_enabled", "clear_civitai_api_key")
_LORA_PATH_ERROR = (
"LoraManager path must be a directory under ComfyUI's custom_nodes "
"whose name contains 'lora'"
)
def _lora_settings_mutation(values):
"""The config commit for a LoRA save: only ``integrations.lora_manager``.
The submitted keys replace their values; other LoRA keys, other
integrations and every other section stay as they are on disk.
"""
def mutate(config):
integrations = config.get("integrations")
integrations = dict(integrations) if isinstance(integrations, dict) else {}
lora = integrations.get("lora_manager")
lora = dict(lora) if isinstance(lora, dict) else {}
lora.update(values)
integrations["lora_manager"] = lora
config["integrations"] = integrations
return config
return mutate
def _lora_error_frame(message):
"""An import stream's error event, as the admin page's modal reads it.
The modal shows ``status`` and ``progress`` from every frame and closes
on ``type: error``, naming ``message``.
"""
return {"type": "error", "status": message, "progress": 0, "message": message}
class LoraIntegrationMixin:
"""Mixin providing LoraManager detection, scanning, and trigger word endpoints."""
@@ -131,14 +166,25 @@ class LoraIntegrationMixin:
return allowed, None
async def lora_enable(self, request):
"""Enable or disable LoraManager integration and save to config.json."""
"""Enable or disable LoraManager integration and save it to config.json.
The values are committed through the config store first (#220) and
published at runtime only once config.json holds them; a save that
cannot be written reports ``persisted: false`` and changes nothing.
The CivitAI key is never echoed back.
"""
try:
data = await request.json()
data, error = await self._read_json_body(request)
if error is not None:
return error
for flag in _LORA_FLAGS:
if flag in data and not isinstance(data[flag], bool):
return self._bad_lora_request(f"{flag} must be true or false")
enabled = data.get("enabled", False)
path = data.get("path", "")
trigger_words = data.get("trigger_words_enabled", False)
from ..config import IntegrationConfig, PromptManagerConfig
from ..config import IntegrationConfig
from ..lora_utils import get_trigger_cache
# The key is never echoed back to the UI, so a blank or absent
@@ -161,20 +207,40 @@ class LoraIntegrationMixin:
if error is not None:
return error
# Update in-memory config
IntegrationConfig.LORA_MANAGER_ENABLED = enabled
IntegrationConfig.LORA_MANAGER_PATH = path
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words
IntegrationConfig.CIVITAI_API_KEY = civitai_key
values = {
"enabled": enabled,
"path": path,
"trigger_words_enabled": trigger_words,
"civitai_api_key": civitai_key,
}
# Persist to the configured config.json
PromptManagerConfig.save_to_file()
def publish(_config):
# Under the commit lock, so the last save written is the one
# the runtime keeps.
IntegrationConfig.LORA_MANAGER_ENABLED = enabled
IntegrationConfig.LORA_MANAGER_PATH = path
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = trigger_words
IntegrationConfig.CIVITAI_API_KEY = civitai_key
try:
_saved, revision = await self._run_in_executor(
get_config_store().commit,
_lora_settings_mutation(values),
expected_revision=None,
publish=publish,
)
except (ConfigUnreadable, RevisionConflict, OSError) as exc:
return self._config_commit_failure(exc)
# Load trigger word cache if enabling
cache = get_trigger_cache()
if enabled and trigger_words and path:
count = await self._run_in_executor(cache.load, path)
self.logger.info(f"Trigger word cache loaded: {count} LoRAs")
try:
count = await self._run_in_executor(cache.load, path)
self.logger.info(f"Trigger word cache loaded: {count} LoRAs")
except Exception as e:
# Saved and enabled; only the trigger words are missing.
self.logger.warning(f"Trigger word cache could not be loaded: {e}")
cache.clear()
elif not enabled:
cache.clear()
@@ -184,6 +250,10 @@ class LoraIntegrationMixin:
"enabled": enabled,
"path": self._public_path(path),
"trigger_words_enabled": trigger_words,
"persisted": True,
"revision": revision,
"runtime_applied": True,
"cache_loaded": cache.is_loaded,
}
)
except Exception as e:
@@ -218,19 +288,33 @@ class LoraIntegrationMixin:
reason="OK",
headers={"Content-Type": "text/event-stream"},
)
await response.prepare(request)
async def send_progress(data):
line = f"data: {json.dumps(data)}\n\n"
await response.write(line.encode("utf-8"))
try:
await self._lora_scan_stream(allowed, send_progress)
except Exception as e:
self.logger.exception("LoRA scan failed")
await send_progress({"type": "error", "message": self._public_error(e)})
# The import replaces prompts and images: a records hold, so it
# never runs beside a scan, restore or tagging job.
busy = self._jobs.blocker(LORA_IMPORT_JOB_KIND, admission="records")
if busy is not None:
await response.prepare(request)
message = f"{busy.title} already running"
await send_progress(_lora_error_frame(message))
await response.write_eof()
return response
await response.write_eof()
async with self._jobs.hold(
LORA_IMPORT_JOB_KIND, "LoRA import (admin page)", admission="records"
) as job:
await response.prepare(request)
try:
await self._lora_scan_stream(allowed, send_progress)
except Exception as e:
self.logger.exception("LoRA scan failed")
job.finish("failed")
await send_progress(_lora_error_frame(self._public_error(e)))
await response.write_eof()
return response
except Exception as e:
+339
View File
@@ -0,0 +1,339 @@
"""Settings API routes: GET and POST /prompt_manager/settings (#220).
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.
"""
import os
from aiohttp import web
from ..config_store import (
ConfigUnreadable,
RevisionConflict,
get_config_store,
)
from .admin import _current_image_monitor
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
except ImportError:
from utils.validators import validate_result_timeout, validate_worker_threads
# Settings owned by POST /settings: config attribute -> (section, key).
_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"),
}
def _section(config, name):
"""``config[name]`` as a fresh dict (a non-object section starts empty)."""
value = config.get(name)
return dict(value) if isinstance(value, dict) else {}
def _settings_mutation(updates, new_roots):
"""The config commit for a settings save: only the submitted keys change.
Every other section and key (integrations and the CivitAI key, database,
unknown sections) is kept as it is on disk.
"""
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
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
monitoring["directories"] = list(new_roots)
gallery["monitoring"] = monitoring
config["gallery"] = gallery
return config
return mutate
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
return await _read_json_body(request)
class SettingsRoutesMixin:
"""Mixin providing the settings read and save endpoints."""
def _register_settings_routes(self, routes):
@routes.get("/prompt_manager/settings")
async def get_settings_route(request):
return await self.get_settings(request)
@routes.post("/prompt_manager/settings")
async def save_settings_route(request):
return await self.save_settings(request)
async def get_settings(self, request):
"""Get current settings."""
try:
from ..config import PromptManagerConfig, GalleryConfig
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
]
revision = await self._run_in_executor(self._config_revision)
return web.json_response(
{
"success": True,
**revision,
"settings": {
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
"worker_threads": PromptManagerConfig.WORKER_THREADS,
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
"monitored_directories": [
self._public_path(d) for d in self._monitored_directories()
],
},
}
)
except Exception as e:
self.logger.error(f"Get settings error: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": "Failed to get settings"},
status=500,
)
def _monitored_directories(self):
"""Directories the running image monitor watches, else the configured roots."""
from ..config import GalleryConfig
monitor = _current_image_monitor()
if monitor is not None:
return list(getattr(monitor, "monitored_directories", []))
return list(GalleryConfig.MONITORING_DIRECTORIES)
async def save_settings(self, request):
"""Save settings: validate, commit to config.json, then publish (#220).
Nothing changes at runtime until config.json holds the new values, so
a save that cannot be written reports ``persisted: false`` and leaves
the running settings as they were. ``expected_revision`` (the
``revision`` from GET) refuses to overwrite a file changed meanwhile;
without it the payload merges into what is on disk (legacy page).
"""
try:
data, error_response = await _read_json(request)
if error_response is not None:
return error_response
expected_revision = data.get("expected_revision")
if expected_revision is not None and not isinstance(expected_revision, str):
return web.json_response(
{"success": False, "error": "expected_revision must be a string"},
status=400,
)
try:
updates = self._validate_settings(data)
except ValueError as ve:
return web.json_response(
{"success": False, "error": str(ve)}, status=400
)
new_roots, error = self._parse_gallery_roots(data)
if error:
return web.json_response({"success": False, "error": error}, status=400)
published = {}
def publish(_config):
# Under the commit lock: saves reach the runtime in the order
# 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)
return web.json_response(
{
"success": True,
"message": "Settings saved successfully",
"restart_required": bool(published["restart_required_fields"]),
"persisted": True,
"revision": revision,
**published,
}
)
except Exception as e:
self.logger.error(f"Save settings error: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": "Failed to save settings"},
status=500,
)
def _publish_settings(self, updates, new_roots):
"""Install committed settings at runtime (inside the config commit).
The values are already durable. A publication that fails is reported
as ``runtime_applied: false`` with the fields that need a restart;
nothing is retried or rolled back. Never raises.
"""
from ..config import PromptManagerConfig
for name, value in updates.items():
setattr(PromptManagerConfig, name, value)
restart_fields = []
runtime_applied = True
if new_roots is not None:
try:
if self._apply_gallery_roots(new_roots):
restart_fields.append("gallery_root_paths")
except Exception as e:
self.logger.error(
f"Saved gallery roots could not be applied: {e}", exc_info=True
)
runtime_applied = False
restart_fields.append("gallery_root_paths")
return {
"runtime_applied": runtime_applied,
"restart_required_fields": restart_fields,
}
def _config_commit_failure(self, exc):
"""The response for a config commit that wrote nothing."""
not_saved = {"success": False, "persisted": False, "runtime_applied": False}
if isinstance(exc, ConfigUnreadable):
self.logger.warning(f"Settings not saved: {exc}")
return web.json_response(
{
**not_saved,
"code": "config-unreadable",
"error": (
"config.json cannot be read, so nothing was saved. "
"Fix or remove the file, then save again."
),
},
status=409,
)
if isinstance(exc, RevisionConflict):
return web.json_response(
{
**not_saved,
"code": "settings-changed",
"error": "Settings changed since they were loaded. Reload them.",
"revision": exc.current,
},
status=409,
)
self.logger.error(f"Could not write config.json: {exc}")
return web.json_response(
{
**not_saved,
"code": "config-write-failed",
"error": "Settings could not be written to config.json.",
},
status=500,
)
@staticmethod
def _config_revision():
"""``{"revision": ...}``, or a null revision with ``config_error``."""
try:
_data, revision = get_config_store().read()
except ConfigUnreadable:
return {"revision": None, "config_error": "config-unreadable"}
return {"revision": revision}
@staticmethod
def _validate_settings(data):
"""Scalar settings of a payload as ``{CONFIG_ATTR: value}``.
Raises ValueError with a user-facing message on the first bad value;
nothing is applied until every value passed.
"""
from ..config import PromptManagerConfig
updates = {}
if "result_timeout" in data:
updates["RESULT_TIMEOUT"] = validate_result_timeout(data["result_timeout"])
if "webui_display_mode" in data:
mode = data["webui_display_mode"]
if mode not in ("popup", "newtab"):
raise ValueError("webui_display_mode must be 'popup' or 'newtab'")
updates["WEBUI_DISPLAY_MODE"] = mode
if "infinite_scroll" in data:
if not isinstance(data["infinite_scroll"], bool):
raise ValueError("infinite_scroll must be true or false")
updates["INFINITE_SCROLL"] = data["infinite_scroll"]
if "worker_threads" in data:
updates["WORKER_THREADS"] = validate_worker_threads(
data["worker_threads"], PromptManagerConfig.max_worker_threads()
)
return updates
def _parse_gallery_roots(self, data):
"""Extract and validate gallery roots from a settings payload.
Accepts ``gallery_root_paths`` (list, preferred) or the legacy single
``gallery_root_path`` string. Blank entries are dropped.
Returns:
(roots, error): ``roots`` is the validated list, or ``None`` when
the payload carries no gallery root key; ``error`` is a message
when validation failed (and ``roots`` is then ``None``).
"""
if "gallery_root_paths" in data:
raw = data["gallery_root_paths"]
if not isinstance(raw, list):
return None, "gallery_root_paths must be a list"
elif "gallery_root_path" in data:
raw = [data["gallery_root_path"]]
else:
return None, None
from ..config import GalleryConfig
roots = []
for entry in raw:
if not isinstance(entry, str):
return None, "Gallery root paths must be strings"
entry = entry.strip()
if not entry:
continue
ok, reason = GalleryConfig.validate_gallery_root(entry)
if not ok:
return (
None,
f"Invalid gallery root '{os.path.basename(entry)}': {reason}",
)
roots.append(GalleryConfig.resolve_gallery_root(entry))
return roots, None
def _apply_gallery_roots(self, roots):
"""Install validated gallery roots; returns True when they changed."""
from ..config import GalleryConfig
if roots == list(GalleryConfig.MONITORING_DIRECTORIES):
return False
GalleryConfig.MONITORING_DIRECTORIES = roots
self._cached_output_dir = None
self.reset_gallery_roots_cache()
return True