diff --git a/package-lock.json b/package-lock.json index 61ba495..dfc8613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "substitute-backend", - "version": "1.9.1", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "substitute-backend", - "version": "1.9.1", + "version": "1.10.0", "license": "AGPL-3.0-or-later", "devDependencies": { "@semantic-release/changelog": "^6.0.3", diff --git a/package.json b/package.json index 2a01fd6..1db0db3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "substitute-backend", "private": true, - "version": "1.9.1", + "version": "1.10.0", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/pyproject.toml b/pyproject.toml index d51ac8a..951bdd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "substitute-backend" description = "Backend liaison services for SugarSubstitute and ComfyUI." -version = "1.9.1" +version = "1.10.0" license = "AGPL-3.0-or-later" license-files = ["LICENSE"] requires-python = ">=3.12" @@ -15,7 +15,6 @@ classifiers = [ dependencies = [ "aiohttp", "requests", - "sugar-dsl==1.2.0", ] [project.optional-dependencies] diff --git a/substitute_backend/__init__.py b/substitute_backend/__init__.py index bea4fe5..de921d3 100644 --- a/substitute_backend/__init__.py +++ b/substitute_backend/__init__.py @@ -17,7 +17,7 @@ from __future__ import annotations -__version__ = "1.9.1" +__version__ = "1.10.0" EXTENSION_NAME = "Substitute BackEnd" API_VERSION = 1 diff --git a/substitute_backend/features/cube_outputs/application/__init__.py b/substitute_backend/features/cube_outputs/application/__init__.py deleted file mode 100644 index 2fff9d8..0000000 --- a/substitute_backend/features/cube_outputs/application/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Application services for cube-output publishing.""" - -from substitute_backend.features.cube_outputs.application.services import CubeOutputServices - -__all__ = ["CubeOutputServices"] diff --git a/substitute_backend/features/cube_outputs/application/services.py b/substitute_backend/features/cube_outputs/application/services.py deleted file mode 100644 index 2e72106..0000000 --- a/substitute_backend/features/cube_outputs/application/services.py +++ /dev/null @@ -1,31 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Service container for cube-output websocket publishing.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_registration import ( - SugarCubesCubeOutputRegistration, -) - - -@dataclass(frozen=True) -class CubeOutputServices: - """Own cube-output observer registration services.""" - - registration: SugarCubesCubeOutputRegistration diff --git a/substitute_backend/features/prompt_queue/domain/run_context.py b/substitute_backend/features/prompt_queue/domain/run_context.py index 416023c..b44777c 100644 --- a/substitute_backend/features/prompt_queue/domain/run_context.py +++ b/substitute_backend/features/prompt_queue/domain/run_context.py @@ -48,6 +48,7 @@ class SubstituteRunContext: workflow_id: str generation_run_id: str client_id: str + output_session_id: str | None = None scene_run_id: str | None = None scene_key: str | None = None scene_title: str | None = None @@ -66,6 +67,8 @@ class SubstituteRunContext: "sourceKey": source.source_key, "sourceLabel": source.source_label, } + if self.output_session_id is not None: + payload["outputSessionId"] = self.output_session_id if self.scene_run_id is not None: payload["sceneRunId"] = self.scene_run_id if self.scene_key is not None: @@ -110,6 +113,7 @@ def parse_substitute_run_context(value: object) -> SubstituteRunContext | None: workflow_id=workflow_id, generation_run_id=generation_run_id, client_id=client_id, + output_session_id=_optional_string(value.get("outputSessionId")), scene_run_id=_optional_string(scene_mapping.get("runId")), scene_key=_optional_string(scene_mapping.get("key")), scene_title=_optional_string(scene_mapping.get("title")), diff --git a/substitute_backend/features/prompt_queue/infrastructure/sugarcubes_queue_context.py b/substitute_backend/features/prompt_queue/infrastructure/sugarcubes_queue_context.py new file mode 100644 index 0000000..d453e9f --- /dev/null +++ b/substitute_backend/features/prompt_queue/infrastructure/sugarcubes_queue_context.py @@ -0,0 +1,121 @@ +# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI +# Copyright (C) 2026 Artificial Sweetener and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Capture Substitute routing context at SugarCubes' validated queue boundary.""" + +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from typing import Protocol + +from substitute_backend.features.prompt_queue.application.run_context_store import ( + SubstituteRunContextStore, +) +from substitute_backend.features.prompt_queue.domain.run_context import ( + parse_substitute_run_context, +) + + +class ValidatedQueueEventLike(Protocol): + """Describe the stable SugarCubes queue event surface consumed by BackEnd.""" + + @property + def prompt_id(self) -> str: + """Return the validated host prompt identity.""" + + @property + def prompt(self) -> object: + """Return the final executable prompt.""" + + @property + def extra_data(self) -> object: + """Return the host queue metadata envelope.""" + + @property + def report(self) -> object | None: + """Return the optional SugarCubes execution report.""" + + +class SugarCubesQueueContextObserver: + """Persist validated Substitute routing metadata before native queue insertion.""" + + def __init__(self, *, run_context_store: SubstituteRunContextStore) -> None: + """Store the authoritative process-lifetime run-context owner.""" + + self._run_context_store = run_context_store + + def on_validated_queue(self, event: ValidatedQueueEventLike) -> None: + """Capture valid Substitute context while leaving ordinary Comfy prompts alone.""" + + extra_data = event.extra_data + if not isinstance(extra_data, Mapping) or "substitute" not in extra_data: + return + context_payload = _context_with_report_sources( + extra_data["substitute"], + getattr(event, "report", None), + ) + context = parse_substitute_run_context(context_payload) + if context is None: + raise ValueError("Substitute run context is malformed.") + self._run_context_store.store( + prompt_id=event.prompt_id, + context=context, + executable_prompt=event.prompt, + ) + + +def _context_with_report_sources(value: object, report: object) -> object: + """Fill native node routing from SugarCubes' authoritative execution report.""" + + if not isinstance(value, Mapping): + return value + enriched = deepcopy(dict(value)) + raw_presentations = enriched.pop("cubePresentations", None) + presentations = raw_presentations if isinstance(raw_presentations, Mapping) else {} + if isinstance(value.get("sources"), Mapping): + return enriched + identities = getattr(report, "execution_node_identities", None) + if not isinstance(identities, tuple | list): + identities = getattr(report, "output_identities", None) + if not isinstance(identities, tuple | list): + return enriched + sources: dict[str, dict[str, str]] = {} + for identity in identities: + execution_id = getattr(identity, "execution_id", None) + instance_id = getattr(identity, "instance_id", None) + instance_alias = getattr(identity, "instance_alias", None) + if not isinstance(execution_id, str) or not isinstance(instance_id, str): + continue + presentation_label = presentations.get(instance_id) + source_label = ( + presentation_label.strip() + if isinstance(presentation_label, str) and presentation_label.strip() + else ( + instance_alias + if isinstance(instance_alias, str) and instance_alias.strip() + else instance_id + ) + ) + sources[execution_id] = { + "sourceKey": f"cube:{instance_id}", + "sourceLabel": source_label, + "cubeAlias": source_label, + } + enriched["sources"] = sources + return enriched + + +__all__ = ["SugarCubesQueueContextObserver", "ValidatedQueueEventLike"] diff --git a/substitute_backend/features/sugar_compile/__init__.py b/substitute_backend/features/sugar_compile/__init__.py deleted file mode 100644 index 52e2475..0000000 --- a/substitute_backend/features/sugar_compile/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Sugar compile feature package.""" diff --git a/substitute_backend/features/sugar_compile/api/__init__.py b/substitute_backend/features/sugar_compile/api/__init__.py deleted file mode 100644 index a6dd61c..0000000 --- a/substitute_backend/features/sugar_compile/api/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""HTTP API for backend-owned Sugar compilation.""" - -from substitute_backend.features.sugar_compile.api.routes import ( - SugarCompileRouteHandlers, - build_sugar_compile_route_handlers, -) - -__all__ = ["SugarCompileRouteHandlers", "build_sugar_compile_route_handlers"] diff --git a/substitute_backend/features/sugar_compile/api/routes.py b/substitute_backend/features/sugar_compile/api/routes.py deleted file mode 100644 index d04d3af..0000000 --- a/substitute_backend/features/sugar_compile/api/routes.py +++ /dev/null @@ -1,107 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""HTTP route handlers for backend-owned Sugar compilation.""" - -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import cast - -from aiohttp import web - -from substitute_backend.api.errors import BackendHttpError, json_error -from substitute_backend.api.serialization import JsonObject -from substitute_backend.features.sugar_compile.application import SugarCompileServices -from substitute_backend.features.sugar_compile.domain import ( - SugarCompileError, - SugarCompileRequest, -) - -RouteHandler = Callable[[web.Request], Awaitable[web.StreamResponse]] - - -@dataclass(frozen=True) -class SugarCompileRouteHandlers: - """Concrete Sugar compile route callables used by host registration.""" - - compile_sugar: RouteHandler - - -def build_sugar_compile_route_handlers( - services: SugarCompileServices, - logger: logging.Logger, -) -> SugarCompileRouteHandlers: - """Build thin HTTP handlers over Sugar compile application services.""" - - async def compile_sugar(request: web.Request) -> web.Response: - """Compile one Sugar script into prompt and workflow artifacts.""" - - try: - body = await _json_object_body(request) - result = services.compile.compile(SugarCompileRequest.from_payload(body)) - return web.json_response(result.to_payload()) - except BackendHttpError as exc: - return json_error(exc) - except SugarCompileError as exc: - return json_error( - BackendHttpError( - message=exc.message, - status=exc.status, - code=exc.code, - ) - ) - except Exception: # pragma: no cover - defensive host boundary. - logger.exception( - "Sugar compile route failed.", - extra={ - "operation": "sugar-compile", - "route": "/substitute/v1/sugar/compile", - }, - ) - return json_error( - BackendHttpError( - message="Sugar workflow could not be compiled.", - status=500, - code="sugar-compile-failed", - ) - ) - - return SugarCompileRouteHandlers(compile_sugar=compile_sugar) - - -async def _json_object_body(request: web.Request) -> JsonObject: - """Parse and validate a JSON object request body.""" - - try: - body = await request.json() - except (TypeError, ValueError) as exc: - raise BackendHttpError( - message="Request body must be a JSON object.", - status=400, - code="sugar-compile-invalid-request", - ) from exc - if not isinstance(body, dict): - raise BackendHttpError( - message="Request body must be a JSON object.", - status=400, - code="sugar-compile-invalid-request", - ) - return cast( - JsonObject, - {str(key): value for key, value in body.items() if isinstance(key, str)}, - ) diff --git a/substitute_backend/features/sugar_compile/application/services.py b/substitute_backend/features/sugar_compile/application/services.py deleted file mode 100644 index 18ef46c..0000000 --- a/substitute_backend/features/sugar_compile/application/services.py +++ /dev/null @@ -1,141 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Use-case service for backend-owned Sugar compilation.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from pathlib import Path -from typing import Protocol - -from substitute_backend.api.errors import BackendHttpError -from substitute_backend.features.sugar_compile.domain import ( - SugarCompileCapabilities, - SugarCompileError, - SugarCompileRequest, - SugarCompileResult, - SugarCompileUnavailableError, -) - - -class SugarWorkflowCompiler(Protocol): - """Describe the infrastructure adapter that invokes Sugar-DSL.""" - - def is_available(self) -> bool: - """Return whether Sugar-DSL can be imported in this backend runtime.""" - - def unavailable_reason(self) -> str: - """Return a setup-oriented reason when Sugar-DSL is unavailable.""" - - def sugar_dsl_version(self) -> str: - """Return the installed Sugar-DSL package version or an empty string.""" - - def compile(self, *, script_text: str, output_dir: Path) -> SugarCompileResult: - """Compile Sugar text into Comfy artifacts.""" - - -@dataclass(frozen=True) -class SugarCompileService: - """Coordinate one Sugar compile request without owning Sugar-DSL details.""" - - compiler: SugarWorkflowCompiler - logger: logging.Logger - - def capabilities(self) -> SugarCompileCapabilities: - """Return the backend capability payload for Sugar compilation.""" - - if self.compiler.is_available(): - return SugarCompileCapabilities( - available=True, - live_node_definitions=True, - sugar_dsl_version=self.compiler.sugar_dsl_version(), - ) - return SugarCompileCapabilities( - available=False, - unavailable_reason=self.compiler.unavailable_reason(), - ) - - def compile(self, request: SugarCompileRequest) -> SugarCompileResult: - """Compile one validated Sugar request through the injected compiler.""" - - self.logger.info( - "Sugar compile requested.", - extra={ - "operation": "sugar-compile", - "output_path": str(request.output_dir), - "script_length": len(request.sugar_script_text), - }, - ) - try: - return self.compiler.compile( - script_text=request.sugar_script_text, - output_dir=request.output_dir, - ) - except SugarCompileUnavailableError as exc: - self.logger.warning( - "Sugar compile unavailable.", - extra={ - "operation": "sugar-compile", - "output_path": str(request.output_dir), - "script_length": len(request.sugar_script_text), - "error_category": exc.code, - }, - ) - raise _http_error(exc) from exc - except SugarCompileError as exc: - self.logger.warning( - "Sugar compile failed.", - extra={ - "operation": "sugar-compile", - "output_path": str(request.output_dir), - "script_length": len(request.sugar_script_text), - "error_category": exc.code, - }, - ) - raise _http_error(exc) from exc - except Exception as exc: - self.logger.exception( - "Unexpected Sugar compile failure.", - extra={ - "operation": "sugar-compile", - "output_path": str(request.output_dir), - "script_length": len(request.sugar_script_text), - "error_category": "unexpected", - }, - ) - raise BackendHttpError( - message=f"Sugar compile failed: {exc}", - status=500, - code="sugar-compile-failed", - ) from exc - - -@dataclass(frozen=True) -class SugarCompileServices: - """Group Sugar compile feature services for host route registration.""" - - compile: SugarCompileService - - -def _http_error(error: SugarCompileError) -> BackendHttpError: - """Convert a domain compile error into the backend HTTP error contract.""" - - return BackendHttpError( - message=error.message, - status=error.status, - code=error.code, - ) diff --git a/substitute_backend/features/sugar_compile/domain/__init__.py b/substitute_backend/features/sugar_compile/domain/__init__.py deleted file mode 100644 index 4abee5b..0000000 --- a/substitute_backend/features/sugar_compile/domain/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Domain models for backend-owned Sugar compilation.""" - -from substitute_backend.features.sugar_compile.domain.models import ( - SUGAR_COMPILE_ROUTE, - SugarCompileCapabilities, - SugarCompileError, - SugarCompileRequest, - SugarCompileResult, - SugarCompileUnavailableError, -) - -__all__ = [ - "SUGAR_COMPILE_ROUTE", - "SugarCompileCapabilities", - "SugarCompileError", - "SugarCompileRequest", - "SugarCompileResult", - "SugarCompileUnavailableError", -] diff --git a/substitute_backend/features/sugar_compile/domain/models.py b/substitute_backend/features/sugar_compile/domain/models.py deleted file mode 100644 index a6344c2..0000000 --- a/substitute_backend/features/sugar_compile/domain/models.py +++ /dev/null @@ -1,152 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Domain contracts for the Sugar compile route.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path, PureWindowsPath - -from substitute_backend.api.serialization import JsonObject - -SUGAR_COMPILE_ROUTE = "/substitute/v1/sugar/compile" -SUGAR_COMPILE_SCHEMA_VERSION = 1 - - -class SugarCompileError(RuntimeError): - """Carry expected Sugar compilation failures across backend layers.""" - - def __init__( - self, - message: str, - *, - status: int = 400, - code: str = "sugar-compile-failed", - ) -> None: - """Create a typed compile failure with public HTTP error metadata.""" - - super().__init__(message) - self.message = message - self.status = status - self.code = code - - -class SugarCompileUnavailableError(SugarCompileError): - """Report a missing Sugar-DSL runtime dependency.""" - - def __init__(self, message: str) -> None: - """Create an unavailable compile failure.""" - - super().__init__( - message, - status=503, - code="sugar-compile-unavailable", - ) - - -@dataclass(frozen=True) -class SugarCompileRequest: - """Describe one Sugar script compile request from SugarSubstitute.""" - - sugar_script_text: str - output_dir: Path - schema_version: int = SUGAR_COMPILE_SCHEMA_VERSION - - @classmethod - def from_payload(cls, payload: JsonObject) -> SugarCompileRequest: - """Validate and construct a compile request from public JSON data.""" - - schema_version = payload.get("schemaVersion") - if schema_version != SUGAR_COMPILE_SCHEMA_VERSION: - raise SugarCompileError( - "Sugar compile request schemaVersion must be 1.", - status=400, - code="sugar-compile-invalid-request", - ) - script_text = payload.get("sugarScriptText") - if not isinstance(script_text, str) or not script_text.strip(): - raise SugarCompileError( - "Sugar compile request requires non-empty sugarScriptText.", - status=400, - code="sugar-compile-invalid-request", - ) - output_dir = payload.get("outputDir") - if not isinstance(output_dir, str) or not output_dir.strip(): - raise SugarCompileError( - "Sugar compile request requires non-empty outputDir.", - status=400, - code="sugar-compile-invalid-request", - ) - if "\x00" in output_dir: - raise SugarCompileError( - "Sugar compile request outputDir contains an invalid character.", - status=400, - code="sugar-compile-invalid-request", - ) - windows_path = PureWindowsPath(output_dir) - if not windows_path.is_absolute(): - raise SugarCompileError( - "Sugar compile request outputDir must be an absolute Windows path.", - status=400, - code="sugar-compile-invalid-request", - ) - return cls( - sugar_script_text=script_text, - output_dir=Path(output_dir), - ) - - -@dataclass(frozen=True) -class SugarCompileResult: - """Hold compiled Comfy prompt and UI workflow artifacts.""" - - prompt: JsonObject - workflow: JsonObject - - def to_payload(self) -> JsonObject: - """Return the public route response shape.""" - - return { - "prompt": self.prompt, - "workflow": self.workflow, - } - - -@dataclass(frozen=True) -class SugarCompileCapabilities: - """Describe Sugar compilation support in the active backend runtime.""" - - available: bool - unavailable_reason: str = "" - live_node_definitions: bool = False - sugar_dsl_version: str = "" - schema_version: int = SUGAR_COMPILE_SCHEMA_VERSION - compile_route: str = SUGAR_COMPILE_ROUTE - - def to_payload(self) -> JsonObject: - """Return the public capability payload.""" - - payload: JsonObject = { - "schemaVersion": self.schema_version, - "available": self.available, - } - if self.available: - payload["compileRoute"] = self.compile_route - payload["liveNodeDefinitions"] = self.live_node_definitions - payload["sugarDslVersion"] = self.sugar_dsl_version - else: - payload["unavailableReason"] = self.unavailable_reason - return payload diff --git a/substitute_backend/features/sugar_compile/infrastructure/__init__.py b/substitute_backend/features/sugar_compile/infrastructure/__init__.py deleted file mode 100644 index 8bc4947..0000000 --- a/substitute_backend/features/sugar_compile/infrastructure/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Infrastructure adapters for backend-owned Sugar compilation.""" - -from substitute_backend.features.sugar_compile.infrastructure.sugar_dsl_compiler import ( - BackendCubeArtifactResolver, - SugarDslWorkflowCompiler, -) - -__all__ = [ - "BackendCubeArtifactResolver", - "SugarDslWorkflowCompiler", -] diff --git a/substitute_backend/features/sugar_compile/infrastructure/sugar_dsl_compiler.py b/substitute_backend/features/sugar_compile/infrastructure/sugar_dsl_compiler.py deleted file mode 100644 index a2578b0..0000000 --- a/substitute_backend/features/sugar_compile/infrastructure/sugar_dsl_compiler.py +++ /dev/null @@ -1,297 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Sugar-DSL compiler adapter for the ComfyUI backend runtime.""" - -from __future__ import annotations - -import copy -import importlib.util -import logging -from collections.abc import Mapping -from importlib import metadata -from pathlib import Path -from typing import Any, cast - -from substitute_backend.api.errors import BackendHttpError -from substitute_backend.api.serialization import JsonObject -from substitute_backend.features.cube_library.application import CubeLibraryService -from substitute_backend.features.sugar_compile.domain import ( - SugarCompileError, - SugarCompileResult, - SugarCompileUnavailableError, -) - -_LATEST_VERSION_MEMO_KEY = "__latest__" -_UNAVAILABLE_REASON = "Sugar-DSL is not installed in the ComfyUI environment." - - -class SugarDslWorkflowCompiler: - """Invoke Sugar-DSL while isolating optional Sugar imports.""" - - def __init__( - self, - *, - cube_library: CubeLibraryService, - logger: logging.Logger, - ) -> None: - """Store backend services needed by Sugar-DSL compilation.""" - - self._cube_library = cube_library - self._logger = logger - - def is_available(self) -> bool: - """Return whether Sugar-DSL's public builder can be imported.""" - - try: - return importlib.util.find_spec("sugar.api.builder") is not None - except (ImportError, ModuleNotFoundError, ValueError): - return False - - def unavailable_reason(self) -> str: - """Return the setup message for a missing Sugar-DSL install.""" - - return _UNAVAILABLE_REASON - - def sugar_dsl_version(self) -> str: - """Return the installed Sugar-DSL package version or an empty string.""" - - try: - return metadata.version("sugar-dsl") - except metadata.PackageNotFoundError: - return "" - - def compile(self, *, script_text: str, output_dir: Path) -> SugarCompileResult: - """Compile Sugar script text through Sugar-DSL.""" - - try: - from sugar.api.builder import ( - build_comfy_artifacts_from_text, - ) - from sugar.compiler.errors import ( - SugarCompilerError as SugarDslCompilerError, - ) - from sugar.runtime.live_definitions import ( - ComfyRegistryLiveNodeDefinitionProvider, - ) - except ImportError as exc: - raise SugarCompileUnavailableError(_UNAVAILABLE_REASON) from exc - - try: - payload = cast( - Mapping[str, object], - build_comfy_artifacts_from_text( - script_text, - output_dir=output_dir, - cube_artifact_resolver=BackendCubeArtifactResolver( - cube_library=self._cube_library, - logger=self._logger, - ), - live_node_definition_provider=ComfyRegistryLiveNodeDefinitionProvider( - logger=self._logger, - ), - ), - ) - except SugarDslCompilerError as exc: - raise SugarCompileError( - exc.message, - status=400, - code=exc.code, - ) from exc - except SugarCompileError: - raise - except Exception as exc: - raise SugarCompileError( - f"Sugar-DSL compile failed: {exc}", - status=500, - code="sugar-compile-failed", - ) from exc - - prompt = payload.get("prompt") - workflow = payload.get("workflow") - if not isinstance(prompt, dict) or not isinstance(workflow, dict): - raise SugarCompileError( - "Sugar-DSL did not return prompt/workflow artifacts.", - status=500, - code="sugar-compile-failed", - ) - return SugarCompileResult( - prompt=cast(JsonObject, prompt), - workflow=cast(JsonObject, workflow), - ) - - -class BackendCubeArtifactResolver: - """Resolve Sugar cube artifacts through the backend Cube Library service.""" - - def __init__( - self, - *, - cube_library: CubeLibraryService, - logger: logging.Logger, - ) -> None: - """Create a resolver for one Sugar compile operation.""" - - self._cube_library = cube_library - self._logger = logger - self._memo: dict[tuple[str, str], Any] = {} - - def resolve( - self, - *, - alias: str, - cube_id: str, - requested_version: str | None, - ) -> Any: - """Return one Sugar-DSL ``ResolvedCubeArtifact`` for a use statement.""" - - memo_key = (cube_id, requested_version or _LATEST_VERSION_MEMO_KEY) - resolved = self._memo.get(memo_key) - if resolved is None: - artifact = self._load_artifact(cube_id, requested_version) - resolved = self._resolved_artifact( - alias=alias, - cube_id=cube_id, - requested_version=requested_version, - artifact=artifact, - ) - self._memo[memo_key] = resolved - return resolved - - def _load_artifact( - self, - cube_id: str, - requested_version: str | None, - ) -> JsonObject: - """Load a raw cube artifact payload from the backend Cube Library.""" - - try: - if requested_version is None: - artifact = self._cube_library.load_cube(cube_id) - else: - artifact = self._cube_library.load_cube_version( - cube_id=cube_id, - version=requested_version, - ) - except BackendHttpError as exc: - raise SugarCompileError( - f"Cube '{cube_id}' is not available in the active Cube Library.", - status=exc.status, - code="sugar-cube-artifact-unavailable", - ) from exc - except Exception as exc: - self._logger.warning( - "Backend cube artifact load failed during Sugar compile.", - extra={ - "operation": "sugar-cube-artifact-resolve", - "cube_id": cube_id, - "cube_version": requested_version or "", - "error": repr(exc), - }, - ) - raise SugarCompileError( - f"Cube '{cube_id}' could not be loaded from the active Cube Library.", - status=503, - code="sugar-cube-artifact-unavailable", - ) from exc - return artifact - - def _resolved_artifact( - self, - *, - alias: str, - cube_id: str, - requested_version: str | None, - artifact: Mapping[str, object], - ) -> Any: - """Validate and convert a Cube Library payload into Sugar-DSL's model.""" - - declared_cube_id = _required_text(artifact, "cubeId", cube_id=cube_id) - if declared_cube_id != cube_id: - raise SugarCompileError( - f"Cube '{cube_id}' returned artifact '{declared_cube_id}'.", - status=502, - code="sugar-cube-artifact-invalid", - ) - resolved_version = _required_text(artifact, "version", cube_id=cube_id) - if requested_version is not None and resolved_version != requested_version: - raise SugarCompileError( - f"Cube '{cube_id}' version mismatch: expected " - f"'{requested_version}', got '{resolved_version}'.", - status=502, - code="sugar-cube-artifact-invalid", - ) - cube_payload = artifact.get("cube") - if not isinstance(cube_payload, Mapping): - raise SugarCompileError( - f"Cube '{cube_id}' returned an invalid artifact payload.", - status=502, - code="sugar-cube-artifact-invalid", - ) - - try: - from sugar.catalog.artifacts import ( - CubeArtifactIdentity, - ResolvedCubeArtifact, - ) - from sugar.catalog.models import ( - validate_cube_document, - ) - except ImportError as exc: - raise SugarCompileUnavailableError(_UNAVAILABLE_REASON) from exc - - try: - cube = validate_cube_document(dict(cube_payload)) - except (RuntimeError, TypeError, ValueError) as exc: - raise SugarCompileError( - f"Cube '{cube_id}' returned an invalid artifact payload: {exc}", - status=502, - code="sugar-cube-artifact-invalid", - ) from exc - self._logger.debug( - "Resolved Sugar cube artifact.", - extra={ - "operation": "sugar-cube-artifact-resolve", - "cube_alias": alias, - "cube_id": cube_id, - "cube_version": resolved_version, - }, - ) - return ResolvedCubeArtifact( - cube=copy.deepcopy(cube), - identity=CubeArtifactIdentity( - cube_id=cube_id, - requested_version=requested_version, - resolved_version=resolved_version, - ), - ) - - -def _required_text( - payload: Mapping[str, object], - key: str, - *, - cube_id: str, -) -> str: - """Read one required non-empty string from a Cube Library artifact.""" - - value = payload.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - raise SugarCompileError( - f"Cube '{cube_id}' returned an invalid artifact {key}.", - status=502, - code="sugar-cube-artifact-invalid", - ) diff --git a/substitute_backend/features/sugar_compile/application/__init__.py b/substitute_backend/features/sugarcubes_integration/__init__.py similarity index 69% rename from substitute_backend/features/sugar_compile/application/__init__.py rename to substitute_backend/features/sugarcubes_integration/__init__.py index 9573d92..948ccf5 100644 --- a/substitute_backend/features/sugar_compile/application/__init__.py +++ b/substitute_backend/features/sugarcubes_integration/__init__.py @@ -13,12 +13,13 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -"""Application services for backend-owned Sugar compilation.""" +"""Expose cohesive SugarCubes liaison construction and lifecycle services.""" -from substitute_backend.features.sugar_compile.application.services import ( - SugarCompileService, - SugarCompileServices, - SugarWorkflowCompiler, +from substitute_backend.features.sugarcubes_integration.composition import ( + build_sugarcubes_integration, +) +from substitute_backend.features.sugarcubes_integration.services import ( + SugarCubesIntegrationServices, ) -__all__ = ["SugarCompileService", "SugarCompileServices", "SugarWorkflowCompiler"] +__all__ = ["SugarCubesIntegrationServices", "build_sugarcubes_integration"] diff --git a/substitute_backend/features/sugarcubes_integration/composition.py b/substitute_backend/features/sugarcubes_integration/composition.py new file mode 100644 index 0000000..c8d05a9 --- /dev/null +++ b/substitute_backend/features/sugarcubes_integration/composition.py @@ -0,0 +1,78 @@ +# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI +# Copyright (C) 2026 Artificial Sweetener and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Construct BackEnd's cohesive SugarCubes liaison service graph.""" + +from __future__ import annotations + +from substitute_backend.features.cube_outputs.infrastructure.prompt_server_publisher import ( + PromptServerCubeOutputPublisher, +) +from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_observer import ( + SubstituteCubeOutputObserver, +) +from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_observer_hook import ( + SugarCubesObserverHookResolver, +) +from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_registration import ( + SugarCubesCubeOutputRegistration, +) +from substitute_backend.features.prompt_queue.application.run_context_store import ( + SubstituteRunContextStore, +) +from substitute_backend.features.prompt_queue.infrastructure.sugarcubes_queue_context import ( + SugarCubesQueueContextObserver, +) +from substitute_backend.features.sugarcubes_integration.queue_context_registration import ( + SugarCubesQueueContextRegistration, +) +from substitute_backend.features.sugarcubes_integration.services import ( + SugarCubesIntegrationServices, +) +from substitute_backend.infrastructure.logging import get_logger + + +def build_sugarcubes_integration( + *, + prompt_server: object, + run_context_store: SubstituteRunContextStore, +) -> SugarCubesIntegrationServices: + """Build all BackEnd observers that participate in SugarCubes execution.""" + + output_publisher = PromptServerCubeOutputPublisher( + prompt_server=prompt_server, + logger=get_logger("cube_outputs.publisher"), + ) + output_observer = SubstituteCubeOutputObserver( + publisher=output_publisher, + logger=get_logger("cube_outputs.observer"), + run_context_store=run_context_store, + ) + return SugarCubesIntegrationServices( + cube_output_registration=SugarCubesCubeOutputRegistration( + hook_resolver=SugarCubesObserverHookResolver( + logger=get_logger("cube_outputs.sugarcubes") + ), + observer=output_observer, + logger=get_logger("cube_outputs.registration"), + ), + queue_context_registration=SugarCubesQueueContextRegistration( + observer=SugarCubesQueueContextObserver(run_context_store=run_context_store), + logger=get_logger("prompt_queue.sugarcubes_context"), + ), + ) + + +__all__ = ["build_sugarcubes_integration"] diff --git a/substitute_backend/features/sugarcubes_integration/queue_context_registration.py b/substitute_backend/features/sugarcubes_integration/queue_context_registration.py new file mode 100644 index 0000000..47e630a --- /dev/null +++ b/substitute_backend/features/sugarcubes_integration/queue_context_registration.py @@ -0,0 +1,74 @@ +# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI +# Copyright (C) 2026 Artificial Sweetener and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Own required Substitute queue-context registration with SugarCubes.""" + +from __future__ import annotations + +import logging + +from substitute_backend.features.prompt_queue.infrastructure.sugarcubes_queue_context import ( + SugarCubesQueueContextObserver, +) +from substitute_backend.infrastructure.sugarcubes_host_api import ( + SugarCubesHostApiResolutionStatus, + SugarCubesHostApiResolver, +) + + +class SugarCubesQueueContextRegistration: + """Attach BackEnd context capture to SugarCubes exactly once per process.""" + + def __init__( + self, + *, + observer: SugarCubesQueueContextObserver, + logger: logging.Logger, + host_api_resolver: SugarCubesHostApiResolver | None = None, + ) -> None: + """Configure lazy public-API resolution and structured diagnostics.""" + + self._observer = observer + self._logger = logger + self._host_api_resolver = host_api_resolver or SugarCubesHostApiResolver() + self._registered = False + + def register(self) -> None: + """Register the required pre-queue observer when SugarCubes is available.""" + + if self._registered: + return + resolution = self._host_api_resolver.resolve() + if resolution.status is SugarCubesHostApiResolutionStatus.PENDING: + self._logger.debug( + "SugarCubes queue-context registration pending", + extra={"reason": resolution.message}, + ) + return + if resolution.status is SugarCubesHostApiResolutionStatus.UNAVAILABLE: + self._logger.warning( + "SugarCubes queue-context registration unavailable", + extra={"reason": resolution.message}, + ) + return + api = resolution.api + if api is None: + raise RuntimeError("SugarCubes host API resolved without an API object.") + api.register_validated_queue_observer( + self._observer.on_validated_queue, + required=True, + ) + self._registered = True + self._logger.info("SugarCubes queue-context observer registered") diff --git a/substitute_backend/features/sugarcubes_integration/services.py b/substitute_backend/features/sugarcubes_integration/services.py new file mode 100644 index 0000000..c9db3b7 --- /dev/null +++ b/substitute_backend/features/sugarcubes_integration/services.py @@ -0,0 +1,52 @@ +# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI +# Copyright (C) 2026 Artificial Sweetener and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Coordinate BackEnd's complete liaison lifecycle with SugarCubes.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Protocol + + +class RegistrationLike(Protocol): + """Describe one idempotent SugarCubes observer registration.""" + + def register(self) -> object: + """Attempt observer registration without duplicating it.""" + + +@dataclass(frozen=True) +class SugarCubesIntegrationServices: + """Own BackEnd's output publication and queue-context liaison registrations.""" + + cube_output_registration: RegistrationLike + queue_context_registration: RegistrationLike + + def register(self) -> None: + """Attempt every SugarCubes liaison registration.""" + + self.cube_output_registration.register() + self.queue_context_registration.register() + + def schedule_retry(self) -> None: + """Retry after Comfy finishes the current asynchronous startup task.""" + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.call_soon(self.register) diff --git a/substitute_backend/host/extension.py b/substitute_backend/host/extension.py index e4fdca0..08c71d4 100644 --- a/substitute_backend/host/extension.py +++ b/substitute_backend/host/extension.py @@ -17,7 +17,6 @@ from __future__ import annotations -import asyncio import os from collections.abc import Mapping, MutableMapping from dataclasses import dataclass @@ -34,19 +33,6 @@ from substitute_backend.features.cube_library.infrastructure import ( PromptServerCubeLibraryPublisher, SugarCubesLibraryAdapter, ) -from substitute_backend.features.cube_outputs.application import CubeOutputServices -from substitute_backend.features.cube_outputs.infrastructure.prompt_server_publisher import ( - PromptServerCubeOutputPublisher, -) -from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_observer import ( - SubstituteCubeOutputObserver, -) -from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_observer_hook import ( - SugarCubesObserverHookResolver, -) -from substitute_backend.features.cube_outputs.infrastructure.sugarcubes_registration import ( - SugarCubesCubeOutputRegistration, -) from substitute_backend.features.downloads.application import DownloadServices from substitute_backend.features.downloads.application.telemetry_service import ( DownloadTelemetryService, @@ -203,11 +189,10 @@ from substitute_backend.features.prompt_queue.infrastructure.comfy_prompt_queue PromptQueueLike, PromptServerRuntimeLike, ) -from substitute_backend.features.sugar_compile.application import ( - SugarCompileService, - SugarCompileServices, +from substitute_backend.features.sugarcubes_integration import ( + SugarCubesIntegrationServices, + build_sugarcubes_integration, ) -from substitute_backend.features.sugar_compile.infrastructure import SugarDslWorkflowCompiler from substitute_backend.host.routes import ( BackendRouteHandlers, PromptServerClassLike, @@ -238,10 +223,9 @@ class BackendServices: model_loading: ModelLoadingServices downloads: DownloadServices preview_assets: PreviewAssetServices - cube_outputs: CubeOutputServices + sugarcubes_integration: SugarCubesIntegrationServices prompt_queue: PromptQueueServices preview_metadata_enrichment: PreviewMetadataEnrichmentInstaller - sugar_compile: SugarCompileServices diagnostics: DiagnosticLogger @@ -502,34 +486,6 @@ def build_download_services(prompt_server: object | None = None) -> DownloadServ ) -def build_cube_output_services( - extension_root: Path, - prompt_server: object | None = None, - run_context_store: SubstituteRunContextStore | None = None, -) -> CubeOutputServices: - """Build services for SugarCubes cube-output websocket publishing.""" - - publisher = PromptServerCubeOutputPublisher( - prompt_server=prompt_server or object(), - logger=get_logger("cube_outputs.publisher"), - ) - observer = SubstituteCubeOutputObserver( - publisher=publisher, - logger=get_logger("cube_outputs.observer"), - run_context_store=run_context_store, - ) - hook_resolver = SugarCubesObserverHookResolver( - logger=get_logger("cube_outputs.sugarcubes"), - ) - return CubeOutputServices( - registration=SugarCubesCubeOutputRegistration( - hook_resolver=hook_resolver, - observer=observer, - logger=get_logger("cube_outputs.registration"), - ) - ) - - def build_preview_asset_services() -> PreviewAssetServices: """Build application services for backend-managed preview assets.""" @@ -578,21 +534,6 @@ def build_prompt_queue_services( return PromptQueueServices(queue=PromptQueueService(adapter)) -def build_sugar_compile_services(cube_library: CubeLibraryServices) -> SugarCompileServices: - """Build services for backend-owned Sugar-DSL compilation.""" - - compiler = SugarDslWorkflowCompiler( - cube_library=cube_library.library, - logger=get_logger("sugar_compile.compiler"), - ) - return SugarCompileServices( - compile=SugarCompileService( - compiler=compiler, - logger=get_logger("sugar_compile.service"), - ) - ) - - def build_backend_services( extension_root: Path, model_roots: ModelRootsProvider | None = None, @@ -641,12 +582,11 @@ def build_backend_services( record_phase("downloads") resolved_preview_assets = preview_assets or build_preview_asset_services() record_phase("preview_assets") - cube_outputs = build_cube_output_services( - extension_root, - prompt_server=prompt_server, + sugarcubes_integration = build_sugarcubes_integration( + prompt_server=prompt_server or object(), run_context_store=run_context_store, ) - record_phase("cube_outputs") + record_phase("sugarcubes_integration") prompt_queue = build_prompt_queue_services( extension_root, prompt_server=prompt_server, @@ -659,8 +599,6 @@ def build_backend_services( logger=get_logger("preview_routing.metadata"), ) record_phase("preview_metadata_enrichment") - sugar_compile = build_sugar_compile_services(cube_library) - record_phase("sugar_compile") _log_startup_timing( "backend_services", total_duration_ms=round((perf_counter() - started_at) * 1000, 3), @@ -675,10 +613,9 @@ def build_backend_services( model_loading=model_loading, downloads=downloads, preview_assets=resolved_preview_assets, - cube_outputs=cube_outputs, + sugarcubes_integration=sugarcubes_integration, prompt_queue=prompt_queue, preview_metadata_enrichment=preview_metadata_enrichment, - sugar_compile=sugar_compile, diagnostics=diagnostics, ) @@ -718,10 +655,10 @@ def register_extension( record_phase("install_download_patch") services.preview_metadata_enrichment.install() record_phase("install_preview_metadata_enrichment") - services.cube_outputs.registration.register() - record_phase("register_cube_outputs") - _schedule_cube_output_registration_retry(services.cube_outputs.registration) - record_phase("schedule_cube_output_retry") + services.sugarcubes_integration.register() + record_phase("register_sugarcubes_integration") + services.sugarcubes_integration.schedule_retry() + record_phase("schedule_sugarcubes_integration_retry") services.cube_library_change_monitor.start() record_phase("start_cube_library_change_monitor") services.model_metadata.changes.start() @@ -778,18 +715,6 @@ def _resolve_prompt_server_instance( return prompt_server -def _schedule_cube_output_registration_retry( - registration: SugarCubesCubeOutputRegistration, -) -> None: - """Retry cube-output registration after Comfy finishes the current startup task.""" - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - loop.call_soon(registration.register) - - def _catalog_revision_from_status(cube_library: CubeLibraryServices) -> str: """Read the current Cube Library catalog revision from status payloads.""" diff --git a/substitute_backend/host/routes.py b/substitute_backend/host/routes.py index b7d9ed4..10425c0 100644 --- a/substitute_backend/host/routes.py +++ b/substitute_backend/host/routes.py @@ -35,7 +35,6 @@ from substitute_backend.features.cube_library.api.routes import ( from substitute_backend.features.cube_library.application.services import ( CubeLibraryServices, ) -from substitute_backend.features.cube_outputs.application import CubeOutputServices from substitute_backend.features.downloads.application import DownloadServices from substitute_backend.features.environment_management.api.routes import ( EnvironmentRouteHandlers, @@ -72,12 +71,9 @@ from substitute_backend.features.prompt_queue.api.routes import ( build_prompt_queue_route_handlers, ) from substitute_backend.features.prompt_queue.application.services import PromptQueueServices -from substitute_backend.features.sugar_compile.api.routes import ( - SugarCompileRouteHandlers, - build_sugar_compile_route_handlers, +from substitute_backend.features.sugarcubes_integration import ( + SugarCubesIntegrationServices, ) -from substitute_backend.features.sugar_compile.application import SugarCompileServices -from substitute_backend.features.sugar_compile.domain import SUGAR_COMPILE_ROUTE from substitute_backend.infrastructure.diagnostics import DiagnosticLogger from substitute_backend.infrastructure.logging import get_logger @@ -149,17 +145,13 @@ class BackendServicesLike(Protocol): """Return preview asset preparation services.""" @property - def cube_outputs(self) -> CubeOutputServices: - """Return cube-output publishing services.""" + def sugarcubes_integration(self) -> SugarCubesIntegrationServices: + """Return SugarCubes liaison services.""" @property def prompt_queue(self) -> PromptQueueServices: """Return prompt queue facade services.""" - @property - def sugar_compile(self) -> SugarCompileServices: - """Return Sugar compile services.""" - @property def diagnostics(self) -> DiagnosticLogger: """Return opt-in diagnostic logging services.""" @@ -175,7 +167,6 @@ class BackendRouteHandlers: local_assets: LocalAssetRouteHandlers preview_assets: PreviewAssetRouteHandlers prompt_queue: PromptQueueRouteHandlers - sugar_compile: SugarCompileRouteHandlers def register_routes( @@ -206,14 +197,9 @@ def register_routes( services.prompt_queue, logger=get_logger("prompt_queue.routes"), ) - sugar_compile_handlers = build_sugar_compile_route_handlers( - services.sugar_compile, - logger=get_logger("sugar_compile.routes"), - ) routes = _resolve_routes(prompt_server) routes.get("/substitute/v1/capabilities")(_build_capabilities_handler(services)) routes.post("/substitute/v1/prompt/queue")(prompt_queue_handlers.queue_prompt) - routes.post(SUGAR_COMPILE_ROUTE)(sugar_compile_handlers.compile_sugar) routes.post(LOCAL_ASSET_AUTHORIZE_ROUTE)(local_asset_handlers.authorize) routes.get("/substitute/v1/models")(model_handlers.list_models) routes.get("/substitute/v1/models/changes")(model_handlers.latest_model_changes) @@ -292,7 +278,6 @@ def register_routes( local_assets=local_asset_handlers, preview_assets=preview_asset_handlers, prompt_queue=prompt_queue_handlers, - sugar_compile=sugar_compile_handlers, ) @@ -321,7 +306,7 @@ def _build_capabilities_handler( try: try: - services.cube_outputs.registration.register() + services.sugarcubes_integration.register() except Exception as exc: logger.warning( "cube-output registration failed during capability probe", @@ -348,12 +333,10 @@ def _build_capabilities_handler( feature_list.append("prompt-queue-facade") if "visual-routing" not in feature_list: feature_list.append("visual-routing") + if "native-cube-queue-context" not in feature_list: + feature_list.append("native-cube-queue-context") if "local-assets" not in feature_list: feature_list.append("local-assets") - sugar_compile_capabilities = services.sugar_compile.compile.capabilities() - record_phase("sugar_compile_capabilities") - if sugar_compile_capabilities.available and "sugar-compile" not in feature_list: - feature_list.append("sugar-compile") feature_payload: list[JsonValue] = list(feature_list) payload["features"] = feature_payload payload["cubeLibrary"] = services.cube_library.library.capabilities() @@ -394,7 +377,13 @@ def _build_capabilities_handler( "eventType": "substitute_cube_output", "previewMetadataKey": "substitute", } - payload["sugarCompile"] = sugar_compile_capabilities.to_payload() + payload["nativeCubeQueueContext"] = { + "schemaVersion": 1, + "queueObserverApiVersion": 1, + "requiredObserver": True, + "preQueuePersistence": True, + "sourceIdentityFromExecutionReport": True, + } record_phase("static_payloads") _log_capabilities_timing( logger, diff --git a/substitute_backend/infrastructure/sugarcubes_host_api.py b/substitute_backend/infrastructure/sugarcubes_host_api.py index dda2d69..441a9bd 100644 --- a/substitute_backend/infrastructure/sugarcubes_host_api.py +++ b/substitute_backend/infrastructure/sugarcubes_host_api.py @@ -24,12 +24,14 @@ from typing import Protocol, cast SUGARCUBES_HOST_API_MODULE = "sugarcubes.host_api" SUPPORTED_SUGARCUBES_HOST_API_VERSION = 1 +SUPPORTED_QUEUE_OBSERVER_API_VERSION = 1 class SugarCubesHostApi(Protocol): """Describe the cross-extension surface owned and published by SugarCubes.""" HOST_API_VERSION: int + QUEUE_OBSERVER_API_VERSION: int def active_backend_services(self) -> object | None: """Return the service graph already created by SugarCubes.""" @@ -40,6 +42,17 @@ class SugarCubesHostApi(Protocol): def unregister_cube_output_observer(self, observer: object) -> None: """Unregister one cube-output observer.""" + def register_validated_queue_observer( + self, + observer: object, + *, + required: bool = False, + ) -> None: + """Register one observer of prompts validated for native queueing.""" + + def unregister_validated_queue_observer(self, observer: object) -> None: + """Unregister one validated-queue observer.""" + class SugarCubesHostApiResolutionStatus(StrEnum): """Describe whether the public SugarCubes API can be consumed now.""" @@ -81,6 +94,16 @@ class SugarCubesHostApiResolver: f"{SUPPORTED_SUGARCUBES_HOST_API_VERSION}." ), ) + queue_observer_version = getattr(module, "QUEUE_OBSERVER_API_VERSION", None) + if queue_observer_version != SUPPORTED_QUEUE_OBSERVER_API_VERSION: + return SugarCubesHostApiResolution( + status=SugarCubesHostApiResolutionStatus.UNAVAILABLE, + message=( + "SugarCubes queue-observer API version " + f"{queue_observer_version!r} is not supported; expected " + f"{SUPPORTED_QUEUE_OBSERVER_API_VERSION}." + ), + ) missing_operation = _missing_operation(module) if missing_operation is not None: return SugarCubesHostApiResolution( @@ -101,6 +124,8 @@ def _missing_operation(module: object) -> str | None: "active_backend_services", "register_cube_output_observer", "unregister_cube_output_observer", + "register_validated_queue_observer", + "unregister_validated_queue_observer", ): if not callable(getattr(module, name, None)): return name diff --git a/tests/test_capabilities_and_routes.py b/tests/test_capabilities_and_routes.py index d2bd872..fb2b2e5 100644 --- a/tests/test_capabilities_and_routes.py +++ b/tests/test_capabilities_and_routes.py @@ -30,7 +30,6 @@ import pytest from aiohttp import web from substitute_backend.api.serialization import JsonObject -from substitute_backend.features.cube_outputs.application import CubeOutputServices from substitute_backend.features.model_metadata.application.capability_service import ( CapabilityService, ) @@ -42,6 +41,9 @@ from substitute_backend.features.preview_assets.application import ( PreviewAssetServices, TaesdAssetService, ) +from substitute_backend.features.sugarcubes_integration import ( + SugarCubesIntegrationServices, +) from substitute_backend.host.extension import build_backend_services from substitute_backend.host.routes import PromptServerLike, register_routes from substitute_backend.infrastructure.logging import get_logger @@ -128,7 +130,6 @@ def test_register_routes_uses_expected_surface(tmp_path: Path) -> None: assert prompt_server.routes.registered == [ ("GET", "/substitute/v1/capabilities"), ("POST", "/substitute/v1/prompt/queue"), - ("POST", "/substitute/v1/sugar/compile"), ("POST", "/substitute/v1/local-assets/authorize"), ("GET", "/substitute/v1/models"), ("GET", "/substitute/v1/models/changes"), @@ -243,10 +244,11 @@ def test_capabilities_payload_advertises_preview_assets( assert isinstance(response, web.Response) assert response.text is not None payload = json.loads(response.text) + assert "sugar-compile" not in payload["features"] + assert "sugarCompile" not in payload assert "preview-assets" in payload["features"] assert "cube-library" in payload["features"] assert "prompt-queue-facade" in payload["features"] - assert "sugar-compile" in payload["features"] assert "local-assets" in payload["features"] assert payload["cubeLibrary"] == { "schemaVersion": 1, @@ -273,13 +275,6 @@ def test_capabilities_payload_advertises_preview_assets( "optimizationReportSupported": True, "debugDumpSupported": False, } - assert payload["sugarCompile"] == { - "schemaVersion": 1, - "available": True, - "compileRoute": "/substitute/v1/sugar/compile", - "liveNodeDefinitions": True, - "sugarDslVersion": "0.8.1", - } asyncio.run(run_capabilities()) @@ -306,8 +301,9 @@ def test_capabilities_payload_survives_cube_output_registration_failure( ) services = replace( services, - cube_outputs=CubeOutputServices( - registration=cast(Any, _FailingRegistration()), + sugarcubes_integration=SugarCubesIntegrationServices( + cube_output_registration=cast(Any, _FailingRegistration()), + queue_context_registration=cast(Any, _FailingRegistration()), ), ) prompt_server = FakePromptServer() @@ -324,6 +320,13 @@ def test_capabilities_payload_survives_cube_output_registration_failure( payload = json.loads(response.text) assert "visual-routing" in payload["features"] assert payload["visualRouting"]["eventType"] == "substitute_cube_output" + assert payload["nativeCubeQueueContext"] == { + "schemaVersion": 1, + "queueObserverApiVersion": 1, + "requiredObserver": True, + "preQueuePersistence": True, + "sourceIdentityFromExecutionReport": True, + } asyncio.run(run_capabilities()) diff --git a/tests/test_cube_outputs.py b/tests/test_cube_outputs.py index efe734f..7ab6df9 100644 --- a/tests/test_cube_outputs.py +++ b/tests/test_cube_outputs.py @@ -88,6 +88,7 @@ class _Hook: identity = "test-hook" HOST_API_VERSION = 1 + QUEUE_OBSERVER_API_VERSION = 1 def __init__(self) -> None: """Initialize empty observer lists.""" @@ -110,6 +111,21 @@ class _Hook: return object() + def register_validated_queue_observer( + self, + observer: object, + *, + required: bool = False, + ) -> None: + """Accept the companion queue-observer API required by the shared contract.""" + + _ = (observer, required) + + def unregister_validated_queue_observer(self, observer: object) -> None: + """Accept queue-observer removal required by the shared contract.""" + + _ = observer + def test_cube_output_event_payload_matches_public_contract() -> None: """Cube-output domain events should serialize the versioned websocket payload.""" @@ -288,6 +304,7 @@ def test_observer_enriches_cube_output_with_substitute_context() -> None: context=SubstituteRunContext( workflow_id="wf-1", generation_run_id="run-1", + output_session_id="generate-click-1", client_id="client-run", scene_key="scene-a", sources={ @@ -329,6 +346,7 @@ def test_observer_enriches_cube_output_with_substitute_context() -> None: "schemaVersion": 1, "workflowId": "wf-1", "generationRunId": "run-1", + "outputSessionId": "generate-click-1", "clientId": "client-run", "sourceKey": "wf-1:node-1", "sourceLabel": "Demo", diff --git a/tests/test_preview_routing.py b/tests/test_preview_routing.py index ba2123b..f43c4be 100644 --- a/tests/test_preview_routing.py +++ b/tests/test_preview_routing.py @@ -81,6 +81,7 @@ def test_preview_metadata_enrichment_adds_substitute_identity() -> None: context=SubstituteRunContext( workflow_id="wf-1", generation_run_id="run-1", + output_session_id="generate-click-1", client_id="client-1", sources={ "node-1": SubstituteSourceRoute( @@ -118,6 +119,7 @@ def test_preview_metadata_enrichment_adds_substitute_identity() -> None: "schemaVersion": 1, "workflowId": "wf-1", "generationRunId": "run-1", + "outputSessionId": "generate-click-1", "clientId": "client-1", "sourceKey": "wf-1:node-1", "sourceLabel": "Demo", diff --git a/tests/test_project_dependencies.py b/tests/test_project_dependencies.py index 542adc6..ed571f1 100644 --- a/tests/test_project_dependencies.py +++ b/tests/test_project_dependencies.py @@ -24,14 +24,12 @@ from pathlib import Path _PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_sugar_dsl_dependency_is_hard_pinned_to_published_release() -> None: - """Keep Backend compilation on the explicitly verified Sugar-DSL release.""" +def test_backend_does_not_depend_on_sugar_dsl() -> None: + """Keep SugarScript compilation outside Substitute BackEnd.""" metadata = tomllib.loads((_PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) project = metadata.get("project") assert isinstance(project, dict) dependencies = project.get("dependencies") assert isinstance(dependencies, list) - assert [value for value in dependencies if str(value).startswith("sugar-dsl")] == [ - "sugar-dsl==1.2.0" - ] + assert not [value for value in dependencies if str(value).startswith("sugar-dsl")] diff --git a/tests/test_prompt_queue.py b/tests/test_prompt_queue.py index 08cdee0..743762b 100644 --- a/tests/test_prompt_queue.py +++ b/tests/test_prompt_queue.py @@ -494,6 +494,7 @@ def test_comfy_queue_adapter_stores_substitute_run_context_by_prompt_id() -> Non "schemaVersion": 1, "workflowId": "wf-1", "generationRunId": "run-1", + "outputSessionId": "generate-click-1", "clientId": "client-1", "sources": { "5": { @@ -512,6 +513,7 @@ def test_comfy_queue_adapter_stores_substitute_run_context_by_prompt_id() -> Non assert context is not None assert context.workflow_id == "wf-1" assert context.generation_run_id == "run-1" + assert context.output_session_id == "generate-click-1" assert context.client_id == "client-1" assert context.sources["5"].source_key == "wf-1:5" diff --git a/tests/test_sugar_compile.py b/tests/test_sugar_compile.py deleted file mode 100644 index 42a9656..0000000 --- a/tests/test_sugar_compile.py +++ /dev/null @@ -1,585 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Tests for backend-owned Sugar compilation.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import Mapping -from importlib import metadata -from pathlib import Path -from typing import Any, cast - -import pytest -from aiohttp import web - -from substitute_backend.api.errors import BackendHttpError -from substitute_backend.api.serialization import JsonObject -from substitute_backend.features.cube_library.application import ( - CubeLibraryService, -) -from substitute_backend.features.sugar_compile.api import ( - SugarCompileRouteHandlers, - build_sugar_compile_route_handlers, -) -from substitute_backend.features.sugar_compile.application import ( - SugarCompileService, - SugarCompileServices, -) -from substitute_backend.features.sugar_compile.domain import ( - SugarCompileError, - SugarCompileRequest, - SugarCompileResult, - SugarCompileUnavailableError, -) -from substitute_backend.features.sugar_compile.infrastructure import ( - BackendCubeArtifactResolver, - SugarDslWorkflowCompiler, -) -from substitute_backend.infrastructure.logging import get_logger - - -class FakeRequest: - """Minimal aiohttp-like request with injectable JSON body.""" - - def __init__(self, body: object) -> None: - """Store one request body.""" - - self._body = body - - async def json(self) -> object: - """Return the configured JSON body.""" - - return self._body - - -class RecordingCompiler: - """Record Sugar compile service calls and return deterministic payloads.""" - - def __init__(self, *, available: bool = True, failure: Exception | None = None) -> None: - """Configure availability and optional compile failure.""" - - self.calls: list[tuple[str, Path]] = [] - self._available = available - self._failure = failure - - def is_available(self) -> bool: - """Return configured availability.""" - - return self._available - - def unavailable_reason(self) -> str: - """Return a deterministic unavailable message.""" - - return "Sugar-DSL is not installed in the ComfyUI environment." - - def sugar_dsl_version(self) -> str: - """Return a deterministic Sugar-DSL version.""" - - return "0.8.1" - - def compile(self, *, script_text: str, output_dir: Path) -> SugarCompileResult: - """Record the compile request and return a wrapped artifact payload.""" - - self.calls.append((script_text, output_dir)) - if self._failure is not None: - raise self._failure - return SugarCompileResult( - prompt={"1": {"class_type": "KSampler"}}, - workflow={"nodes": []}, - ) - - -class RecordingCubeLibraryGateway: - """Return raw Cube Library artifacts while recording load calls.""" - - def __init__( - self, - *, - invalid_cube_id: str = "", - invalid_version: str = "", - missing_cube_id: str = "", - ) -> None: - """Configure load recording and failure controls.""" - - self.latest_loads: list[str] = [] - self.version_loads: list[tuple[str, str]] = [] - self._invalid_cube_id = invalid_cube_id - self._invalid_version = invalid_version - self._missing_cube_id = missing_cube_id - - def status(self) -> JsonObject: - """Return available status.""" - - return {"schemaVersion": 1, "available": True} - - def capabilities(self) -> JsonObject: - """Return available Cube Library capabilities.""" - - return {"schemaVersion": 1, "available": True} - - def catalog( - self, - *, - include_disabled: bool, - diagnostic_context: object | None = None, - ) -> JsonObject: - """Return an empty catalog.""" - - _ = include_disabled, diagnostic_context - return {"schemaVersion": 1, "cubes": []} - - def load_cube( - self, - cube_id: str, - *, - diagnostic_context: object | None = None, - ) -> JsonObject: - """Return a latest cube artifact.""" - - _ = diagnostic_context - self.latest_loads.append(cube_id) - return self._artifact(cube_id, "latest") - - def list_cube_versions(self, cube_id: str) -> JsonObject: - """Return no versions.""" - - _ = cube_id - return {"schemaVersion": 1, "versions": []} - - def load_cube_version( - self, - *, - cube_id: str, - version: str, - diagnostic_context: object | None = None, - ) -> JsonObject: - """Return a versioned cube artifact.""" - - _ = diagnostic_context - self.version_loads.append((cube_id, version)) - return self._artifact(cube_id, version) - - def prewarm_cube_version(self, *, cube_id: str, version: str) -> JsonObject: - """Return accepted prewarm status.""" - - _ = cube_id, version - return {"schemaVersion": 1, "accepted": True} - - def icon_asset(self, cube_id: str) -> tuple[bytes, str]: - """Return icon content.""" - - _ = cube_id - return b"", "image/png" - - def list_packs(self) -> JsonObject: - """Return no packs.""" - - return {"schemaVersion": 1, "packs": []} - - def preflight_pack(self, *, owner: str, repo: str, branch: str) -> JsonObject: - """Return a no-op preflight payload.""" - - _ = owner, repo, branch - return {"schemaVersion": 1} - - def add_pack( - self, - *, - owner: str, - repo: str, - branch: str, - enabled: bool, - auto_update: bool, - sync_immediately: bool, - ) -> JsonObject: - """Return a no-op add payload.""" - - _ = owner, repo, branch, enabled, auto_update, sync_immediately - return {"schemaVersion": 1} - - def update_pack( - self, - *, - owner: str, - repo: str, - branch: str | None, - enabled: bool | None, - auto_update: bool | None, - ) -> JsonObject: - """Return a no-op update payload.""" - - _ = owner, repo, branch, enabled, auto_update - return {"schemaVersion": 1} - - def remove_pack(self, *, owner: str, repo: str) -> JsonObject: - """Return a no-op remove payload.""" - - _ = owner, repo - return {"schemaVersion": 1} - - def sync_pack(self, *, owner: str, repo: str) -> JsonObject: - """Return a no-op sync payload.""" - - _ = owner, repo - return {"schemaVersion": 1} - - def sync_all_packs(self) -> JsonObject: - """Return a no-op sync-all payload.""" - - return {"schemaVersion": 1} - - def readiness(self) -> JsonObject: - """Return readiness.""" - - return {"schemaVersion": 1, "ready": True} - - def dependency_readiness(self) -> JsonObject: - """Return dependency readiness.""" - - return {"schemaVersion": 1, "ready": True} - - def repair_dependencies( - self, - *, - baseline_only: bool, - approved_node_ids: tuple[str, ...], - sync_enabled_repos: bool, - ) -> JsonObject: - """Return repair result.""" - - _ = baseline_only, approved_node_ids, sync_enabled_repos - return {"schemaVersion": 1, "restartRequired": False} - - def sync_and_check(self, payload: dict[str, object]) -> JsonObject: - """Return a no-op sync/check result.""" - - _ = payload - return {"schemaVersion": 1, "restartRequired": False} - - def _artifact(self, cube_id: str, version: str) -> JsonObject: - """Build a raw Cube Library artifact.""" - - if cube_id == self._missing_cube_id: - raise BackendHttpError( - message="Missing cube.", - status=404, - code="cube-library-not-found", - ) - declared_cube_id = self._invalid_cube_id or cube_id - declared_version = self._invalid_version or version - return { - "schemaVersion": 1, - "cubeId": declared_cube_id, - "version": declared_version, - "cube": { - "cube_id": cube_id, - "version": version, - "implementation": { - "nodes": { - "node": { - "class_type": "TestNode", - "inputs": {"value": 1}, - } - }, - "inputs": {}, - "outputs": {}, - "definitions": {}, - "subgraphs": [], - "layout": {}, - }, - "surface": { - "default_flavor_id": "default", - "controls": [], - }, - "flavors": { - "authored": [ - { - "id": "default", - "name": "Default", - "values": {}, - } - ] - }, - }, - } - - -def _request(body: object) -> web.Request: - """Return a typed fake aiohttp request.""" - - return cast(web.Request, FakeRequest(body)) - - -def _payload(response: web.StreamResponse) -> dict[str, Any]: - """Decode one JSON response.""" - - text = cast(web.Response, response).text or "{}" - return cast(dict[str, Any], json.loads(text)) - - -def _handlers(compiler: RecordingCompiler) -> SugarCompileRouteHandlers: - """Build Sugar compile route handlers with a fake compiler.""" - - return build_sugar_compile_route_handlers( - SugarCompileServices( - compile=SugarCompileService( - compiler=compiler, - logger=get_logger("tests.sugar_compile.service"), - ) - ), - logger=get_logger("tests.sugar_compile.routes"), - ) - - -def test_compile_route_rejects_non_object_json() -> None: - """Sugar compile route should reject array request bodies.""" - - async def run() -> None: - response = await _handlers(RecordingCompiler()).compile_sugar(_request([])) - - assert response.status == 400 - assert _payload(response)["error"]["code"] == "sugar-compile-invalid-request" - - asyncio.run(run()) - - -def test_compile_route_rejects_missing_script_text() -> None: - """Sugar compile route should require script text before invoking compiler.""" - - async def run() -> None: - compiler = RecordingCompiler() - response = await _handlers(compiler).compile_sugar( - _request( - { - "schemaVersion": 1, - "outputDir": "E:\\outputs", - } - ) - ) - - assert response.status == 400 - assert _payload(response)["error"]["code"] == "sugar-compile-invalid-request" - assert compiler.calls == [] - - asyncio.run(run()) - - -def test_compile_route_returns_wrapped_prompt_and_workflow() -> None: - """Sugar compile route should return the compiler artifact payload.""" - - async def run() -> None: - compiler = RecordingCompiler() - response = await _handlers(compiler).compile_sugar( - _request( - { - "schemaVersion": 1, - "sugarScriptText": 'use "Owner/Repo/demo.cube" as demo', - "outputDir": "E:\\outputs", - } - ) - ) - - assert response.status == 200 - assert _payload(response) == { - "prompt": {"1": {"class_type": "KSampler"}}, - "workflow": {"nodes": []}, - } - assert compiler.calls == [('use "Owner/Repo/demo.cube" as demo', Path("E:\\outputs"))] - - asyncio.run(run()) - - -def test_compile_service_capabilities_include_sugar_dsl_version() -> None: - """Sugar compile capabilities should expose the Backend-owned Sugar-DSL version.""" - - service = SugarCompileService( - compiler=RecordingCompiler(), - logger=logging.getLogger("tests.sugar_compile.service"), - ) - - assert service.capabilities().to_payload() == { - "schemaVersion": 1, - "available": True, - "compileRoute": "/substitute/v1/sugar/compile", - "liveNodeDefinitions": True, - "sugarDslVersion": "0.8.1", - } - - -def test_sugar_dsl_compiler_reads_installed_distribution_version( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Sugar-DSL infrastructure adapter should resolve installed package metadata.""" - - def installed_version(name: str) -> str: - """Return deterministic Sugar-DSL metadata.""" - - _ = name - return "0.9.2" - - monkeypatch.setattr(metadata, "version", installed_version) - compiler = SugarDslWorkflowCompiler( - cube_library=CubeLibraryService(gateway=RecordingCubeLibraryGateway()), - logger=get_logger("tests.sugar_compile.compiler"), - ) - - assert compiler.sugar_dsl_version() == "0.9.2" - - -def test_sugar_dsl_compiler_allows_missing_distribution_version( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Missing Sugar-DSL package metadata should not disable compile capability.""" - - def missing_version(name: str) -> str: - """Raise the package metadata missing error.""" - - raise metadata.PackageNotFoundError(name) - - monkeypatch.setattr(metadata, "version", missing_version) - compiler = SugarDslWorkflowCompiler( - cube_library=CubeLibraryService(gateway=RecordingCubeLibraryGateway()), - logger=get_logger("tests.sugar_compile.compiler"), - ) - - assert compiler.sugar_dsl_version() == "" - - -def test_compile_service_maps_unavailable_errors() -> None: - """Sugar compile service should preserve unavailable setup failures.""" - - service = SugarCompileService( - compiler=RecordingCompiler(failure=SugarCompileUnavailableError("Sugar-DSL is missing.")), - logger=logging.getLogger("tests.sugar_compile.service"), - ) - - request = SugarCompileRequest( - sugar_script_text='use "Owner/Repo/demo.cube" as demo', - output_dir=Path("E:\\outputs"), - ) - - with pytest.raises(BackendHttpError) as error_info: - service.compile(request) - - assert error_info.value.status == 503 - assert error_info.value.code == "sugar-compile-unavailable" - - -def test_compile_service_maps_expected_compile_failures() -> None: - """Sugar compile service should preserve structured compile failures.""" - - service = SugarCompileService( - compiler=RecordingCompiler( - failure=SugarCompileError( - "Cube invalid.", - status=502, - code="sugar-cube-artifact-invalid", - ) - ), - logger=logging.getLogger("tests.sugar_compile.service"), - ) - - request = SugarCompileRequest( - sugar_script_text='use "Owner/Repo/demo.cube" as demo', - output_dir=Path("E:\\outputs"), - ) - - with pytest.raises(BackendHttpError) as error_info: - service.compile(request) - - assert error_info.value.status == 502 - assert error_info.value.code == "sugar-cube-artifact-invalid" - - -def test_cube_artifact_resolver_loads_latest_and_pinned_artifacts_once() -> None: - """Backend resolver should memoize equivalent cube/version requests.""" - - gateway = RecordingCubeLibraryGateway() - resolver = BackendCubeArtifactResolver( - cube_library=CubeLibraryService(gateway=gateway), - logger=get_logger("tests.sugar_compile.resolver"), - ) - - latest_a = resolver.resolve( - alias="latestA", - cube_id="Owner/Repo/latest.cube", - requested_version=None, - ) - latest_b = resolver.resolve( - alias="latestB", - cube_id="Owner/Repo/latest.cube", - requested_version=None, - ) - pinned_a = resolver.resolve( - alias="pinnedA", - cube_id="Owner/Repo/pinned.cube", - requested_version="1.0.0", - ) - pinned_b = resolver.resolve( - alias="pinnedB", - cube_id="Owner/Repo/pinned.cube", - requested_version="1.0.0", - ) - - assert latest_a is latest_b - assert pinned_a is pinned_b - assert gateway.latest_loads == ["Owner/Repo/latest.cube"] - assert gateway.version_loads == [("Owner/Repo/pinned.cube", "1.0.0")] - - -def test_cube_artifact_resolver_rejects_identity_mismatch() -> None: - """Backend resolver should reject Cube Library artifact id mismatches.""" - - resolver = BackendCubeArtifactResolver( - cube_library=CubeLibraryService( - gateway=RecordingCubeLibraryGateway(invalid_cube_id="Other/Repo/demo.cube") - ), - logger=get_logger("tests.sugar_compile.resolver"), - ) - - with pytest.raises(SugarCompileError) as error_info: - resolver.resolve( - alias="demo", - cube_id="Owner/Repo/demo.cube", - requested_version=None, - ) - - assert error_info.value.code == "sugar-cube-artifact-invalid" - - -def test_cube_artifact_resolver_returns_sugar_resolved_artifact() -> None: - """Backend resolver should return Sugar-DSL's resolved artifact model.""" - - resolver = BackendCubeArtifactResolver( - cube_library=CubeLibraryService(gateway=RecordingCubeLibraryGateway()), - logger=get_logger("tests.sugar_compile.resolver"), - ) - - resolved = resolver.resolve( - alias="demo", - cube_id="Owner/Repo/demo.cube", - requested_version="1.2.3", - ) - identity = cast(Any, resolved).identity - cube = cast(Mapping[str, object], cast(Any, resolved).cube) - - assert identity.cube_id == "Owner/Repo/demo.cube" - assert identity.requested_version == "1.2.3" - assert identity.resolved_version == "1.2.3" - assert cube["cube_id"] == "Owner/Repo/demo.cube" diff --git a/tests/test_sugar_live_node_definitions.py b/tests/test_sugar_live_node_definitions.py deleted file mode 100644 index 738d67f..0000000 --- a/tests/test_sugar_live_node_definitions.py +++ /dev/null @@ -1,132 +0,0 @@ -# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI -# Copyright (C) 2026 Artificial Sweetener and contributors -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -"""Tests for backend orchestration of Sugar-owned live definitions.""" - -from __future__ import annotations - -import importlib -import logging -from pathlib import Path -from typing import Any, cast - -import pytest - -from substitute_backend.api.serialization import JsonObject -from substitute_backend.features.cube_library.application import CubeLibraryService -from substitute_backend.features.sugar_compile.domain import SugarCompileError -from substitute_backend.features.sugar_compile.infrastructure import ( - SugarDslWorkflowCompiler, -) - - -def test_backend_compiler_passes_live_provider_to_sugar_dsl( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Backend compile calls should receive Sugar-DSL's runtime live provider.""" - - builder = importlib.import_module("sugar.api.builder") - - calls: list[dict[str, Any]] = [] - - def fake_build_comfy_artifacts_from_text( - script_text: str, - *, - output_dir: Path, - cube_artifact_resolver: object, - live_node_definition_provider: object, - ) -> JsonObject: - """Record Sugar-DSL compile keyword arguments.""" - - calls.append( - { - "script_text": script_text, - "output_dir": output_dir, - "cube_artifact_resolver": cube_artifact_resolver, - "live_node_definition_provider": live_node_definition_provider, - } - ) - return {"prompt": {}, "workflow": {}} - - monkeypatch.setattr( - builder, - "build_comfy_artifacts_from_text", - fake_build_comfy_artifacts_from_text, - ) - compiler = SugarDslWorkflowCompiler( - cube_library=_unused_cube_library(), - logger=logging.getLogger("tests.sugar.compiler"), - ) - - compiler.compile(script_text='use "demo" as Demo', output_dir=Path("E:\\outputs")) - - [call] = calls - assert call["script_text"] == 'use "demo" as Demo' - assert call["output_dir"] == Path("E:\\outputs") - provider = call["live_node_definition_provider"] - assert type(provider).__name__ == "ComfyRegistryLiveNodeDefinitionProvider" - assert type(provider).__module__ == "sugar.runtime.live_definitions" - - -def test_backend_compiler_maps_live_definition_errors( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Sugar-DSL live-definition failures should keep their structured code.""" - - builder = importlib.import_module("sugar.api.builder") - errors = importlib.import_module("sugar.compiler.errors") - sugar_compiler_error = cast(Any, errors).SugarCompilerError - - def fake_build_comfy_artifacts_from_text( - script_text: str, - *, - output_dir: Path, - cube_artifact_resolver: object, - live_node_definition_provider: object, - ) -> JsonObject: - """Raise the typed Sugar-DSL error from the compile boundary.""" - - _ = script_text, output_dir, cube_artifact_resolver, live_node_definition_provider - raise sugar_compiler_error( - "Required live input has no default.", - code="sugar-live-default-missing", - cube_alias="Demo", - cube_id="demo", - node_key="Demo.node", - node_class_type="SimpleNode", - input_name="new_widget", - ) - - monkeypatch.setattr( - builder, - "build_comfy_artifacts_from_text", - fake_build_comfy_artifacts_from_text, - ) - compiler = SugarDslWorkflowCompiler( - cube_library=_unused_cube_library(), - logger=logging.getLogger("tests.sugar.compiler"), - ) - - with pytest.raises(SugarCompileError) as error_info: - compiler.compile(script_text='use "demo" as Demo', output_dir=Path("E:\\outputs")) - - assert error_info.value.code == "sugar-live-default-missing" - assert error_info.value.status == 400 - - -def _unused_cube_library() -> CubeLibraryService: - """Return a cube library placeholder for compile calls that do not resolve cubes.""" - - return CubeLibraryService(gateway=cast(Any, object())) diff --git a/tests/test_sugarcubes_adapter.py b/tests/test_sugarcubes_adapter.py index feea82a..a1159f9 100644 --- a/tests/test_sugarcubes_adapter.py +++ b/tests/test_sugarcubes_adapter.py @@ -244,4 +244,9 @@ def _publish_host_api( module.__dict__["active_backend_services"] = lambda: services module.__dict__["register_cube_output_observer"] = lambda _observer: None module.__dict__["unregister_cube_output_observer"] = lambda _observer: None + module.__dict__["QUEUE_OBSERVER_API_VERSION"] = 1 + module.__dict__["register_validated_queue_observer"] = lambda _observer, *, required=False: ( + required + ) + module.__dict__["unregister_validated_queue_observer"] = lambda _observer: None monkeypatch.setitem(sys.modules, SUGARCUBES_HOST_API_MODULE, module) diff --git a/tests/test_sugarcubes_host_api.py b/tests/test_sugarcubes_host_api.py index 9cb5238..ece8fc6 100644 --- a/tests/test_sugarcubes_host_api.py +++ b/tests/test_sugarcubes_host_api.py @@ -43,6 +43,21 @@ def test_resolver_returns_the_canonical_versioned_host_api( assert resolution.api is module +def test_resolver_requires_the_validated_queue_observer_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Native queue context must fail closed when SugarCubes lacks its observer API.""" + + module = _host_api_module(version=1) + del module.__dict__["register_validated_queue_observer"] + monkeypatch.setitem(sys.modules, SUGARCUBES_HOST_API_MODULE, module) + + resolution = SugarCubesHostApiResolver().resolve() + + assert resolution.status is SugarCubesHostApiResolutionStatus.UNAVAILABLE + assert "register_validated_queue_observer" in resolution.message + + def test_resolver_rejects_an_unsupported_host_api_version( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -77,4 +92,9 @@ def _host_api_module(*, version: int) -> ModuleType: module.__dict__["active_backend_services"] = lambda: object() module.__dict__["register_cube_output_observer"] = lambda _observer: None module.__dict__["unregister_cube_output_observer"] = lambda _observer: None + module.__dict__["QUEUE_OBSERVER_API_VERSION"] = 1 + module.__dict__["register_validated_queue_observer"] = lambda _observer, *, required=False: ( + required + ) + module.__dict__["unregister_validated_queue_observer"] = lambda _observer: None return module diff --git a/tests/test_sugarcubes_queue_context.py b/tests/test_sugarcubes_queue_context.py new file mode 100644 index 0000000..07aeafd --- /dev/null +++ b/tests/test_sugarcubes_queue_context.py @@ -0,0 +1,303 @@ +# Substitute BackEnd - backend liaison services for SugarSubstitute and ComfyUI +# Copyright (C) 2026 Artificial Sweetener and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Tests for Substitute context capture at SugarCubes' native queue boundary.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +import pytest + +from substitute_backend.features.prompt_queue.application.run_context_store import ( + SubstituteRunContextStore, +) +from substitute_backend.features.prompt_queue.infrastructure.sugarcubes_queue_context import ( + SugarCubesQueueContextObserver, +) +from substitute_backend.features.sugarcubes_integration.queue_context_registration import ( + SugarCubesQueueContextRegistration, +) +from substitute_backend.infrastructure.sugarcubes_host_api import ( + SugarCubesHostApi, + SugarCubesHostApiResolution, + SugarCubesHostApiResolutionStatus, + SugarCubesHostApiResolver, +) + + +@dataclass(frozen=True) +class _ValidatedQueueEvent: + """Provide the structural queue event consumed by the observer.""" + + prompt_id: str + prompt: object + extra_data: object + report: object | None = None + + +class _HostApi: + """Capture the exact callable registered at the cross-extension boundary.""" + + def __init__(self) -> None: + """Initialize empty registration state.""" + + self.observer: Callable[[object], None] | None = None + self.required = False + + def register_validated_queue_observer( + self, + observer: object, + *, + required: bool = False, + ) -> None: + """Retain a callable observer and its fail-closed policy.""" + + assert callable(observer) + self.observer = observer + self.required = required + + +class _Resolver: + """Resolve one configured host API for registration tests.""" + + def __init__(self, api: _HostApi) -> None: + """Store the API returned by resolve.""" + + self._api = api + + def resolve(self) -> SugarCubesHostApiResolution: + """Return a successful structural resolution.""" + + return SugarCubesHostApiResolution( + status=SugarCubesHostApiResolutionStatus.RESOLVED, + message="resolved", + api=cast(SugarCubesHostApi, self._api), + ) + + +def test_native_queue_observer_stores_substitute_context_before_queueing() -> None: + """A validated native prompt should retain its Substitute visual routing context.""" + + store = SubstituteRunContextStore(time_source=lambda: 123.0) + observer = SugarCubesQueueContextObserver(run_context_store=store) + + observer.on_validated_queue( + _ValidatedQueueEvent( + prompt_id="prompt-1", + prompt={"5": {"class_type": "TestNode", "inputs": {}}}, + extra_data={"substitute": _substitute_context()}, + ) + ) + + context = store.resolve("prompt-1") + assert context is not None + assert context.workflow_id == "wf-1" + assert context.sources["5"].source_label == "CubeA" + + +def test_native_queue_observer_ignores_non_substitute_comfy_prompts() -> None: + """Ordinary Comfy queue traffic should not create Substitute-owned context.""" + + store = SubstituteRunContextStore() + observer = SugarCubesQueueContextObserver(run_context_store=store) + + observer.on_validated_queue( + _ValidatedQueueEvent(prompt_id="prompt-1", prompt={}, extra_data={}) + ) + + assert store.resolve("prompt-1") is None + + +def test_native_queue_observer_derives_output_sources_from_execution_report() -> None: + """SugarCubes output identities should become BackEnd routing sources atomically.""" + + @dataclass(frozen=True) + class _OutputIdentity: + """Provide one SugarCubes report output identity.""" + + execution_id: str + instance_id: str + instance_alias: str | None + + @dataclass(frozen=True) + class _Report: + """Provide the output-identity portion of a SugarCubes execution report.""" + + output_identities: tuple[_OutputIdentity, ...] + + store = SubstituteRunContextStore() + observer = SugarCubesQueueContextObserver(run_context_store=store) + context = _substitute_context() + context.pop("sources") + + observer.on_validated_queue( + _ValidatedQueueEvent( + prompt_id="prompt-1", + prompt={"output-1": {"class_type": "SugarCubes.CubeOutput"}}, + extra_data={"substitute": context}, + report=_Report((_OutputIdentity("output-1", "instance-a", "CubeA"),)), + ) + ) + + stored = store.resolve("prompt-1") + assert stored is not None + assert stored.sources["output-1"].source_key == "cube:instance-a" + assert stored.sources["output-1"].source_label == "CubeA" + + +def test_native_queue_observer_uses_substitute_cube_presentation_labels() -> None: + """Use pre-queue presentation labels instead of qualified execution aliases.""" + + @dataclass(frozen=True) + class _OutputIdentity: + """Provide one SugarCubes report output identity.""" + + execution_id: str + instance_id: str + instance_alias: str + + @dataclass(frozen=True) + class _Report: + """Provide output identities after SugarCubes lowers the graph.""" + + output_identities: tuple[_OutputIdentity, ...] + + store = SubstituteRunContextStore() + observer = SugarCubesQueueContextObserver(run_context_store=store) + context = _substitute_context() + context.pop("sources") + context["cubePresentations"] = {"instance-a": "Prompt by Region"} + + observer.on_validated_queue( + _ValidatedQueueEvent( + prompt_id="prompt-1", + prompt={"output-1": {"class_type": "SugarCubes.CubeOutput"}}, + extra_data={"substitute": context}, + report=_Report( + ( + _OutputIdentity( + "output-1", + "instance-a", + "Anima/Prompt by Region", + ), + ) + ), + ) + ) + + stored = store.resolve("prompt-1") + assert stored is not None + assert stored.sources["output-1"].source_key == "cube:instance-a" + assert stored.sources["output-1"].source_label == "Prompt by Region" + + +def test_native_queue_observer_routes_every_cube_owned_execution_node() -> None: + """Internal lowered nodes should carry Cube identity for previews and timing.""" + + @dataclass(frozen=True) + class _ExecutionIdentity: + """Provide one SugarCubes lowered-node identity.""" + + execution_id: str + instance_id: str + instance_alias: str + + @dataclass(frozen=True) + class _Report: + """Provide the node-identity portion of a SugarCubes execution report.""" + + execution_node_identities: tuple[_ExecutionIdentity, ...] + + store = SubstituteRunContextStore() + observer = SugarCubesQueueContextObserver(run_context_store=store) + context = _substitute_context() + context.pop("sources") + + observer.on_validated_queue( + _ValidatedQueueEvent( + prompt_id="prompt-1", + prompt={"cube-a:sampler": {"class_type": "KSampler"}}, + extra_data={"substitute": context}, + report=_Report((_ExecutionIdentity("cube-a:sampler", "instance-a", "CubeA"),)), + ) + ) + + stored = store.resolve("prompt-1") + assert stored is not None + assert stored.sources["cube-a:sampler"].source_key == "cube:instance-a" + assert stored.sources["cube-a:sampler"].source_label == "CubeA" + + +def test_native_queue_observer_rejects_malformed_substitute_context() -> None: + """Malformed claimed Substitute context must stop queue insertion fail-closed.""" + + observer = SugarCubesQueueContextObserver(run_context_store=SubstituteRunContextStore()) + + with pytest.raises(ValueError, match="Substitute run context"): + observer.on_validated_queue( + _ValidatedQueueEvent( + prompt_id="prompt-1", + prompt={}, + extra_data={"substitute": {"schemaVersion": 1}}, + ) + ) + + +def test_registration_passes_the_observer_method_as_a_required_callable() -> None: + """Registration must give SugarCubes a callable instead of its owning object.""" + + store = SubstituteRunContextStore() + observer = SugarCubesQueueContextObserver(run_context_store=store) + api = _HostApi() + registration = SugarCubesQueueContextRegistration( + observer=observer, + logger=logging.getLogger(__name__), + host_api_resolver=cast(SugarCubesHostApiResolver, _Resolver(api)), + ) + + registration.register() + + assert api.required is True + assert api.observer is not None + api.observer( + _ValidatedQueueEvent( + prompt_id="prompt-registered", + prompt={"5": {"class_type": "TestNode", "inputs": {}}}, + extra_data={"substitute": _substitute_context()}, + ) + ) + assert store.resolve("prompt-registered") is not None + + +def _substitute_context() -> dict[str, object]: + """Return a valid app-owned visual routing payload.""" + + return { + "schemaVersion": 1, + "workflowId": "wf-1", + "generationRunId": "run-1", + "clientId": "client-1", + "sources": { + "5": { + "sourceKey": "wf-1:5", + "sourceLabel": "CubeA", + "cubeAlias": "CubeA", + } + }, + }