feat(environment): manage ComfyUI model roots

This commit is contained in:
Artificial Sweetener
2026-07-15 22:58:52 -04:00
parent bfc9c3c6d6
commit 94ef103bb6
21 changed files with 972 additions and 5 deletions
+48
View File
@@ -0,0 +1,48 @@
# 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 <https://www.gnu.org/licenses/>.
"""Configure BackEnd model-root state before a newly installed Comfy can start."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
_EXTENSION_ROOT = Path(__file__).resolve().parent
if str(_EXTENSION_ROOT) not in sys.path:
sys.path.insert(0, str(_EXTENSION_ROOT))
from substitute_backend.features.environment_management.infrastructure.model_root_store import ( # noqa: E402
ModelRootStore,
)
def main(argv: list[str] | None = None) -> int:
"""Persist a model-root selection for an offline Comfy installation."""
parser = argparse.ArgumentParser()
parser.add_argument("--comfy-root", required=True, type=Path)
selection = parser.add_mutually_exclusive_group(required=True)
selection.add_argument("--default", action="store_true")
selection.add_argument("--path", type=Path)
arguments = parser.parse_args(argv)
store = ModelRootStore(arguments.comfy_root)
store.save(None if arguments.default else arguments.path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "substitute-backend",
"version": "1.6.2",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "substitute-backend",
"version": "1.6.2",
"version": "1.7.0",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "substitute-backend",
"private": true,
"version": "1.6.2",
"version": "1.7.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+45
View File
@@ -0,0 +1,45 @@
# 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 <https://www.gnu.org/licenses/>.
"""Run BackEnd model-root configuration in ComfyUI's prestartup phase."""
from __future__ import annotations
import importlib
import importlib.util
from pathlib import Path
from types import ModuleType
def _load_prestartup_support(extension_root: Path) -> ModuleType:
"""Load the isolated support module without importing the node package."""
module_path = extension_root / "substitute_backend_prestartup.py"
spec = importlib.util.spec_from_file_location(
"substitute_backend_prestartup_support", module_path
)
if spec is None or spec.loader is None:
raise RuntimeError("Substitute BackEnd prestartup support could not be loaded.")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_EXTENSION_ROOT = Path(__file__).resolve().parent
_SUPPORT = _load_prestartup_support(_EXTENSION_ROOT)
_SUPPORT.apply_model_root(
_EXTENSION_ROOT.parents[1],
importlib.import_module("folder_paths"),
)
+4 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "substitute-backend"
description = "Backend liaison services for SugarSubstitute and ComfyUI."
version = "1.6.2"
version = "1.7.0"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
requires-python = ">=3.12"
@@ -31,6 +31,9 @@ dev = [
Repository = "https://github.com/Artificial-Sweetener/Substitute-BackEnd"
"Bug Tracker" = "https://github.com/Artificial-Sweetener/Substitute-BackEnd/issues"
[tool.setuptools]
py-modules = ["substitute_backend_prestartup"]
[tool.comfy]
PublisherId = "artificialsweetener"
DisplayName = "Substitute BackEnd"
+1 -1
View File
@@ -17,7 +17,7 @@
from __future__ import annotations
__version__ = "1.6.2"
__version__ = "1.7.0"
EXTENSION_NAME = "Substitute BackEnd"
API_VERSION = 1
@@ -27,6 +27,7 @@ from substitute_backend.api.errors import BackendHttpError, json_error
from substitute_backend.features.environment_management.application.services import (
EnvironmentManagementServices,
)
from substitute_backend.features.environment_management.domain.model_root import ModelRootMode
RouteHandler = Callable[[web.Request], Awaitable[web.StreamResponse]]
@@ -37,6 +38,8 @@ class EnvironmentRouteHandlers:
capabilities: RouteHandler
status: RouteHandler
get_model_root: RouteHandler
update_model_root: RouteHandler
list_packages: RouteHandler
list_components: RouteHandler
plan_operation: RouteHandler
@@ -85,6 +88,44 @@ def build_environment_route_handlers(
)
)
async def get_model_root(request: web.Request) -> web.Response:
"""Return persisted and active Comfy model-root state."""
_ = request
try:
return web.json_response(services.model_root.get_status().to_payload())
except (OSError, ValueError) as exc:
logger.warning(
"model-root status unavailable",
extra={"operation": "model-root-status", "error": repr(exc)},
)
return json_error(
BackendHttpError(
message="Model-root configuration could not be read.",
status=500,
code="model-root-status-unavailable",
)
)
async def update_model_root(request: web.Request) -> web.Response:
"""Persist the model root that ComfyUI will use after restart."""
try:
body = await _json_object_body(request)
mode, raw_path = _parse_model_root_update(body)
status = services.model_root.configure(mode, raw_path)
return web.json_response(status.to_payload())
except BackendHttpError as exc:
return json_error(exc)
except (OSError, ValueError) as exc:
return json_error(
BackendHttpError(
message=str(exc),
status=400,
code="invalid-model-root",
)
)
async def restart(request: web.Request) -> web.Response:
"""Queue a Comfy restart operation."""
@@ -302,6 +343,8 @@ def build_environment_route_handlers(
return EnvironmentRouteHandlers(
capabilities=capabilities,
status=status,
get_model_root=get_model_root,
update_model_root=update_model_root,
list_packages=list_packages,
list_components=list_components,
plan_operation=plan_operation,
@@ -330,6 +373,36 @@ async def _json_object_body(request: web.Request) -> dict[str, object]:
return body
def _parse_model_root_update(
body: dict[str, object],
) -> tuple[ModelRootMode, str | None]:
"""Parse one model-root update without leaking untyped request data."""
raw_mode = body.get("mode")
if not isinstance(raw_mode, str):
raise BackendHttpError(
message="Model-root mode is required.",
status=400,
code="invalid-model-root",
)
try:
mode = ModelRootMode(raw_mode)
except ValueError as exc:
raise BackendHttpError(
message="Model-root mode must be 'default' or 'custom'.",
status=400,
code="invalid-model-root",
) from exc
raw_path = body.get("path")
if raw_path is not None and not isinstance(raw_path, str):
raise BackendHttpError(
message="Model-root path must be a string.",
status=400,
code="invalid-model-root",
)
return mode, raw_path
def _required_int(data: dict[str, object], key: str) -> int:
"""Read one required integer request field."""
@@ -50,6 +50,7 @@ class EnvironmentService:
EnvironmentFeature.PACKAGE_INVENTORY,
EnvironmentFeature.COMPONENT_INVENTORY,
EnvironmentFeature.OPERATION_PLANNING,
EnvironmentFeature.MODEL_ROOT_MANAGEMENT,
]
if restart_support.supported:
supported_features.append(EnvironmentFeature.RESTART)
@@ -59,6 +60,7 @@ class EnvironmentService:
restart_supported=restart_support.supported,
package_mutation_supported=False,
operation_planning_supported=True,
model_root_management_supported=True,
restart_unavailable_reason=restart_support.unavailable_reason,
)
@@ -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 <https://www.gnu.org/licenses/>.
"""Coordinate persisted and active Comfy model-root state."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from substitute_backend.features.environment_management.domain.model_root import (
ModelRootMode,
ModelRootStatus,
)
from substitute_backend.features.environment_management.infrastructure.model_root_runtime import (
ModelRootRuntime,
)
from substitute_backend.features.environment_management.infrastructure.model_root_store import (
ModelRootStore,
)
_MODEL_ROOT_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ModelRootService:
"""Own model-root queries and configuration changes for one Comfy host."""
comfy_root: Path
store: ModelRootStore
runtime: ModelRootRuntime
def get_status(self) -> ModelRootStatus:
"""Return the persisted selection alongside the running process state."""
default_root = (self.comfy_root / "models").resolve()
configured_root = self.store.load()
active_root = self.runtime.active_model_root()
desired_root = configured_root or default_root
return ModelRootStatus(
schema_version=_MODEL_ROOT_SCHEMA_VERSION,
default_model_root=default_root,
configured_model_root=configured_root,
active_model_root=active_root,
restart_required=desired_root != active_root,
)
def configure(self, mode: ModelRootMode, path: str | None) -> ModelRootStatus:
"""Persist an explicit default or custom selection for the next launch."""
if mode is ModelRootMode.DEFAULT:
if path is not None:
raise ValueError("Default model-root mode does not accept a path.")
self.store.save(None)
else:
if path is None or not path.strip():
raise ValueError("Custom model-root mode requires a path.")
self.store.save(ModelRootStore.resolve_custom_root(path))
return self.get_status()
__all__ = ["ModelRootService"]
@@ -32,6 +32,9 @@ from substitute_backend.features.environment_management.application.inventory_se
from substitute_backend.features.environment_management.application.job_service import (
JobService,
)
from substitute_backend.features.environment_management.application.model_root_service import (
ModelRootService,
)
from substitute_backend.features.environment_management.application.restart_service import (
RestartService,
)
@@ -47,3 +50,4 @@ class EnvironmentManagementServices:
maintenance_plan: MaintenancePlanService
operation_planning: OperationPlanningService
restart: RestartService
model_root: ModelRootService
@@ -31,6 +31,7 @@ class EnvironmentFeature(StrEnum):
RESTART = "restart"
OPERATION_PLANNING = "operation-planning"
PACKAGE_MUTATION = "package-mutation"
MODEL_ROOT_MANAGEMENT = "model-root-management"
@dataclass(frozen=True)
@@ -42,6 +43,7 @@ class EnvironmentCapabilities:
restart_supported: bool
package_mutation_supported: bool
operation_planning_supported: bool
model_root_management_supported: bool
restart_unavailable_reason: str | None = None
def to_payload(self) -> JsonObject:
@@ -53,6 +55,7 @@ class EnvironmentCapabilities:
"restartSupported": self.restart_supported,
"packageMutationSupported": self.package_mutation_supported,
"operationPlanningSupported": self.operation_planning_supported,
"modelRootManagementSupported": self.model_root_management_supported,
}
if self.restart_unavailable_reason is not None:
payload["restartUnavailableReason"] = self.restart_unavailable_reason
@@ -0,0 +1,65 @@
# 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 <https://www.gnu.org/licenses/>.
"""Model-root contracts owned by the Comfy environment."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from substitute_backend.api.serialization import JsonObject
class ModelRootMode(StrEnum):
"""Identify whether Comfy uses its default or a configured model root."""
DEFAULT = "default"
CUSTOM = "custom"
@dataclass(frozen=True)
class ModelRootStatus:
"""Describe persisted and active model-root state for one Comfy host."""
schema_version: int
default_model_root: Path
configured_model_root: Path | None
active_model_root: Path
restart_required: bool
@property
def uses_default(self) -> bool:
"""Return whether the persisted configuration selects Comfy's default."""
return self.configured_model_root is None
def to_payload(self) -> JsonObject:
"""Return a stable API payload for external environment clients."""
return {
"schemaVersion": self.schema_version,
"defaultModelRoot": str(self.default_model_root),
"configuredModelRoot": (
str(self.configured_model_root) if self.configured_model_root is not None else None
),
"activeModelRoot": str(self.active_model_root),
"usesDefault": self.uses_default,
"restartRequired": self.restart_required,
}
__all__ = ["ModelRootMode", "ModelRootStatus"]
@@ -0,0 +1,48 @@
# 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 <https://www.gnu.org/licenses/>.
"""Read active model-root state from ComfyUI at the host boundary."""
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Protocol, cast
class ModelRootRuntime(Protocol):
"""Expose the model root active in the current Comfy process."""
def active_model_root(self) -> Path:
"""Return the model root currently used by ComfyUI."""
class _FolderPathsModule(Protocol):
"""Describe the ComfyUI state required by the runtime adapter."""
models_dir: str
class ComfyModelRootRuntime:
"""Read the active root from ComfyUI's initialized folder registry."""
def active_model_root(self) -> Path:
"""Return ComfyUI's normalized active model root."""
folder_paths = cast("_FolderPathsModule", importlib.import_module("folder_paths"))
return Path(folder_paths.models_dir).resolve()
__all__ = ["ComfyModelRootRuntime", "ModelRootRuntime"]
@@ -0,0 +1,110 @@
# 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 <https://www.gnu.org/licenses/>.
"""Persist the Comfy installation's authoritative model-root selection."""
from __future__ import annotations
import json
import os
from pathlib import Path
from uuid import uuid4
_CONFIG_DIRECTORY = ".substitute"
_CONFIG_FILE = "model_root.json"
_SCHEMA_VERSION = 1
class ModelRootStore:
"""Read and atomically replace model-root configuration for one Comfy root."""
def __init__(self, comfy_root: Path) -> None:
"""Initialize persistence beneath the supplied Comfy installation."""
self._comfy_root = comfy_root.resolve()
self._config_path = self._comfy_root / _CONFIG_DIRECTORY / _CONFIG_FILE
@property
def config_path(self) -> Path:
"""Return the canonical persisted configuration path."""
return self._config_path
def load(self) -> Path | None:
"""Return the configured custom root, or ``None`` for Comfy's default."""
if not self._config_path.exists():
return None
payload = json.loads(self._config_path.read_text(encoding="utf-8"))
if payload.get("schemaVersion") != _SCHEMA_VERSION:
raise ValueError("Unsupported model-root configuration schema.")
value = payload.get("modelRoot")
if not isinstance(value, str) or not value.strip():
raise ValueError("Model-root configuration does not contain a path.")
return self.resolve_custom_root(value)
def save(self, model_root: Path | None) -> Path | None:
"""Persist a custom root, or remove configuration for Comfy's default."""
if model_root is None:
self._remove_config()
return None
resolved_root = self.resolve_custom_root(str(model_root))
resolved_root.mkdir(parents=True, exist_ok=True)
if not resolved_root.is_dir():
raise ValueError("Model root must be a directory.")
self._config_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = self._config_path.with_name(f".{self._config_path.name}.{uuid4().hex}.tmp")
try:
temporary_path.write_text(
json.dumps(
{
"schemaVersion": _SCHEMA_VERSION,
"modelRoot": str(resolved_root),
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
os.replace(temporary_path, self._config_path)
finally:
if temporary_path.exists():
temporary_path.unlink()
return resolved_root
@staticmethod
def resolve_custom_root(value: str) -> Path:
"""Validate and normalize one host-side custom model root."""
expanded = Path(os.path.expandvars(value)).expanduser()
if not expanded.is_absolute():
raise ValueError("Model root must be an absolute path.")
resolved = expanded.resolve()
if resolved.exists() and not resolved.is_dir():
raise ValueError("Model root must be a directory.")
return resolved
def _remove_config(self) -> None:
"""Remove the custom selection while preserving unrelated host state."""
if self._config_path.exists():
self._config_path.unlink()
directory = self._config_path.parent
if directory.exists() and not any(directory.iterdir()):
directory.rmdir()
__all__ = ["ModelRootStore"]
+14
View File
@@ -62,6 +62,9 @@ from substitute_backend.features.environment_management.application.inventory_se
from substitute_backend.features.environment_management.application.job_service import (
JobService,
)
from substitute_backend.features.environment_management.application.model_root_service import (
ModelRootService,
)
from substitute_backend.features.environment_management.application.restart_service import (
RestartService,
)
@@ -75,6 +78,12 @@ from substitute_backend.features.environment_management.infrastructure import (
PipInspector,
)
from substitute_backend.features.environment_management.infrastructure.job_store import JobStore
from substitute_backend.features.environment_management.infrastructure.model_root_runtime import (
ComfyModelRootRuntime,
)
from substitute_backend.features.environment_management.infrastructure.model_root_store import (
ModelRootStore,
)
from substitute_backend.features.environment_management.infrastructure.python_environment import (
PythonEnvironmentInspector,
)
@@ -367,6 +376,11 @@ def build_environment_management_services(extension_root: Path) -> EnvironmentMa
coordinator=restart_coordinator,
logger=get_logger("environment.restart"),
),
model_root=ModelRootService(
comfy_root=comfy_root,
store=ModelRootStore(comfy_root),
runtime=ComfyModelRootRuntime(),
),
)
+5
View File
@@ -91,6 +91,9 @@ class RouteRegistrar(Protocol):
def post(self, path: str) -> Callable[[_RouteHandler], _RouteHandler]:
"""Return a decorator registering a POST handler."""
def put(self, path: str) -> Callable[[_RouteHandler], _RouteHandler]:
"""Return a decorator registering a PUT handler."""
def delete(self, path: str) -> Callable[[_RouteHandler], _RouteHandler]:
"""Return a decorator registering a DELETE handler."""
@@ -239,6 +242,8 @@ def register_routes(
routes.post("/substitute/v1/cube-library/sync-and-check")(cube_library_handlers.sync_and_check)
routes.get("/substitute/v1/environment/capabilities")(environment_handlers.capabilities)
routes.get("/substitute/v1/environment/status")(environment_handlers.status)
routes.get("/substitute/v1/environment/model-root")(environment_handlers.get_model_root)
routes.put("/substitute/v1/environment/model-root")(environment_handlers.update_model_root)
routes.get("/substitute/v1/environment/packages")(environment_handlers.list_packages)
routes.get("/substitute/v1/environment/components")(environment_handlers.list_components)
routes.post("/substitute/v1/environment/operations/plan")(environment_handlers.plan_operation)
+165
View File
@@ -0,0 +1,165 @@
# 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 <https://www.gnu.org/licenses/>.
"""Apply BackEnd-owned model configuration before normal custom-node imports."""
from __future__ import annotations
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Any
from uuid import uuid4
_CONFIG_RELATIVE_PATH = Path(".substitute") / "model_root.json"
_LEGACY_CONFIG_RELATIVE_PATH = Path(".substitute") / "managed_model_root.json"
_LEGACY_HOOK_DIRECTORY = "SubstituteManagedModelRoot"
_SCHEMA_VERSION = 1
_LOGGER = logging.getLogger("substitute_backend.prestartup")
def apply_model_root(comfy_root: Path, folder_paths: Any) -> Path:
"""Apply the persisted root and return the model root active after startup."""
_migrate_legacy_configuration(comfy_root)
old_root = Path(str(folder_paths.models_dir)).resolve()
configured_root = _read_configured_root(comfy_root)
new_root = configured_root or (comfy_root / "models").resolve()
new_root.mkdir(parents=True, exist_ok=True)
if new_root != old_root:
_redirect_registered_paths(folder_paths, old_root, new_root)
folder_paths.models_dir = str(new_root)
_clear_caches(folder_paths)
_LOGGER.info("Substitute BackEnd configured ComfyUI model root: %s", new_root)
_remove_legacy_hook(comfy_root)
return new_root
def _migrate_legacy_configuration(comfy_root: Path) -> None:
"""Adopt desktop-owned persisted state before the normal startup read."""
canonical_path = comfy_root / _CONFIG_RELATIVE_PATH
legacy_path = comfy_root / _LEGACY_CONFIG_RELATIVE_PATH
if not legacy_path.exists():
return
if canonical_path.exists():
legacy_path.unlink()
_LOGGER.info("Removed superseded legacy Substitute model-root configuration.")
return
payload = json.loads(legacy_path.read_text(encoding="utf-8"))
value = payload.get("model_root")
if not isinstance(value, str) or not value.strip():
raise ValueError("Legacy Substitute model-root configuration is invalid.")
model_root = Path(value).expanduser()
if not model_root.is_absolute():
raise ValueError("Legacy Substitute model root must be absolute.")
_write_configuration(canonical_path, model_root.resolve())
legacy_path.unlink()
_LOGGER.info("Migrated legacy Substitute model-root configuration.")
def _write_configuration(config_path: Path, model_root: Path) -> None:
"""Atomically write the lightweight prestartup configuration shape."""
config_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = config_path.with_name(f".{config_path.name}.{uuid4().hex}.tmp")
try:
temporary_path.write_text(
json.dumps(
{"schemaVersion": _SCHEMA_VERSION, "modelRoot": str(model_root)},
indent=2,
)
+ "\n",
encoding="utf-8",
)
os.replace(temporary_path, config_path)
finally:
if temporary_path.exists():
temporary_path.unlink()
def _remove_legacy_hook(comfy_root: Path) -> None:
"""Remove the superseded desktop-generated custom node after migration."""
hook_directory = comfy_root / "custom_nodes" / _LEGACY_HOOK_DIRECTORY
if not hook_directory.exists():
return
try:
shutil.rmtree(hook_directory)
except OSError as exc:
_LOGGER.warning(
"Could not remove legacy Substitute model-root hook: %s",
exc,
)
return
_LOGGER.info("Removed legacy Substitute model-root startup hook.")
def _read_configured_root(comfy_root: Path) -> Path | None:
"""Read the lightweight configuration without importing BackEnd services."""
config_path = comfy_root / _CONFIG_RELATIVE_PATH
if not config_path.exists():
return None
payload = json.loads(config_path.read_text(encoding="utf-8"))
if payload.get("schemaVersion") != _SCHEMA_VERSION:
raise ValueError("Unsupported Substitute BackEnd model-root schema.")
value = payload.get("modelRoot")
if not isinstance(value, str) or not value.strip():
raise ValueError("Substitute BackEnd model-root configuration is invalid.")
configured_root = Path(value).expanduser()
if not configured_root.is_absolute():
raise ValueError("Substitute BackEnd model root must be absolute.")
return configured_root.resolve()
def _redirect_registered_paths(
folder_paths: Any,
old_root: Path,
new_root: Path,
) -> None:
"""Move default-root registry entries while preserving external additions."""
registry = getattr(folder_paths, "folder_names_and_paths", {})
for folder_name, values in list(registry.items()):
paths, extensions = values
rewritten: list[str] = []
changed = False
for raw_path in paths:
path = Path(str(raw_path)).resolve()
try:
relative = path.relative_to(old_root)
except ValueError:
rewritten.append(str(raw_path))
continue
rewritten.append(str(new_root / relative))
changed = True
if changed:
registry[folder_name] = (rewritten, extensions)
def _clear_caches(folder_paths: Any) -> None:
"""Clear supported Comfy filename caches after registry redirection."""
for attribute in ("filename_list_cache", "cache_helper"):
cache = getattr(folder_paths, attribute, None)
clear = getattr(cache, "clear", None)
if callable(clear):
clear()
__all__ = ["apply_model_root"]
+7
View File
@@ -68,6 +68,11 @@ class FakeRoutes:
return self._record("POST", path)
def put(self, path: str) -> Callable[[_RouteHandler], _RouteHandler]:
"""Record a PUT route registration."""
return self._record("PUT", path)
def delete(self, path: str) -> Callable[[_RouteHandler], _RouteHandler]:
"""Record a DELETE route registration."""
@@ -152,6 +157,8 @@ def test_register_routes_uses_expected_surface(tmp_path: Path) -> None:
("POST", "/substitute/v1/cube-library/sync-and-check"),
("GET", "/substitute/v1/environment/capabilities"),
("GET", "/substitute/v1/environment/status"),
("GET", "/substitute/v1/environment/model-root"),
("PUT", "/substitute/v1/environment/model-root"),
("GET", "/substitute/v1/environment/packages"),
("GET", "/substitute/v1/environment/components"),
("POST", "/substitute/v1/environment/operations/plan"),
+5
View File
@@ -637,6 +637,11 @@ class _FakeRoutes:
return self._record("POST", path)
def put(self, path: str) -> Any:
"""Record a PUT handler."""
return self._record("PUT", path)
def delete(self, path: str) -> Any:
"""Record a DELETE handler."""
+69
View File
@@ -46,6 +46,9 @@ from substitute_backend.features.environment_management.application.inventory_se
from substitute_backend.features.environment_management.application.job_service import (
JobService,
)
from substitute_backend.features.environment_management.application.model_root_service import (
ModelRootService,
)
from substitute_backend.features.environment_management.application.restart_service import (
RestartService,
)
@@ -79,6 +82,12 @@ from substitute_backend.features.environment_management.infrastructure import (
from substitute_backend.features.environment_management.infrastructure.job_store import (
JobStore,
)
from substitute_backend.features.environment_management.infrastructure.model_root_runtime import (
ModelRootRuntime,
)
from substitute_backend.features.environment_management.infrastructure.model_root_store import (
ModelRootStore,
)
from substitute_backend.features.environment_management.infrastructure.python_environment import (
PythonEnvironmentInspector,
)
@@ -129,6 +138,20 @@ class UnsupportedRestartCoordinator(RestartCoordinator):
)
class StaticModelRootRuntime(ModelRootRuntime):
"""Return a fixed active model root for service tests."""
def __init__(self, active_root: Path) -> None:
"""Store the active root."""
self._active_root = active_root
def active_model_root(self) -> Path:
"""Return the configured active root."""
return self._active_root.resolve()
class StaticPipInspector(PipInspector):
"""Return fixed pip packages for inventory tests."""
@@ -1007,6 +1030,47 @@ def _maintenance_plan_service(
)
def test_model_root_routes_report_and_persist_host_state(tmp_path: Path) -> None:
"""Environment routes expose a typed model-root mutation contract."""
services = _environment_services(tmp_path, StaticPipInspector())
handlers = build_environment_route_handlers(
services,
get_logger("tests.environment.routes"),
)
custom_root = tmp_path / "shared-models"
async def run_routes() -> None:
"""Exercise model-root GET and update handlers."""
initial = await handlers.get_model_root(cast("web.Request", object()))
initial_payload = _response_payload(initial)
assert initial_payload["usesDefault"] is True
assert initial_payload["restartRequired"] is False
updated = await handlers.update_model_root(
cast(
"web.Request",
FakeJsonRequest({"mode": "custom", "path": str(custom_root)}),
)
)
updated_payload = _response_payload(updated)
assert updated_payload["configuredModelRoot"] == str(custom_root.resolve())
assert updated_payload["restartRequired"] is True
invalid = await handlers.update_model_root(
cast(
"web.Request",
FakeJsonRequest({"mode": "custom", "path": "relative/models"}),
)
)
assert invalid.status == 400
error = cast("JsonObject", _response_payload(invalid)["error"])
assert error["code"] == "invalid-model-root"
asyncio.run(run_routes())
def _expected_triton_requirement() -> str:
"""Return the platform-specific Triton requirement expected by the service."""
@@ -1048,6 +1112,11 @@ def _environment_services(
coordinator=SupportedRestartCoordinator(),
logger=get_logger("tests.environment.restart"),
),
model_root=ModelRootService(
comfy_root=tmp_path,
store=ModelRootStore(tmp_path),
runtime=StaticModelRootRuntime(tmp_path / "models"),
),
)
+227
View File
@@ -0,0 +1,227 @@
# 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 <https://www.gnu.org/licenses/>.
"""Tests for authoritative Comfy model-root management."""
from __future__ import annotations
import json
from pathlib import Path
from types import ModuleType
from typing import Any, cast
import pytest
from substitute_backend.features.environment_management.application.model_root_service import (
ModelRootService,
)
from substitute_backend.features.environment_management.domain.model_root import ModelRootMode
from substitute_backend.features.environment_management.infrastructure.model_root_runtime import (
ModelRootRuntime,
)
from substitute_backend.features.environment_management.infrastructure.model_root_store import (
ModelRootStore,
)
from substitute_backend_prestartup import apply_model_root
class MutableModelRootRuntime(ModelRootRuntime):
"""Expose a mutable active root for restart-transition tests."""
def __init__(self, active_root: Path) -> None:
"""Initialize the active root."""
self.active_root = active_root
def active_model_root(self) -> Path:
"""Return the current active root."""
return self.active_root.resolve()
class ClearTracker:
"""Record Comfy cache invalidation calls."""
def __init__(self) -> None:
"""Initialize an uncleared tracker."""
self.cleared = False
def clear(self) -> None:
"""Record cache invalidation."""
self.cleared = True
def test_store_defaults_to_comfy_models_without_configuration(tmp_path: Path) -> None:
"""An unconfigured host uses its own models directory."""
runtime = MutableModelRootRuntime(tmp_path / "models")
service = ModelRootService(tmp_path, ModelRootStore(tmp_path), runtime)
status = service.get_status()
assert status.uses_default is True
assert status.default_model_root == (tmp_path / "models").resolve()
assert status.active_model_root == (tmp_path / "models").resolve()
assert status.restart_required is False
def test_custom_root_is_persisted_atomically_and_requires_restart(tmp_path: Path) -> None:
"""A custom selection is durable while active state remains process-owned."""
runtime = MutableModelRootRuntime(tmp_path / "models")
store = ModelRootStore(tmp_path)
service = ModelRootService(tmp_path, store, runtime)
custom_root = tmp_path / "shared models"
status = service.configure(ModelRootMode.CUSTOM, str(custom_root))
assert status.configured_model_root == custom_root.resolve()
assert status.active_model_root == (tmp_path / "models").resolve()
assert status.restart_required is True
assert store.load() == custom_root.resolve()
assert not tuple(store.config_path.parent.glob("*.tmp"))
runtime.active_root = custom_root
assert service.get_status().restart_required is False
def test_default_mode_removes_only_owned_configuration(tmp_path: Path) -> None:
"""Resetting to default preserves unrelated Substitute host state."""
store = ModelRootStore(tmp_path)
store.save(tmp_path / "shared")
unrelated = store.config_path.parent / "other.json"
unrelated.write_text("{}", encoding="utf-8")
service = ModelRootService(
tmp_path,
store,
MutableModelRootRuntime(tmp_path / "shared"),
)
status = service.configure(ModelRootMode.DEFAULT, None)
assert status.uses_default is True
assert status.restart_required is True
assert not store.config_path.exists()
assert unrelated.exists()
@pytest.mark.parametrize("value", ["models", "", "."])
def test_custom_root_rejects_relative_paths(tmp_path: Path, value: str) -> None:
"""Host configuration never interprets relative paths ambiguously."""
service = ModelRootService(
tmp_path,
ModelRootStore(tmp_path),
MutableModelRootRuntime(tmp_path / "models"),
)
with pytest.raises(ValueError, match=r"absolute|requires"):
service.configure(ModelRootMode.CUSTOM, value)
def test_prestartup_redirects_default_categories_and_preserves_external_paths(
tmp_path: Path,
) -> None:
"""Prestartup moves Comfy defaults without consuming extra model paths."""
comfy_root = tmp_path / "ComfyUI"
old_root = comfy_root / "models"
new_root = tmp_path / "shared-models"
external_root = tmp_path / "external-loras"
config_path = comfy_root / ".substitute" / "model_root.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps({"schemaVersion": 1, "modelRoot": str(new_root)}),
encoding="utf-8",
)
folder_paths = cast(Any, ModuleType("folder_paths"))
folder_paths.models_dir = str(old_root)
folder_paths.folder_names_and_paths = {
"checkpoints": ([str(old_root / "checkpoints")], {".safetensors"}),
"loras": (
[str(old_root / "loras"), str(external_root)],
{".safetensors"},
),
}
filename_cache = ClearTracker()
helper_cache = ClearTracker()
folder_paths.filename_list_cache = filename_cache
folder_paths.cache_helper = helper_cache
active_root = apply_model_root(comfy_root, folder_paths)
assert active_root == new_root.resolve()
assert Path(folder_paths.models_dir) == new_root.resolve()
assert folder_paths.folder_names_and_paths["checkpoints"][0] == [str(new_root / "checkpoints")]
assert folder_paths.folder_names_and_paths["loras"][0] == [
str(new_root / "loras"),
str(external_root),
]
assert filename_cache.cleared is True
assert helper_cache.cleared is True
def test_prestartup_uses_default_without_configuration(tmp_path: Path) -> None:
"""The BackEnd hook does not require SugarSubstitute launch state."""
comfy_root = tmp_path / "ComfyUI"
folder_paths = cast(Any, ModuleType("folder_paths"))
folder_paths.models_dir = str(comfy_root / "models")
folder_paths.folder_names_and_paths = {}
assert apply_model_root(comfy_root, folder_paths) == (comfy_root / "models").resolve()
def test_prestartup_migrates_legacy_desktop_state_and_hook(tmp_path: Path) -> None:
"""BackEnd adopts the previous owner before removing its generated hook."""
comfy_root = tmp_path / "ComfyUI"
legacy_config = comfy_root / ".substitute" / "managed_model_root.json"
legacy_hook = comfy_root / "custom_nodes" / "SubstituteManagedModelRoot"
custom_root = tmp_path / "legacy-models"
legacy_config.parent.mkdir(parents=True)
legacy_config.write_text(
json.dumps({"schema_version": 1, "model_root": str(custom_root)}),
encoding="utf-8",
)
legacy_hook.mkdir(parents=True)
(legacy_hook / "prestartup_script.py").write_text("", encoding="utf-8")
folder_paths = cast(Any, ModuleType("folder_paths"))
folder_paths.models_dir = str(comfy_root / "models")
folder_paths.folder_names_and_paths = {}
assert apply_model_root(comfy_root, folder_paths) == custom_root.resolve()
assert not legacy_config.exists()
assert not legacy_hook.exists()
assert ModelRootStore(comfy_root).load() == custom_root.resolve()
def test_prestartup_keeps_canonical_selection_over_legacy_state(tmp_path: Path) -> None:
"""Canonical BackEnd state should win and remove a leftover desktop file."""
comfy_root = tmp_path / "ComfyUI"
canonical_root = tmp_path / "canonical-models"
ModelRootStore(comfy_root).save(canonical_root)
legacy_config = comfy_root / ".substitute" / "managed_model_root.json"
legacy_config.write_text("not valid anymore", encoding="utf-8")
folder_paths = cast(Any, ModuleType("folder_paths"))
folder_paths.models_dir = str(comfy_root / "models")
folder_paths.folder_names_and_paths = {}
assert apply_model_root(comfy_root, folder_paths) == canonical_root.resolve()
assert not legacy_config.exists()