Moxie-Multimedia / utils /workflow_submission.py
turtle89431's picture
Upload folder using huggingface_hub (part 2)
3afd6d6 verified
Raw History Blame Contribute Delete
9.7 kB
"""In-memory handoff from API callers to a single ComfyUI browser page."""
import hashlib
import json
import re
import time
import uuid
from collections.abc import Callable
from aiohttp import web
class WorkflowSubmissions:
def __init__(self, connected: Callable[[str], bool]) -> None:
self.connected = connected
self.clients: dict[str, dict] = {}
self.jobs: dict[str, dict] = {}
def _prune(self) -> None:
now = time.time()
self.clients = {key: value for key, value in self.clients.items()
if now - value["last_seen"] < 30 and self.connected(value["session_id"])}
for job in self.jobs.values():
if job["status"] == "pending" and now - job["created_at"] > 60:
job.update(status="failed", error="Browser did not accept the workflow within 60 seconds")
job.pop("workflow", None)
if job["status"] == "loading" and now - job["claimed_at"] > 120:
job.update(status="unknown", error="Browser result unavailable; check queue/history before retrying")
self.jobs = {key: value for key, value in self.jobs.items()
if now - value["created_at"] < 3600 or value["status"] in {"loading", "unknown"}}
def list_clients(self) -> list[dict]:
self._prune()
return list(self.clients.values())
@staticmethod
def _identifier(value: object, field: str) -> str:
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value):
raise ValueError(f"{field} must be 1-128 letters, digits, underscores or hyphens")
return value
@staticmethod
def summary(job: dict) -> dict:
return {key: value for key, value in job.items()
if key not in {"workflow", "fingerprint", "claim_token"}}
def submit(self, payload: dict) -> dict:
self._prune()
workflow = payload.get("workflow")
if not isinstance(workflow, dict) or not isinstance(workflow.get("nodes"), list) or not isinstance(workflow.get("links"), list):
raise ValueError("workflow must be UI workflow JSON with nodes and links; API prompt alone cannot preserve the editor layout")
if any(not isinstance(node, dict) or "id" not in node or not isinstance(node.get("type"), str) for node in workflow["nodes"]):
raise ValueError("Every workflow node must have an id and type")
mode = payload.get("mode", "new_tab")
if mode not in ("new_tab", "replace"):
raise ValueError("mode must be new_tab or replace")
auto_queue = payload.get("auto_queue", True)
if type(auto_queue) is not bool:
raise ValueError("auto_queue must be a boolean")
name = payload.get("name", "Skill workflow")
if not isinstance(name, str) or not name.strip() or len(name) > 200 or any(c in name for c in "/\\\x00"):
raise ValueError("name must be a filename without directory separators, up to 200 characters")
request_id = self._identifier(payload.get("request_id", str(uuid.uuid4())), "request_id")
client_id = payload.get("client_id")
if client_id is not None:
self._identifier(client_id, "client_id")
fingerprint = hashlib.sha256(json.dumps(
[workflow, mode, auto_queue, name, client_id], sort_keys=True, allow_nan=False,
).encode()).hexdigest()
previous = self.jobs.get(request_id)
if previous:
if previous["fingerprint"] != fingerprint:
raise web.HTTPConflict(text="request_id was already used with a different payload")
return self.summary(previous)
if client_id is None:
if len(self.clients) > 1:
raise web.HTTPConflict(text="Multiple browser pages available; select client_id from GET /easy-media/workflow/clients")
client_id = next(iter(self.clients), None)
if client_id not in self.clients:
raise web.HTTPServiceUnavailable(text="Open or refresh a ComfyUI page with the Easy Media extension first")
if any(job["client_id"] == client_id and job["status"] in {"pending", "loading", "unknown"} for job in self.jobs.values()):
raise web.HTTPConflict(text="This browser already has an unfinished submission; check its status before retrying")
if len(self.jobs) >= 256:
raise web.HTTPServiceUnavailable(text="Submission history is full; retry after old requests expire")
job = dict(request_id=request_id, client_id=client_id, workflow=workflow,
mode=mode, auto_queue=auto_queue, name=name, status="pending",
created_at=time.time(), fingerprint=fingerprint)
self.jobs[request_id] = job
return self.summary(job)
def poll(self, payload: dict) -> dict | None:
self._prune()
client_id = self._identifier(payload.get("client_id"), "client_id")
session_id = self._identifier(payload.get("session_id"), "session_id")
if not self.connected(session_id):
raise web.HTTPConflict(text="ComfyUI WebSocket is not connected")
title = payload.get("title", "ComfyUI")
if not isinstance(title, str) or len(title) > 300:
raise ValueError("title must be a string up to 300 characters")
self.clients[client_id] = dict(client_id=client_id, session_id=session_id, title=title, last_seen=time.time())
for job in self.jobs.values():
if job["client_id"] == client_id and job["status"] == "pending":
job.update(status="loading", claimed_at=time.time(), claim_token=str(uuid.uuid4()))
delivery = {**self.summary(job), "workflow": job.pop("workflow"), "claim_token": job["claim_token"]}
return delivery
return None
def get(self, request_id: str) -> dict:
self._prune()
if request_id not in self.jobs:
raise web.HTTPNotFound(text="Unknown or expired request_id")
return self.summary(self.jobs[request_id])
def complete(self, request_id: str, payload: dict) -> dict:
self.get(request_id)
job = self.jobs[request_id]
if not job.get("claim_token") or payload.get("claim_token") != job["claim_token"]:
raise web.HTTPForbidden(text="Invalid claim token")
status = payload.get("status")
if status not in ("loaded", "queued", "failed", "unknown"):
raise ValueError("Invalid result status")
result = {"status": status}
if status == "queued":
result["prompt_id"] = self._identifier(payload.get("prompt_id"), "prompt_id")
if status in ("failed", "unknown"):
error = payload.get("error")
if not isinstance(error, str) or not error or len(error) > 4000:
raise ValueError("error must be a non-empty string up to 4000 characters")
result["error"] = error
if job["status"] not in {"loading", "unknown"}:
if all(job.get(key) == value for key, value in result.items()):
return self.summary(job)
raise web.HTTPConflict(text="Submission already finished")
job.pop("error", None)
job.update(result)
return self.summary(job)
def register_workflow_routes(routes: web.RouteTableDef, connected: Callable[[str], bool]) -> None:
submissions = WorkflowSubmissions(connected)
async def read_payload(request: web.Request) -> dict:
# Reject cross-site form posts even when the server permits broad CORS.
if request.content_type != "application/json":
raise web.HTTPUnsupportedMediaType(text="Content-Type must be application/json")
origin = request.headers.get("Origin")
if origin and origin != f"{request.scheme}://{request.host}":
raise web.HTTPForbidden(text="Cross-origin workflow control is not allowed")
try:
payload = await request.json()
except (ValueError, UnicodeError) as error:
raise web.HTTPBadRequest(text="Invalid JSON") from error
if not isinstance(payload, dict):
raise web.HTTPBadRequest(text="Request must contain a JSON object")
return payload
async def respond(action: Callable[..., object], *args: object) -> web.Response:
try:
return web.json_response(action(*args))
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
except web.HTTPException as error:
return web.json_response({"error": error.text}, status=error.status)
@routes.get("/easy-media/workflow/clients")
async def clients(_request: web.Request) -> web.Response:
return web.json_response({"clients": submissions.list_clients()})
@routes.post("/easy-media/workflow/submit")
async def submit(request: web.Request) -> web.Response:
return await respond(submissions.submit, await read_payload(request))
@routes.post("/easy-media/workflow/poll")
async def poll(request: web.Request) -> web.Response:
return await respond(lambda data: {"job": submissions.poll(data)}, await read_payload(request))
@routes.get("/easy-media/workflow/submissions/{request_id}")
async def status(request: web.Request) -> web.Response:
return await respond(submissions.get, request.match_info["request_id"])
@routes.post("/easy-media/workflow/submissions/{request_id}/result")
async def result(request: web.Request) -> web.Response:
return await respond(submissions.complete, request.match_info["request_id"], await read_payload(request))