Files
JasonHoku-ComfyUI-Ultimate-…/run_api.py
T
Jason HokuandClaude Fable 5.1 e35030f0a4 fix: ultrareview slice D (waves 2+3) findings
- lazy shell: SAVE never persists __uscg_lazy__ (saveState guard + save_manifest pop); the manifest
  fetch route heals files that already carry it
- dashboard node: a Builder session switch does a full reload (session_name from the payload, not
  the stale widget) instead of re-fetching the old session
- dashboard node: window message handlers only accept a dashboard iframe as source
- scanned ids are namespaced by session (same relpath in two sessions no longer collides on merge);
  rescan merge donates each old id once and only via unambiguous basenames
- deferred upscale resume is keyed on base_fp == config_fp (fixed-seed grids upscale every cell)
- legacy distributed companion ignores the preview pass: master keeps the upscale phase
- connected latent: recompute w/h before gen_w/gen_h so 16x models record real dimensions
- Start on an identical paused sweep resumes it
- outputFromKey keeps subgraph node ids ("57:35") whole

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 15:25:46 -08:00

199 lines
8.1 KiB
Python

"""Pure helpers for the headless run API (POST /config_tester/run, GET /config_tester/run_status).
No aiohttp / ComfyUI imports at module level so the helpers are unit-testable on their own.
The routes themselves live in __init__.py.
"""
import json
import os
import re
from collections import OrderedDict
DEFAULT_SESSION = "my_session" # the Generator node's own default (sampler_node.py)
_MAX_TRACKED_RUNS = 200
def sanitize_session_name_strict(name):
"""Same regex as delete_session / save_manifest / get_session_html. '' when nothing is left."""
return re.sub(r'[^\w\-]', '', str(name or ''))
def _etag_matches(if_none_match, etag):
"""RFC 9110 weak comparison of an If-None-Match header against our ETag ('*' matches anything)."""
if not if_none_match:
return False
want = etag[2:] if etag.startswith("W/") else etag
for part in str(if_none_match).split(","):
part = part.strip()
if part == "*":
return True
if part.startswith("W/"):
part = part[2:]
if part == want:
return True
return False
def load_manifest_for_fetch(benchmarks_base, raw_session, if_none_match=None):
"""Backend of GET /config_tester/manifest?session=<name> (the dashboard SPA's data fetch).
Returns (status, body_bytes_or_None, headers). One attempt only: status 503 means a torn read
(the file changed while we read it) or invalid JSON (save_manifest is not atomic), which the
route retries a few times before answering. The body is the manifest re-serialised COMPACT
(the on-disk file is indent=4, ~1.9x bigger). ETag = weak validator from mtime_ns + size.
"""
session = sanitize_session_name_strict(raw_session)
if not session:
return 400, json.dumps({"error": "missing or invalid session"}).encode(), {}
base = os.path.realpath(benchmarks_base)
path = os.path.join(base, session, "manifest.json")
real = os.path.realpath(path)
if not real.startswith(base + os.sep):
return 403, json.dumps({"error": "forbidden"}).encode(), {}
try:
st1 = os.stat(real)
except OSError:
return 404, json.dumps({"error": "not found"}).encode(), {}
etag = 'W/"%x-%x"' % (st1.st_mtime_ns, st1.st_size)
headers = {"ETag": etag, "Cache-Control": "no-cache"}
if _etag_matches(if_none_match, etag):
return 304, None, headers
try:
with open(real, "r", encoding="utf-8") as f:
data = json.load(f)
st2 = os.stat(real)
except (OSError, ValueError):
return 503, json.dumps({"error": "manifest is being written, retry"}).encode(), {"Retry-After": "1"}
if (st2.st_mtime_ns, st2.st_size) != (st1.st_mtime_ns, st1.st_size):
return 503, json.dumps({"error": "manifest changed while reading, retry"}).encode(), {"Retry-After": "1"}
if isinstance(data, dict):
data.pop("__uscg_lazy__", None) # heal files corrupted by a SAVE on the lazy shell
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return 200, body, headers
def normalize_configs_payload(configs_json):
"""Accept a dict with a `configs` list, a bare list, or a JSON string of either.
Returns a dict that always has a non-empty `configs` list; other top-level keys
(`_distribution`, `_session_settings`, ...) are preserved. Raises ValueError otherwise.
"""
obj = configs_json
if isinstance(obj, (str, bytes)):
try:
obj = json.loads(obj)
except (TypeError, ValueError) as e:
raise ValueError(f"configs_json is not valid JSON: {e}")
if isinstance(obj, list):
obj = {"configs": obj}
if not isinstance(obj, dict):
raise ValueError("configs_json must be an object with a 'configs' list, a list of configs, or a JSON string of either")
configs = obj.get("configs")
if not isinstance(configs, list) or not configs:
raise ValueError("configs_json must contain a non-empty 'configs' list")
return obj
def apply_run_overrides(obj, session_name=None, overwrite_existing=None, nonce=None):
"""Apply run-level overrides. Returns (obj, sanitised_session_name).
Session precedence: explicit session_name, then _session_settings.session_name, then the
Generator default. The sanitised name is written back to _session_settings.session_name
because that is what the Generator actually reads. `nonce` goes into an inert top-level
key; it changes IS_CHANGED's hash so ComfyUI's node cache cannot short-circuit the run.
"""
settings = obj.get("_session_settings")
if not isinstance(settings, dict):
settings = {}
obj["_session_settings"] = settings
chosen = session_name or settings.get("session_name") or DEFAULT_SESSION
session = sanitize_session_name_strict(chosen)
settings["session_name"] = session
if overwrite_existing is not None:
settings["overwrite_existing"] = bool(overwrite_existing)
if nonce is not None:
obj["_run_nonce"] = str(nonce)
return obj, session
def build_run_prompt(configs_str, session_name):
"""The 2-node API-format prompt: Generator -> Dashboard (which owns the output)."""
return {
"1": {"class_type": "UltimateSamplerGrid", "inputs": {"configs_json": configs_str}},
"2": {"class_type": "UltimateGridDashboard",
"inputs": {"session_name": session_name, "dashboard_html": ["1", 0]}},
}
def resolve_saved_config(configs_dir, name):
"""Mirror of the /configbuilder/load_config path handling. Returns the file path.
Raises FileNotFoundError (missing) or PermissionError (escapes configs_dir).
"""
filename = str(name or "")
if not filename.endswith(".json"):
filename += ".json"
filename = os.path.basename(filename)
filepath = os.path.join(configs_dir, filename)
real_path = os.path.realpath(filepath)
real_base = os.path.realpath(configs_dir)
if not (real_path == real_base or real_path.startswith(real_base + os.sep)):
raise PermissionError("config path escapes the configs directory")
if not os.path.isfile(filepath):
raise FileNotFoundError(f"config '{filename}' not found")
return filepath
def saved_config_to_configs_obj(data, state_to_configs_json):
"""Saved Builder files hold the Builder STATE; convert via the single source of truth."""
if isinstance(data, dict):
if "config_arrays" in data:
return json.loads(state_to_configs_json(data))
if "configs" in data:
return data
raise ValueError("saved config is neither a Builder state (config_arrays) nor a configs JSON (configs)")
def summarize_history(entry):
"""Small status summary of a history entry. Never returns `outputs` or `prompt`."""
status = (entry or {}).get("status") or {}
out = {"status": status.get("status_str", "unknown"), "completed": bool(status.get("completed", False))}
for msg in status.get("messages") or []:
try:
if msg[0] == "execution_error":
d = msg[1] or {}
out["error"] = {
"node_id": d.get("node_id"),
"node_type": d.get("node_type"),
"exception_message": d.get("exception_message"),
}
break
except (IndexError, TypeError, KeyError):
continue
return out
def find_in_queue(running, pending, prompt_id):
"""('running', 0), ('pending', position) or None. Items are tuples (number, prompt_id, ...)."""
for item in running or []:
if len(item) > 1 and item[1] == prompt_id:
return ("running", 0)
for pos, item in enumerate(sorted(pending or [], key=lambda i: i[0])):
if len(item) > 1 and item[1] == prompt_id:
return ("pending", pos)
return None
class RunTracker:
"""Bounded prompt_id -> session_name map (oldest evicted first)."""
def __init__(self, cap=_MAX_TRACKED_RUNS):
self._cap = cap
self._d = OrderedDict()
def add(self, prompt_id, session):
self._d[prompt_id] = session
while len(self._d) > self._cap:
self._d.popitem(last=False)
def get(self, prompt_id):
return self._d.get(prompt_id)