From d00d2c91ce4fdff481419e8541774b96ae8d3969 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:36:52 +0700 Subject: [PATCH 1/4] feat(comfyui): gate local images by machine compatibility --- apps/api/src/hcs_api/comfyui_compatibility.py | 677 ++++++++++++++++++ apps/api/src/hcs_api/comfyui_model.py | 34 +- apps/api/src/hcs_api/comfyui_runtime.py | 20 + .../api/src/hcs_api/comfyui_teaching_image.py | 50 +- apps/api/src/hcs_api/main.py | 17 + apps/api/src/hcs_api/provider_hub.py | 109 ++- apps/api/tests/test_comfyui_compatibility.py | 133 ++++ apps/api/tests/test_comfyui_model.py | 23 +- apps/api/tests/test_comfyui_runtime.py | 40 ++ apps/api/tests/test_comfyui_teaching_image.py | 92 +++ apps/api/tests/test_provider_hub.py | 96 +++ .../comfyui/local-image-compatibility.v1.json | 48 ++ 12 files changed, 1294 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/hcs_api/comfyui_compatibility.py create mode 100644 apps/api/tests/test_comfyui_compatibility.py create mode 100644 providers/comfyui/local-image-compatibility.v1.json diff --git a/apps/api/src/hcs_api/comfyui_compatibility.py b/apps/api/src/hcs_api/comfyui_compatibility.py new file mode 100644 index 0000000..4092a91 --- /dev/null +++ b/apps/api/src/hcs_api/comfyui_compatibility.py @@ -0,0 +1,677 @@ +"""Backend-authoritative machine gate for the frozen local image capability.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import stat +import subprocess +import threading +from collections.abc import Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from . import storage + +COMPATIBILITY_CONTRACT_SHA256 = ( + "493723bacabef0edf3b1fef69862c8fb1a9635cbf1729bbfe96cfe4e2a4662b8" +) +CompatibilityStatus = Literal[ + "compatible", "compatible_limited", "incompatible", "unknown" +] +CompatibilityAction = Literal[ + "install_runtime", + "repair_runtime", + "start_runtime", + "install_model", + "repair_model", + "generate", +] +CompatibilityReasonCode = Literal[ + "unsupported_os", + "unsupported_architecture", + "os_version_too_old", + "os_version_unknown", + "mps_unavailable", + "mps_probe_failed", + "memory_below_minimum", + "memory_unknown", + "disk_below_runtime_minimum", + "disk_below_install_minimum", + "disk_unknown", + "platform_not_real_validated", + "experimental_support", + "requirements_contract_unavailable", + "requirements_contract_identity_mismatch", + "requirements_identity_mismatch", +] + +_UNKNOWN_REASONS = frozenset( + { + "os_version_unknown", + "mps_probe_failed", + "memory_unknown", + "disk_unknown", + "requirements_contract_unavailable", + "requirements_contract_identity_mismatch", + } +) +_INCOMPATIBLE_REASONS = frozenset( + { + "unsupported_os", + "unsupported_architecture", + "os_version_too_old", + "mps_unavailable", + "memory_below_minimum", + "disk_below_runtime_minimum", + "platform_not_real_validated", + "requirements_identity_mismatch", + } +) + + +class LocalImageCompatibilityError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class FixedRequirements(_StrictModel): + runtime_package_id: Literal["hcs.comfyui-runtime"] + runtime_version: Literal["0.28.0"] + runtime_source_commit: Literal["700821e1364eaab0e8f21c538a2131719fec57bf"] + runtime_manifest_path: Literal["providers/comfyui/runtime-manifest.v1.json"] + runtime_manifest_sha256: Literal[ + "e6550ecd7a4b43aa85c7312cb69cc006651ea832e184867e1494699760ac186a" + ] + model_package_id: Literal["hcs.sd15-teaching-illustration-fp16"] + model_version: Literal["1.5-fp16-emaonly"] + model_manifest_path: Literal["providers/comfyui/model-package-sd15-fp16.v1.json"] + model_manifest_sha256: Literal[ + "b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658" + ] + workflow_pack_id: Literal["hcs.teaching-illustration-sd15-core"] + workflow_version: Literal["1.0.0"] + workflow_pack_path: Literal[ + "providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json" + ] + workflow_pack_sha256: Literal[ + "e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e" + ] + + +class RealValidation(_StrictModel): + validated: bool + validated_at: str + hardware: str + evidence: Literal["docs/comfyui-teaching-image-phase-2c.md"] + + +class PlatformRequirement(_StrictModel): + operating_system: Literal["macos"] + architecture: Literal["arm64"] + minimum_os_version: Literal["14.0"] + inference_backend: Literal["mps"] + minimum_memory_bytes: Literal[17_179_869_184] + support: Literal["experimental"] + real_validation: RealValidation + + +class DiskRequirements(_StrictModel): + runtime_download_bytes: Literal[673_583_054] + model_download_bytes: Literal[2_132_711_147] + temporary_download_peak_bytes: Literal[2_132_711_147] + full_install_minimum_free_bytes: Literal[10_737_418_240] + runtime_install_minimum_free_bytes: Literal[8_589_934_592] + model_install_minimum_free_bytes: Literal[5_368_709_120] + generation_minimum_free_bytes: Literal[1_073_741_824] + + +class LocalImageCompatibilityContract(_StrictModel): + schema_: Literal["hanclassstudio.local_image_compatibility_contract.v1"] = Field( + alias="schema" + ) + contract_id: Literal["hcs.local-basic-image-generation.compatibility"] + version: Literal["1.0.0"] + capability_name: Literal["Local basic image generation"] + requirements: FixedRequirements + platforms: list[PlatformRequirement] = Field(min_length=1, max_length=1) + disk: DiskRequirements + cache_ttl_seconds: Literal[300] + cpu_fallback_allowed: Literal[False] + + @model_validator(mode="after") + def _fixed_platform(self) -> LocalImageCompatibilityContract: + platform_requirement = self.platforms[0] + if not platform_requirement.real_validation.validated: + raise ValueError("enabled local image platform must have real validation") + return self + + +class CompatibilityReason(_StrictModel): + code: CompatibilityReasonCode + blocking: bool + message: str + observed: str | None = None + required: str | None = None + + +class LocalImageMachineCompatibility(_StrictModel): + schema_: Literal["hanclassstudio.local_image_machine_compatibility.v1"] = Field( + default="hanclassstudio.local_image_machine_compatibility.v1", + alias="schema", + ) + contract_id: str + contract_version: str | None + contract_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + status: CompatibilityStatus + model_compatible: bool + operating_system: str + os_version: str | None + architecture: str + inference_backend: str | None + inference_backend_available: bool | None + total_memory_bytes: int | None = Field(default=None, ge=0) + free_disk_bytes: int | None = Field(default=None, ge=0) + platform_support: Literal["experimental", "unavailable", "unknown"] + platform_real_validated: bool | None + requirements_identity_verified: bool + requirements: FixedRequirements | None + disk_requirements: DiskRequirements | None + reasons: list[CompatibilityReason] + checked_at: str + expires_at: str + + +_CACHE_LOCK = threading.RLock() +_CACHE: LocalImageMachineCompatibility | None = None + + +def compatibility_contract_path() -> Path: + return ( + storage.ROOT_DIR / "providers" / "comfyui" / "local-image-compatibility.v1.json" + ) + + +def _read_bounded_regular_file(path: Path, maximum_bytes: int) -> bytes: + consumed = 0 + chunks: list[bytes] = [] + with path.open("rb") as handle: + info = os.fstat(handle.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum_bytes: + raise OSError("compatibility contract input is not a bounded regular file") + while chunk := handle.read(1024 * 1024): + consumed += len(chunk) + if consumed > maximum_bytes: + raise OSError("compatibility contract input exceeded its limit") + chunks.append(chunk) + return b"".join(chunks) + + +def _sha256_regular_file(path: Path, maximum_bytes: int) -> str: + return hashlib.sha256(_read_bounded_regular_file(path, maximum_bytes)).hexdigest() + + +def load_compatibility_contract() -> LocalImageCompatibilityContract: + path = compatibility_contract_path() + try: + payload = _read_bounded_regular_file(path, 64 * 1024) + if hashlib.sha256(payload).hexdigest() != COMPATIBILITY_CONTRACT_SHA256: + raise LocalImageCompatibilityError( + "requirements_contract_identity_mismatch", + "The fixed local image compatibility contract identity changed", + ) + return LocalImageCompatibilityContract.model_validate_json(payload) + except LocalImageCompatibilityError: + raise + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise LocalImageCompatibilityError( + "requirements_contract_unavailable", + "The fixed local image compatibility contract is unavailable", + ) from exc + + +def _memory_bytes() -> int | None: + try: + value = int(os.sysconf("SC_PAGE_SIZE")) * int(os.sysconf("SC_PHYS_PAGES")) + return value if value > 0 else None + except (AttributeError, OSError, TypeError, ValueError): + return None + + +def _free_disk_bytes() -> int | None: + target = storage.RUNTIME_DIR + while not target.exists() and target != target.parent: + target = target.parent + try: + return shutil.disk_usage(target).free + except OSError: + return None + + +def _probe_mps() -> tuple[bool | None, str | None]: + executable = Path("/usr/sbin/system_profiler") + if not executable.is_file(): + discovered = shutil.which("system_profiler") + if not discovered: + return None, None + executable = Path(discovered) + try: + result = subprocess.run( + [str(executable), "SPDisplaysDataType", "-json"], + capture_output=True, + text=True, + timeout=8, + check=False, + cwd="/", + env={ + "LANG": "C", + "LC_ALL": "C", + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + }, + ) + if ( + result.returncode != 0 + or not result.stdout + or len(result.stdout.encode("utf-8")) > 1024 * 1024 + ): + return None, None + payload = json.loads(result.stdout) + displays = payload.get("SPDisplaysDataType") + if not isinstance(displays, list): + return None, None + for display in displays: + if not isinstance(display, dict): + continue + if any( + key.startswith(("spdisplays_metal", "spdisplays_mtl")) + for key in display + ): + return True, "system_profiler_metal" + return False, "system_profiler_no_metal" + except (OSError, subprocess.SubprocessError, UnicodeError, json.JSONDecodeError): + return None, None + + +MEMORY_PROBER: Callable[[], int | None] = _memory_bytes +DISK_PROBER: Callable[[], int | None] = _free_disk_bytes +INFERENCE_BACKEND_PROBER: Callable[[], tuple[bool | None, str | None]] = _probe_mps +CONTRACT_LOADER: Callable[[], LocalImageCompatibilityContract] = ( + load_compatibility_contract +) +SYSTEM_NAME: Callable[[], str] = platform.system +MACHINE_NAME: Callable[[], str] = platform.machine +MAC_VERSION: Callable[[], tuple[str, tuple[str, str, str], str]] = platform.mac_ver + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + +def _normalized_platform() -> tuple[str, str]: + system_name = SYSTEM_NAME().lower() + machine_name = MACHINE_NAME().lower() + operating_system = { + "darwin": "macos", + "windows": "windows", + "linux": "linux", + }.get(system_name, system_name or "unknown") + architecture = { + "aarch64": "arm64", + "amd64": "x86_64", + }.get(machine_name, machine_name or "unknown") + return operating_system, architecture + + +def _version_tuple(value: str) -> tuple[int, int] | None: + try: + parts = value.split(".") + if len(parts) < 2: + return None + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +def _requirements_match(requirements: FixedRequirements) -> bool: + for path_name, expected_sha256 in ( + (requirements.runtime_manifest_path, requirements.runtime_manifest_sha256), + (requirements.model_manifest_path, requirements.model_manifest_sha256), + (requirements.workflow_pack_path, requirements.workflow_pack_sha256), + ): + try: + if ( + _sha256_regular_file(storage.ROOT_DIR / path_name, 512 * 1024) + != expected_sha256 + ): + return False + except OSError: + return False + return True + + +def _reason( + code: CompatibilityReasonCode, + message: str, + *, + blocking: bool, + observed: str | None = None, + required: str | None = None, +) -> CompatibilityReason: + return CompatibilityReason( + code=code, + blocking=blocking, + message=message, + observed=observed, + required=required, + ) + + +def _unknown_contract_snapshot( + error: LocalImageCompatibilityError, + checked_at: datetime, +) -> LocalImageMachineCompatibility: + operating_system, architecture = _normalized_platform() + reason_code: CompatibilityReasonCode = ( + "requirements_contract_identity_mismatch" + if error.code == "requirements_contract_identity_mismatch" + else "requirements_contract_unavailable" + ) + return LocalImageMachineCompatibility( + contract_id="hcs.local-basic-image-generation.compatibility", + contract_version=None, + contract_sha256=COMPATIBILITY_CONTRACT_SHA256, + status="unknown", + model_compatible=False, + operating_system=operating_system, + os_version=(MAC_VERSION()[0] or None) if operating_system == "macos" else None, + architecture=architecture, + inference_backend=None, + inference_backend_available=None, + total_memory_bytes=MEMORY_PROBER(), + free_disk_bytes=DISK_PROBER(), + platform_support="unknown", + platform_real_validated=None, + requirements_identity_verified=False, + requirements=None, + disk_requirements=None, + reasons=[ + _reason( + reason_code, + error.message, + blocking=True, + ) + ], + checked_at=_iso(checked_at), + expires_at=_iso(checked_at + timedelta(seconds=300)), + ) + + +def _probe() -> LocalImageMachineCompatibility: + checked_at = datetime.now(timezone.utc) + try: + contract = CONTRACT_LOADER() + except LocalImageCompatibilityError as exc: + return _unknown_contract_snapshot(exc, checked_at) + + operating_system, architecture = _normalized_platform() + requirement = contract.platforms[0] + platform_matches = ( + operating_system == requirement.operating_system + and architecture == requirement.architecture + ) + os_version = (MAC_VERSION()[0] or None) if operating_system == "macos" else None + memory = MEMORY_PROBER() + free_disk = DISK_PROBER() + backend_available: bool | None = None + backend_evidence: str | None = None + reasons: list[CompatibilityReason] = [] + + if operating_system != requirement.operating_system: + reasons.append( + _reason( + "unsupported_os", + "Local basic image generation supports only macOS", + blocking=True, + observed=operating_system, + required=requirement.operating_system, + ) + ) + elif architecture != requirement.architecture: + reasons.append( + _reason( + "unsupported_architecture", + "Local basic image generation supports only Apple Silicon", + blocking=True, + observed=architecture, + required=requirement.architecture, + ) + ) + else: + current_version = _version_tuple(os_version or "") + minimum_version = _version_tuple(requirement.minimum_os_version) + if current_version is None: + reasons.append( + _reason( + "os_version_unknown", + "The macOS version could not be determined", + blocking=True, + required=requirement.minimum_os_version, + ) + ) + elif minimum_version is not None and current_version < minimum_version: + reasons.append( + _reason( + "os_version_too_old", + "Local basic image generation requires a newer macOS version", + blocking=True, + observed=os_version, + required=requirement.minimum_os_version, + ) + ) + backend_available, backend_evidence = INFERENCE_BACKEND_PROBER() + if backend_available is None: + reasons.append( + _reason( + "mps_probe_failed", + "The required Apple Metal inference backend could not be verified", + blocking=True, + required=requirement.inference_backend, + ) + ) + elif not backend_available: + reasons.append( + _reason( + "mps_unavailable", + "The required Apple Metal inference backend is unavailable", + blocking=True, + observed=backend_evidence, + required=requirement.inference_backend, + ) + ) + + if memory is None: + reasons.append( + _reason( + "memory_unknown", + "Total memory could not be determined", + blocking=True, + required=str(requirement.minimum_memory_bytes), + ) + ) + elif memory < requirement.minimum_memory_bytes: + reasons.append( + _reason( + "memory_below_minimum", + "Local basic image generation requires at least 16 GB memory", + blocking=True, + observed=str(memory), + required=str(requirement.minimum_memory_bytes), + ) + ) + + if free_disk is None: + reasons.append( + _reason( + "disk_unknown", + "Free disk space could not be determined", + blocking=True, + required=str(contract.disk.full_install_minimum_free_bytes), + ) + ) + elif free_disk < contract.disk.generation_minimum_free_bytes: + reasons.append( + _reason( + "disk_below_runtime_minimum", + "Not enough free disk remains to run local image generation safely", + blocking=True, + observed=str(free_disk), + required=str(contract.disk.generation_minimum_free_bytes), + ) + ) + elif free_disk < contract.disk.full_install_minimum_free_bytes: + reasons.append( + _reason( + "disk_below_install_minimum", + "The device can run an existing installation but lacks the full install reserve", + blocking=False, + observed=str(free_disk), + required=str(contract.disk.full_install_minimum_free_bytes), + ) + ) + + requirements_verified = _requirements_match(contract.requirements) + if not requirements_verified: + reasons.append( + _reason( + "requirements_identity_mismatch", + "A fixed Runtime, model, or workflow requirement identity changed", + blocking=True, + ) + ) + if not requirement.real_validation.validated: + reasons.append( + _reason( + "platform_not_real_validated", + "This platform has not completed a real local image lifecycle", + blocking=True, + ) + ) + if requirement.support == "experimental": + reasons.append( + _reason( + "experimental_support", + "macOS Apple Silicon support is real-validated but remains limited", + blocking=False, + ) + ) + + codes = {reason.code for reason in reasons} + if codes & _INCOMPATIBLE_REASONS: + status: CompatibilityStatus = "incompatible" + elif codes & _UNKNOWN_REASONS: + status = "unknown" + elif reasons: + status = "compatible_limited" + else: + status = "compatible" + return LocalImageMachineCompatibility( + contract_id=contract.contract_id, + contract_version=contract.version, + contract_sha256=COMPATIBILITY_CONTRACT_SHA256, + status=status, + model_compatible=status in {"compatible", "compatible_limited"}, + operating_system=operating_system, + os_version=os_version, + architecture=architecture, + inference_backend=requirement.inference_backend, + inference_backend_available=backend_available, + total_memory_bytes=memory, + free_disk_bytes=free_disk, + platform_support=(requirement.support if platform_matches else "unavailable"), + platform_real_validated=( + requirement.real_validation.validated if platform_matches else False + ), + requirements_identity_verified=requirements_verified, + requirements=contract.requirements, + disk_requirements=contract.disk, + reasons=reasons, + checked_at=_iso(checked_at), + expires_at=_iso(checked_at + timedelta(seconds=contract.cache_ttl_seconds)), + ) + + +def reset_machine_compatibility_cache() -> None: + global _CACHE + with _CACHE_LOCK: + _CACHE = None + + +def machine_compatibility_snapshot( + *, + force_refresh: bool = False, +) -> LocalImageMachineCompatibility: + global _CACHE + now = datetime.now(timezone.utc) + with _CACHE_LOCK: + if not force_refresh and _CACHE is not None: + try: + if datetime.fromisoformat(_CACHE.expires_at) > now: + return _CACHE + except ValueError: + pass + _CACHE = _probe() + return _CACHE + + +def compatibility_allows_action( + snapshot: LocalImageMachineCompatibility, + action: CompatibilityAction, +) -> bool: + if not snapshot.model_compatible or snapshot.free_disk_bytes is None: + return False + disk = snapshot.disk_requirements + if disk is None: + return False + threshold = { + "install_runtime": disk.full_install_minimum_free_bytes, + "repair_runtime": disk.runtime_install_minimum_free_bytes, + "start_runtime": disk.generation_minimum_free_bytes, + "install_model": disk.model_install_minimum_free_bytes, + "repair_model": disk.model_install_minimum_free_bytes, + "generate": disk.generation_minimum_free_bytes, + }[action] + return snapshot.free_disk_bytes >= threshold + + +def require_machine_compatibility( + action: CompatibilityAction, +) -> LocalImageMachineCompatibility: + snapshot = machine_compatibility_snapshot(force_refresh=True) + if snapshot.status == "unknown": + raise LocalImageCompatibilityError( + "machine_compatibility_unknown", + "Machine compatibility could not be verified; the operation was refused", + ) + if snapshot.status == "incompatible": + raise LocalImageCompatibilityError( + "machine_incompatible", + "This machine is incompatible with the fixed local image capability", + ) + if not compatibility_allows_action(snapshot, action): + raise LocalImageCompatibilityError( + "insufficient_disk", + "The operation does not have the required free disk reserve", + ) + return snapshot diff --git a/apps/api/src/hcs_api/comfyui_model.py b/apps/api/src/hcs_api/comfyui_model.py index c8fe718..1402463 100644 --- a/apps/api/src/hcs_api/comfyui_model.py +++ b/apps/api/src/hcs_api/comfyui_model.py @@ -5,8 +5,6 @@ import hashlib import json import os -import platform -import re import secrets import shutil import stat @@ -20,6 +18,10 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from . import storage +from .comfyui_compatibility import ( + LocalImageCompatibilityError, + require_machine_compatibility, +) from .comfyui_runtime import ( ComfyUIRuntimeError, RuntimeDirectoryIdentity, @@ -441,6 +443,7 @@ def load_workflow_pack() -> ComfyUIWorkflowPack: WORKFLOW_PACK_LOADER: Callable[[], ComfyUIWorkflowPack] = load_workflow_pack RUNTIME_SNAPSHOT = runtime_snapshot DISK_USAGE = shutil.disk_usage +COMPATIBILITY_GUARD = require_machine_compatibility def _model_root() -> Path: @@ -719,22 +722,14 @@ def _tree_identity(record: ModelInstallationRecord) -> str: return hashlib.sha256(payload).hexdigest() -def _validate_platform(manifest: ComfyUIModelManifest) -> None: - adapter = manifest.platforms[0] - machine = platform.machine().lower() - if platform.system() != "Darwin" or machine not in {"arm64", "aarch64"}: - raise ComfyUIModelError("unsupported_platform", "The fixed model package supports only macOS Apple Silicon") - version = tuple(int(part) for part in re.findall(r"\d+", platform.mac_ver()[0])[:2]) - if version < (14, 0): - raise ComfyUIModelError("unsupported_platform", "The fixed model package requires macOS 14 or newer") +def _validate_platform( + _manifest: ComfyUIModelManifest, + action: Literal["install_model", "repair_model"] = "install_model", +) -> None: try: - pages = os.sysconf("SC_PHYS_PAGES") - page_size = os.sysconf("SC_PAGE_SIZE") - memory_mb = pages * page_size // (1024**2) - except (OSError, ValueError): - memory_mb = adapter.minimum_memory_mb - if memory_mb < adapter.minimum_memory_mb: - raise ComfyUIModelError("insufficient_memory", "The fixed model package requires at least 16 GB memory") + COMPATIBILITY_GUARD(action) + except LocalImageCompatibilityError as exc: + raise ComfyUIModelError(exc.code, exc.message) from exc def _assert_runtime_stopped(manifest: ComfyUIModelManifest, *, require_installed: bool) -> None: @@ -1197,7 +1192,10 @@ def run_model_install( WORKFLOW_PACK_LOADER() with _MODEL_MUTATION_LOCK: recover_model_installations() - _validate_platform(manifest) + _validate_platform( + manifest, + "repair_model" if operation == "repair" else "install_model", + ) _assert_runtime_stopped(manifest, require_installed=True) current = _read_installation() if operation == "install" and current is not None: diff --git a/apps/api/src/hcs_api/comfyui_runtime.py b/apps/api/src/hcs_api/comfyui_runtime.py index d2021e4..42c4e77 100644 --- a/apps/api/src/hcs_api/comfyui_runtime.py +++ b/apps/api/src/hcs_api/comfyui_runtime.py @@ -41,6 +41,10 @@ secure_dirfd_extraction_supported, sha256_file, ) +from .comfyui_compatibility import ( + LocalImageCompatibilityError, + require_machine_compatibility, +) RuntimeStatus = Literal[ @@ -90,6 +94,7 @@ "runtime_identity_mismatch", "runtime_crashed", "runtime_stop_failed", "runtime_health_failed", "runtime_modified", "repair_failed", "uninstall_failed", "confirmation_invalid", "confirmation_expired", "confirmation_stale", + "machine_compatibility_unknown", "machine_incompatible", "task_conflict", "cancelled", "internal_error", }) @@ -436,6 +441,7 @@ class RuntimeSnapshot(_StrictModel): ProgressCallback = Callable[[str, int, str, int | None, int | None], None] CancellationCheck = Callable[[], None] +COMPATIBILITY_GUARD = require_machine_compatibility def _iso() -> str: @@ -460,6 +466,15 @@ def _runtime_manifest() -> ComfyUIRuntimeManifest: raise ComfyUIRuntimeError("runtime_manifest_invalid", "ComfyUI Runtime manifest or dependency lock is invalid") from exc +def _require_machine_compatibility( + action: Literal["install_runtime", "repair_runtime", "start_runtime"], +) -> None: + try: + COMPATIBILITY_GUARD(action) + except LocalImageCompatibilityError as exc: + raise ComfyUIRuntimeError(exc.code, exc.message) from exc + + def _public_archive_error_code(code: str) -> str: if code == "checksum_mismatch": return code @@ -2296,6 +2311,10 @@ def run_runtime_install( "confirmation_invalid", "Repair requires a valid backend confirmation" ) assert_runtime_operation_identity(confirmation) + _require_machine_compatibility( + "repair_runtime" if operation == "repair" else "install_runtime" + ) + if operation == "repair": try: stop_runtime(force=False) except ComfyUIRuntimeError as exc: @@ -3457,6 +3476,7 @@ def check_runtime_health() -> RuntimeHealthSnapshot: def start_runtime() -> RuntimeHealthSnapshot: manifest = _runtime_manifest() with _PROCESS_LOCK: + _require_machine_compatibility("start_runtime") state = _read_state() if not state.installed: raise ComfyUIRuntimeError("runtime_not_installed", "Install ComfyUI Runtime before starting it") diff --git a/apps/api/src/hcs_api/comfyui_teaching_image.py b/apps/api/src/hcs_api/comfyui_teaching_image.py index 2fdae37..a76c04a 100644 --- a/apps/api/src/hcs_api/comfyui_teaching_image.py +++ b/apps/api/src/hcs_api/comfyui_teaching_image.py @@ -20,6 +20,11 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from .comfyui_compatibility import ( + LocalImageCompatibilityError, + machine_compatibility_snapshot, + require_machine_compatibility, +) from .comfyui_model import ( MODEL_MANIFEST_SHA256, WORKFLOW_PACK_SHA256, @@ -65,6 +70,8 @@ _ASSET_MANIFEST_LIMIT = 16 * 1024 * 1024 _CAPABILITY_CACHE_LOCK = threading.RLock() _CAPABILITY_CACHE: dict[str, GenerationCapabilitySnapshot] = {} +MACHINE_COMPATIBILITY_SNAPSHOT = machine_compatibility_snapshot +COMPATIBILITY_GUARD = require_machine_compatibility class TeachingImageError(RuntimeError): @@ -156,15 +163,17 @@ class CompiledTeachingImagePlan(_StrictModel): class GenerationCapabilitySnapshot(_StrictModel): - schema_: Literal["hanclassstudio.local_image_generation_capability.v1"] = Field( - default="hanclassstudio.local_image_generation_capability.v1", alias="schema" + schema_: Literal["hanclassstudio.local_image_generation_capability.v2"] = Field( + default="hanclassstudio.local_image_generation_capability.v2", alias="schema" ) runtime_installed: bool runtime_ready: bool + model_compatible: bool model_installed: bool model_ready: bool workflow_ready: bool generation_ready: bool + compatibility_contract_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") checked_at: str technical_error: dict[str, str] | None = None @@ -274,7 +283,7 @@ def _checkpoint_is_available(object_info: Any) -> bool: ) -def _capability_fingerprint(runtime: Any, model: Any) -> str: +def _capability_fingerprint(runtime: Any, model: Any, compatibility: Any) -> str: return _sha256_payload( { "runtime_version": runtime.version, @@ -290,23 +299,46 @@ def _capability_fingerprint(runtime: Any, model: Any) -> str: "workflow_pack_id": model.workflow_pack_id, "workflow_version": model.workflow_version, "workflow_sha256": WORKFLOW_PACK_SHA256, + "model_compatible": compatibility.model_compatible, + "compatibility_status": compatibility.status, + "compatibility_contract_sha256": compatibility.contract_sha256, } ) def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilitySnapshot: + compatibility = MACHINE_COMPATIBILITY_SNAPSHOT(force_refresh=deep) runtime = runtime_snapshot() model = model_snapshot(deep=deep) workflow_ready = model.workflow_ready error = model.technical_error runtime_ready = runtime.runtime_ready + if not compatibility.model_compatible: + blocking = next( + (reason for reason in compatibility.reasons if reason.blocking), + None, + ) + error = { + "code": ( + "machine_compatibility_unknown" + if compatibility.status == "unknown" + else "machine_incompatible" + ), + "message": ( + blocking.message + if blocking is not None + else "The machine is not compatible with local image generation" + ), + } fingerprint = ( - _capability_fingerprint(runtime, model) - if deep or (runtime_ready and model.model_ready and workflow_ready) + _capability_fingerprint(runtime, model, compatibility) + if compatibility.model_compatible + and (deep or (runtime_ready and model.model_ready and workflow_ready)) else "" ) if ( not deep + and compatibility.model_compatible and runtime_ready and model.model_ready and workflow_ready @@ -316,7 +348,7 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit cached = _CAPABILITY_CACHE.get(fingerprint) if cached is not None: return cached - if deep: + if deep and compatibility.model_compatible: try: health = check_runtime_health() runtime_ready = health.healthy and health.identity_verified and health.custom_nodes_pristine @@ -352,6 +384,7 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit error = {"code": exc.code, "message": exc.message} ready = ( deep + and compatibility.model_compatible and runtime_ready and model.model_ready and workflow_ready @@ -360,10 +393,12 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit snapshot = GenerationCapabilitySnapshot( runtime_installed=runtime.installed, runtime_ready=runtime_ready, + model_compatible=compatibility.model_compatible, model_installed=model.installed, model_ready=model.model_ready, workflow_ready=workflow_ready, generation_ready=ready, + compatibility_contract_sha256=compatibility.contract_sha256, checked_at=_iso(), technical_error=error, ) @@ -865,11 +900,14 @@ def _assert_asset_id_available(manifest: AssetManifest, asset_id: str) -> None: def _revalidate_generation_context(plan: CompiledTeachingImagePlan) -> None: """Reject output if any approved execution identity changed while it ran.""" try: + COMPATIBILITY_GUARD("generate") runtime = runtime_snapshot(recover=False) current_runtime_identity = runtime_installation_identity() model_record = validate_model_installation(deep=False) current_model_identity = model_installation_identity(model_record) workflow = load_workflow_pack() + except LocalImageCompatibilityError as exc: + raise TeachingImageError(exc.code, exc.message) from exc except (ComfyUIRuntimeError, ComfyUIModelError) as exc: raise TeachingImageError( "generation_identity_mismatch", diff --git a/apps/api/src/hcs_api/main.py b/apps/api/src/hcs_api/main.py index 719e1b5..71accf0 100644 --- a/apps/api/src/hcs_api/main.py +++ b/apps/api/src/hcs_api/main.py @@ -115,6 +115,7 @@ hub_catalog, prepare_comfyui_mutation, prepare_comfyui_model_mutation, + recheck_comfyui_machine_compatibility, save_online_config, set_online_disabled, start_comfyui_mutation, @@ -1114,6 +1115,9 @@ def _provider_hub_http_error(error: ProviderHubError) -> HTTPException: "confirmation_invalid": 409, "confirmation_expired": 409, "confirmation_stale": 409, + "machine_compatibility_unknown": 409, + "machine_incompatible": 409, + "insufficient_disk": 409, "unsupported_platform": 400, "runtime_start_failed": 503, "runtime_start_timeout": 504, @@ -1369,6 +1373,19 @@ def check_provider_generation_health(package_id: str) -> dict[str, Any]: raise _provider_hub_http_error(error) from error +@app.post( + "/api/providers/hub/packages/{package_id}/compatibility/check", + response_model=ProviderHubItem, +) +def recheck_provider_machine_compatibility(package_id: str) -> dict[str, Any]: + try: + return recheck_comfyui_machine_compatibility(package_id).model_dump( + mode="json" + ) + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + @app.post("/api/providers/hub/packages/{package_id}/start", response_model=ProviderHubItem) def start_provider_runtime(package_id: str) -> dict[str, Any]: try: diff --git a/apps/api/src/hcs_api/provider_hub.py b/apps/api/src/hcs_api/provider_hub.py index a1214d7..889e755 100644 --- a/apps/api/src/hcs_api/provider_hub.py +++ b/apps/api/src/hcs_api/provider_hub.py @@ -30,6 +30,14 @@ from . import storage from .ffmpeg_video import FfmpegCapability, probe_ffmpeg from .comfyui_archive import ComfyUIArchiveError +from .comfyui_compatibility import ( + CompatibilityAction, + LocalImageCompatibilityError, + LocalImageMachineCompatibility, + compatibility_allows_action, + machine_compatibility_snapshot, + require_machine_compatibility, +) from .comfyui_runtime import ( ComfyUIRuntimeError, RuntimeOperation, @@ -92,6 +100,7 @@ "check_runtime", "repair_runtime", "uninstall_runtime", "view_runtime_logs", "open_runtime_directory", "install_model", "repair_model", "uninstall_model", "check_generation", + "recheck_compatibility", ] TrustLevel = Literal[ "official_verified", "community_verified", "discovered_unverified", @@ -130,6 +139,8 @@ _COMFYUI_PACKAGE_ID = "hcs.comfyui-runtime" _VIDEO_PROBE_TTL_SECONDS = 15 * 60 _ONLINE_HOSTS = {"api.openai.com"} +MACHINE_COMPATIBILITY_SNAPSHOT = machine_compatibility_snapshot +COMPATIBILITY_GUARD = require_machine_compatibility class ProviderHubError(RuntimeError): @@ -139,6 +150,17 @@ def __init__(self, code: ErrorCode | str, message: str) -> None: self.message = message +def _require_machine_compatibility( + action: Literal[ + "install_runtime", "repair_runtime", "install_model", "repair_model" + ], +) -> None: + try: + COMPATIBILITY_GUARD(action) + except LocalImageCompatibilityError as exc: + raise ProviderHubError(exc.code, exc.message) from exc + + class VideoCapabilityProbeCache(BaseModel): model_config = ConfigDict(extra="forbid") @@ -302,10 +324,12 @@ class ProviderHubItem(BaseModel): technical_error: dict[str, Any] | None = None last_health_check_at: str | None = None runtime_ready: bool = False + model_compatible: bool = False generation_ready: bool = False runtime_details: RuntimeSnapshot | None = None model_details: ModelPackageSnapshot | None = None generation_details: GenerationCapabilitySnapshot | None = None + machine_compatibility: LocalImageMachineCompatibility | None = None class ProviderHubCatalog(BaseModel): @@ -656,8 +680,8 @@ def _local_package_item(hardware: HardwareCapability) -> ProviderHubItem: if installed: actions.append("check_health") return ProviderHubItem( - id=_LOCAL_PACKAGE_ID, provider_id="fixture_local_image", name="本地基础生图", - description="用于词汇图片、教学插图和课件配图的安全小型能力包演练。", + id=_LOCAL_PACKAGE_ID, provider_id="fixture_local_image", name="本地生图安装演练", + description="只验证安装状态机的安全沙盒,不会生成真实图片。", provider_type="offline", capabilities=["text_to_image", "teaching_illustration", "vocabulary_image"], trust_level="official_verified", registry_source="official_registry", status=status, installed=installed, configured=installed, ready=ready, compatible=compatible, @@ -672,7 +696,7 @@ def _local_package_item(hardware: HardwareCapability) -> ProviderHubItem: redistribution_allowed=True, clear=True, ), capability_package=CapabilityPackageSpec( - id=_LOCAL_PACKAGE_ID, name="本地基础生图", description="安全 fixture 验证安装、校验、健康检查和失败清理。", + id=_LOCAL_PACKAGE_ID, name="本地生图安装演练", description="安全 fixture 验证安装、校验、健康检查和失败清理。", runtime=RuntimeSpec(id="fixture-runtime", name="Fixture Runtime", version="1.0.0", execution="local_fixture"), model_packages=[ModelPackageSpec(id="fixture-safe-image-model", name="安全测试模型元数据", version="1.0.0", format="json", safe_format=True)], workflow_packs=[WorkflowPackSpec(id="teaching-illustration-fixture-v1", name="教学插图测试工作流", version="1.0.0", capabilities=["teaching_illustration", "vocabulary_image"])], @@ -682,7 +706,12 @@ def _local_package_item(hardware: HardwareCapability) -> ProviderHubItem: ) -def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: +def _comfyui_package_item( + _hardware: HardwareCapability, + *, + machine: LocalImageMachineCompatibility | None = None, +) -> ProviderHubItem: + machine = machine or MACHINE_COMPATIBILITY_SNAPSHOT() try: snapshot = runtime_snapshot() model = model_snapshot() @@ -694,6 +723,8 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: status = "installing" elif generation.generation_ready: status = "ready" + elif machine.status == "incompatible": + status = "incompatible" else: status = snapshot.status actions: list[HubAction] @@ -714,14 +745,34 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: actions.append("uninstall_model") if snapshot.runtime_ready and model.model_ready: actions.append("check_generation") - compatible: Compatibility = hardware.status - if not snapshot.compatible: - compatible = "unsupported" + gated_actions: dict[HubAction, CompatibilityAction] = { + "install_runtime": "install_runtime", + "repair_runtime": "repair_runtime", + "start_runtime": "start_runtime", + "install_model": "install_model", + "repair_model": "repair_model", + "check_generation": "generate", + } + actions = [ + action + for action in actions + if action not in gated_actions + or compatibility_allows_action( + machine, gated_actions[action] + ) + ] + actions.append("recheck_compatibility") + compatible: Compatibility = { + "compatible": "compatible", + "compatible_limited": "compatible_but_slow", + "incompatible": "unsupported", + "unknown": "unknown", + }[machine.status] return ProviderHubItem( id=_COMFYUI_PACKAGE_ID, provider_id="comfyui_runtime", - name="ComfyUI 本地教学图片", - description="固定 Runtime、Stable Diffusion v1.5 FP16 模型与官方核心节点教学插图工作流。", + name="本地基础生图", + description="受控安装并在本机运行的基础教学图片能力;所有结果都需要教师复核。", provider_type="offline", capabilities=[ "local_image_runtime", @@ -736,10 +787,12 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: configured=snapshot.installed and model.installed, ready=generation.generation_ready, runtime_ready=snapshot.runtime_ready, + model_compatible=machine.model_compatible, generation_ready=generation.generation_ready, runtime_details=snapshot, model_details=model, generation_details=generation, + machine_compatibility=machine, compatible=compatible, available_actions=actions, recommended=True, @@ -754,11 +807,6 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: source_links=SourceLinks( official_website_url="https://www.comfy.org/", project_url="https://github.com/Comfy-Org/ComfyUI", - model_url=( - "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/" - "blob/4fddeb7f9096623f1b77f4708feb96126a08a0cf/" - "v1-5-pruned-emaonly-fp16.safetensors" - ), license_url="https://github.com/Comfy-Org/ComfyUI/blob/700821e1364eaab0e8f21c538a2131719fec57bf/LICENSE", ), license=LicenseInfo( @@ -772,8 +820,8 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: ), capability_package=CapabilityPackageSpec( id=_COMFYUI_PACKAGE_ID, - name="ComfyUI 本地教学图片", - description="一个固定 Runtime、一个固定模型包和一个固定官方核心节点工作流。", + name="本地基础生图", + description="一个冻结的受控本地教学图片能力。", runtime=RuntimeSpec( id="comfyui", name="ComfyUI", @@ -814,7 +862,11 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: last_health_check_at=( generation.checked_at if generation.generation_ready - else (snapshot.last_health.checked_at if snapshot.last_health else model.checked_at) + else ( + snapshot.last_health.checked_at + if snapshot.last_health + else model.checked_at or machine.checked_at + ) ), ) except ( @@ -825,8 +877,8 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: return ProviderHubItem( id=_COMFYUI_PACKAGE_ID, provider_id="comfyui_runtime", - name="ComfyUI 本地教学图片", - description="固定 Runtime、模型或工作流合同当前无法验证。", + name="本地基础生图", + description="固定本地教学图片合同当前无法验证。", provider_type="offline", capabilities=["local_image_runtime"], trust_level="official_verified", @@ -836,9 +888,11 @@ def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: configured=False, ready=False, runtime_ready=False, + model_compatible=machine.model_compatible, generation_ready=False, + machine_compatibility=machine, compatible="unknown", - available_actions=["view_runtime_logs"], + available_actions=["view_runtime_logs", "recheck_compatibility"], recommended=True, requires_download=True, runs_locally=True, @@ -1461,6 +1515,10 @@ def start_comfyui_mutation( raise ProviderHubError("task_conflict", "ComfyUI Runtime is already installed; use repair") if operation in {"repair", "uninstall"} and not snapshot.installed: raise ProviderHubError("runtime_not_installed", "ComfyUI Runtime is not installed") + if operation != "uninstall": + _require_machine_compatibility( + "repair_runtime" if operation == "repair" else "install_runtime" + ) confirmation: RuntimeOperationSummary | None = None if operation in {"repair", "uninstall"}: if not confirmation_token or not expected_runtime_identity: @@ -1615,6 +1673,10 @@ def start_comfyui_model_mutation( raise ProviderHubError("task_conflict", "The fixed model is already installed; use repair") elif not model.installed: raise ProviderHubError("model_not_installed", "The fixed teaching image model is not installed") + if operation != "uninstall": + _require_machine_compatibility( + "repair_model" if operation == "repair" else "install_model" + ) confirmation: ModelOperationSummary | None = None if operation in {"repair", "uninstall"}: if not confirmation_token or not expected_model_identity: @@ -1735,6 +1797,13 @@ def check_comfyui_generation_package(package_id: str) -> ProviderHubItem: return _comfyui_package_item(detect_hardware()) +def recheck_comfyui_machine_compatibility(package_id: str) -> ProviderHubItem: + if package_id != _COMFYUI_PACKAGE_ID: + raise ProviderHubError("runtime_not_found", "Runtime package was not found") + machine = MACHINE_COMPATIBILITY_SNAPSHOT(force_refresh=True) + return _comfyui_package_item(detect_hardware(), machine=machine) + + def start_comfyui_runtime_package(package_id: str) -> ProviderHubItem: if package_id != _COMFYUI_PACKAGE_ID: raise ProviderHubError("runtime_not_found", "Runtime package was not found") diff --git a/apps/api/tests/test_comfyui_compatibility.py b/apps/api/tests/test_comfyui_compatibility.py new file mode 100644 index 0000000..5cdcb9f --- /dev/null +++ b/apps/api/tests/test_comfyui_compatibility.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import hcs_api.comfyui_compatibility as compatibility +import pytest + + +@pytest.fixture(autouse=True) +def _reset_cache() -> None: + compatibility.reset_machine_compatibility_cache() + yield + compatibility.reset_machine_compatibility_cache() + + +def _supported( + monkeypatch, + *, + memory: int | None = 16 * 1024**3, + disk: int | None = 12 * 1024**3, + backend: bool | None = True, +) -> None: + monkeypatch.setattr(compatibility, "SYSTEM_NAME", lambda: "Darwin") + monkeypatch.setattr(compatibility, "MACHINE_NAME", lambda: "arm64") + monkeypatch.setattr( + compatibility, + "MAC_VERSION", + lambda: ("26.5.2", ("", "", ""), ""), + ) + monkeypatch.setattr(compatibility, "MEMORY_PROBER", lambda: memory) + monkeypatch.setattr(compatibility, "DISK_PROBER", lambda: disk) + monkeypatch.setattr( + compatibility, + "INFERENCE_BACKEND_PROBER", + lambda: (backend, "fixture_metal" if backend is not None else None), + ) + + +def test_fixed_contract_binds_runtime_model_workflow_and_disk() -> None: + contract = compatibility.load_compatibility_contract() + assert contract.requirements.runtime_manifest_sha256 == ( + "e6550ecd7a4b43aa85c7312cb69cc006651ea832e184867e1494699760ac186a" + ) + assert contract.requirements.model_manifest_sha256 == ( + "b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658" + ) + assert contract.requirements.workflow_pack_sha256 == ( + "e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e" + ) + assert contract.disk.runtime_download_bytes == 673_583_054 + assert contract.disk.model_download_bytes == 2_132_711_147 + assert contract.cpu_fallback_allowed is False + + +def test_real_validated_macos_arm64_is_compatible_but_limited( + monkeypatch, +) -> None: + _supported(monkeypatch) + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "compatible_limited" + assert snapshot.model_compatible is True + assert snapshot.inference_backend == "mps" + assert snapshot.inference_backend_available is True + assert snapshot.platform_real_validated is True + assert snapshot.requirements_identity_verified is True + assert [reason.code for reason in snapshot.reasons] == ["experimental_support"] + + +def test_unknown_backend_fails_closed(monkeypatch) -> None: + _supported(monkeypatch, backend=None) + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "unknown" + assert snapshot.model_compatible is False + assert "mps_probe_failed" in {reason.code for reason in snapshot.reasons} + with pytest.raises(compatibility.LocalImageCompatibilityError) as error: + compatibility.require_machine_compatibility("generate") + assert error.value.code == "machine_compatibility_unknown" + + +def test_known_platform_and_memory_failures_are_incompatible( + monkeypatch, +) -> None: + _supported(monkeypatch, memory=8 * 1024**3) + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "incompatible" + assert "memory_below_minimum" in {reason.code for reason in snapshot.reasons} + monkeypatch.setattr(compatibility, "SYSTEM_NAME", lambda: "Linux") + monkeypatch.setattr(compatibility, "DISK_PROBER", lambda: None) + compatibility.reset_machine_compatibility_cache() + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "incompatible" + assert snapshot.platform_support == "unavailable" + assert "unsupported_os" in {reason.code for reason in snapshot.reasons} + + +def test_disk_limit_is_operation_specific(monkeypatch) -> None: + _supported(monkeypatch, disk=6 * 1024**3) + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "compatible_limited" + assert compatibility.compatibility_allows_action(snapshot, "start_runtime") + assert compatibility.compatibility_allows_action(snapshot, "install_model") + assert not compatibility.compatibility_allows_action(snapshot, "install_runtime") + with pytest.raises(compatibility.LocalImageCompatibilityError) as error: + compatibility.require_machine_compatibility("install_runtime") + assert error.value.code == "insufficient_disk" + + +def test_fixed_requirement_mismatch_blocks_all_actions( + monkeypatch, +) -> None: + _supported(monkeypatch) + monkeypatch.setattr( + compatibility, "_requirements_match", lambda _requirements: False + ) + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + assert snapshot.status == "incompatible" + assert snapshot.requirements_identity_verified is False + assert not compatibility.compatibility_allows_action(snapshot, "generate") + + +def test_force_refresh_bypasses_cached_probe(monkeypatch) -> None: + _supported(monkeypatch) + calls = 0 + + def backend() -> tuple[bool, str]: + nonlocal calls + calls += 1 + return True, "fixture_metal" + + monkeypatch.setattr(compatibility, "INFERENCE_BACKEND_PROBER", backend) + compatibility.machine_compatibility_snapshot() + compatibility.machine_compatibility_snapshot() + assert calls == 1 + compatibility.machine_compatibility_snapshot(force_refresh=True) + assert calls == 2 diff --git a/apps/api/tests/test_comfyui_model.py b/apps/api/tests/test_comfyui_model.py index 20f2d79..cdcd2d5 100644 --- a/apps/api/tests/test_comfyui_model.py +++ b/apps/api/tests/test_comfyui_model.py @@ -8,6 +8,7 @@ import pytest import hcs_api.comfyui_model as model +from hcs_api.comfyui_compatibility import LocalImageCompatibilityError import hcs_api.storage as storage @@ -51,7 +52,9 @@ def _isolate(tmp_path: Path, monkeypatch) -> tuple[model.ComfyUIModelManifest, b manifest.safetensors.header_size = header_size manifest.safetensors.tensor_count = 1 monkeypatch.setattr(model, "MODEL_MANIFEST_LOADER", lambda: manifest) - monkeypatch.setattr(model, "_validate_platform", lambda _manifest: None) + monkeypatch.setattr( + model, "_validate_platform", lambda _manifest, _action="install_model": None + ) monkeypatch.setattr( model, "RUNTIME_SNAPSHOT", @@ -105,6 +108,24 @@ def test_fixed_model_and_workflow_contracts_are_digest_pinned() -> None: ] +def test_model_install_platform_gate_fails_closed_when_compatibility_is_unknown( + monkeypatch, +) -> None: + actions = [] + + def blocked(action): + actions.append(action) + raise LocalImageCompatibilityError( + "machine_compatibility_unknown", "fixture unknown" + ) + + monkeypatch.setattr(model, "COMPATIBILITY_GUARD", blocked) + with pytest.raises(model.ComfyUIModelError) as error: + model._validate_platform(model.load_model_manifest(), "repair_model") + assert error.value.code == "machine_compatibility_unknown" + assert actions == ["repair_model"] + + def test_model_install_repair_uninstall_and_tamper_detection(tmp_path, monkeypatch) -> None: manifest, weight, _license = _isolate(tmp_path, monkeypatch) installed = model.run_model_install("install-fixture") diff --git a/apps/api/tests/test_comfyui_runtime.py b/apps/api/tests/test_comfyui_runtime.py index 089880d..9b2e397 100644 --- a/apps/api/tests/test_comfyui_runtime.py +++ b/apps/api/tests/test_comfyui_runtime.py @@ -20,6 +20,7 @@ ComfyUIRuntimeManifest, load_runtime_manifest, ) +from hcs_api.comfyui_compatibility import LocalImageCompatibilityError def _sha(data: bytes) -> str: @@ -36,6 +37,7 @@ def _isolate(tmp_path: Path, monkeypatch) -> None: runtime._WORKER_HANDLES.clear() runtime._LOG_THREADS.clear() runtime._DESTRUCTIVE_CONFIRMATIONS.clear() + monkeypatch.setattr(runtime, "COMPATIBILITY_GUARD", lambda _action: None) def _write_archive(path: Path, manifest: ComfyUIRuntimeManifest) -> dict[str, bytes]: @@ -133,6 +135,44 @@ def _confirmed_operation(operation: str) -> runtime.RuntimeOperationSummary: ) +def test_runtime_install_fails_closed_before_mutation_when_compatibility_is_unknown( + tmp_path: Path, monkeypatch +) -> None: + _isolate(tmp_path, monkeypatch) + _install_fakes(tmp_path, monkeypatch) + + def blocked(_action): + raise LocalImageCompatibilityError( + "machine_compatibility_unknown", "fixture unknown" + ) + + monkeypatch.setattr(runtime, "COMPATIBILITY_GUARD", blocked) + with pytest.raises(runtime.ComfyUIRuntimeError) as error: + runtime.run_runtime_install("blocked-install") + assert error.value.code == "machine_compatibility_unknown" + assert runtime._journals() == {} + + +def test_runtime_start_fails_closed_before_process_work_when_compatibility_is_unknown( + tmp_path: Path, monkeypatch +) -> None: + _isolate(tmp_path, monkeypatch) + actions = [] + + def blocked(action): + actions.append(action) + raise LocalImageCompatibilityError( + "machine_compatibility_unknown", "fixture unknown" + ) + + monkeypatch.setattr(runtime, "COMPATIBILITY_GUARD", blocked) + with pytest.raises(runtime.ComfyUIRuntimeError) as error: + runtime.start_runtime() + assert error.value.code == "machine_compatibility_unknown" + assert actions == ["start_runtime"] + assert runtime._read_process() is None + + def test_install_uses_durable_journal_atomic_publish_and_runtime_only_boundary(tmp_path: Path, monkeypatch) -> None: _isolate(tmp_path, monkeypatch) manifest, archive = _install_fakes(tmp_path, monkeypatch) diff --git a/apps/api/tests/test_comfyui_teaching_image.py b/apps/api/tests/test_comfyui_teaching_image.py index 00c195a..fead0e5 100644 --- a/apps/api/tests/test_comfyui_teaching_image.py +++ b/apps/api/tests/test_comfyui_teaching_image.py @@ -27,6 +27,22 @@ from pydantic import ValidationError +@pytest.fixture(autouse=True) +def _compatible_machine(monkeypatch) -> None: + snapshot = SimpleNamespace( + status="compatible_limited", + model_compatible=True, + contract_sha256="493723bacabef0edf3b1fef69862c8fb1a9635cbf1729bbfe96cfe4e2a4662b8", + reasons=[], + ) + monkeypatch.setattr( + images, + "MACHINE_COMPATIBILITY_SNAPSHOT", + lambda **_kwargs: snapshot, + ) + monkeypatch.setattr(images, "COMPATIBILITY_GUARD", lambda _action: snapshot) + + def _chunk(name: bytes, payload: bytes) -> bytes: return ( struct.pack(">I", len(payload)) @@ -405,6 +421,40 @@ def replace_runtime(_plan, _port, _maximum): assert not (project / "assets/images").exists() +def test_machine_change_rejects_valid_png_before_publication( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + allowed = {"value": True} + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + + def compatibility_guard(_action): + if not allowed["value"]: + raise images.LocalImageCompatibilityError( + "machine_incompatible", "fixture compatibility changed" + ) + + def finish_after_machine_change(_plan, _port, _maximum): + allowed["value"] = False + return _png(), "current-job" + + monkeypatch.setattr(images, "COMPATIBILITY_GUARD", compatibility_guard) + monkeypatch.setattr( + images, "IMAGE_EXECUTOR", finish_after_machine_change + ) + with pytest.raises(images.TeachingImageError) as error: + images.generate_teaching_image(project, _request()) + assert error.value.code == "machine_incompatible" + assert not (project / "assets/data/asset_manifest.json").exists() + assert not (project / "assets/images").exists() + + def test_invalid_png_and_manifest_failure_leave_no_partial_artifact_then_retry( tmp_path: Path, monkeypatch ) -> None: @@ -621,11 +671,53 @@ def test_generation_readiness_requires_all_three_layers(monkeypatch) -> None: ) snapshot = images.generation_capability_snapshot() assert snapshot.runtime_ready is True + assert snapshot.model_compatible is True assert snapshot.model_ready is False assert snapshot.workflow_ready is True assert snapshot.generation_ready is False +def test_generation_readiness_fails_closed_when_machine_is_unknown( + monkeypatch, +) -> None: + unknown = SimpleNamespace( + status="unknown", + model_compatible=False, + contract_sha256="493723bacabef0edf3b1fef69862c8fb1a9635cbf1729bbfe96cfe4e2a4662b8", + reasons=[ + SimpleNamespace( + blocking=True, + message="The MPS backend could not be verified", + ) + ], + ) + monkeypatch.setattr( + images, + "MACHINE_COMPATIBILITY_SNAPSHOT", + lambda **_kwargs: unknown, + ) + monkeypatch.setattr( + images, + "runtime_snapshot", + lambda: SimpleNamespace(installed=True, runtime_ready=True), + ) + monkeypatch.setattr( + images, + "model_snapshot", + lambda **_kwargs: SimpleNamespace( + installed=True, + model_ready=True, + workflow_ready=True, + technical_error=None, + ), + ) + snapshot = images.generation_capability_snapshot(deep=True) + assert snapshot.model_compatible is False + assert snapshot.generation_ready is False + assert snapshot.technical_error is not None + assert snapshot.technical_error["code"] == "machine_compatibility_unknown" + + def test_generation_ready_is_fail_closed_until_live_joint_check(monkeypatch) -> None: images._CAPABILITY_CACHE.clear() runtime_snapshot = SimpleNamespace( diff --git a/apps/api/tests/test_provider_hub.py b/apps/api/tests/test_provider_hub.py index 54f2470..2cd9069 100644 --- a/apps/api/tests/test_provider_hub.py +++ b/apps/api/tests/test_provider_hub.py @@ -15,6 +15,7 @@ from pydantic import ValidationError import hcs_api.main as main +import hcs_api.comfyui_compatibility as compatibility import hcs_api.provider_hub as hub import hcs_api.provider_registry as registry import hcs_api.storage as storage @@ -43,6 +44,13 @@ def _isolate(tmp_path: Path, monkeypatch) -> TestClient: hub._cancelled_tasks.clear() hub._refresh_threads.clear() hub._reset_video_probe_cache() + machine = _machine_snapshot() + monkeypatch.setattr( + hub, + "MACHINE_COMPATIBILITY_SNAPSHOT", + lambda **_kwargs: machine, + ) + monkeypatch.setattr(hub, "COMPATIBILITY_GUARD", lambda _action: machine) return TestClient(app) @@ -107,6 +115,46 @@ def _model_snapshot(*, installed: bool, ready: bool) -> ModelPackageSnapshot: ) +def _machine_snapshot( + *, + status: compatibility.CompatibilityStatus = "compatible_limited", + free_disk_bytes: int = 12 * 1024**3, +) -> compatibility.LocalImageMachineCompatibility: + contract = compatibility.load_compatibility_contract() + return compatibility.LocalImageMachineCompatibility( + contract_id=contract.contract_id, + contract_version=contract.version, + contract_sha256=compatibility.COMPATIBILITY_CONTRACT_SHA256, + status=status, + model_compatible=status in {"compatible", "compatible_limited"}, + operating_system="macos", + os_version="26.5.2", + architecture="arm64", + inference_backend="mps", + inference_backend_available=status != "unknown", + total_memory_bytes=16 * 1024**3, + free_disk_bytes=free_disk_bytes, + platform_support="experimental", + platform_real_validated=True, + requirements_identity_verified=status != "unknown", + requirements=contract.requirements, + disk_requirements=contract.disk, + reasons=[ + compatibility.CompatibilityReason( + code=( + "mps_probe_failed" + if status == "unknown" + else "experimental_support" + ), + blocking=status == "unknown", + message="fixture compatibility", + ) + ], + checked_at=datetime.now(timezone.utc).isoformat(), + expires_at=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(), + ) + + def _generation_snapshot( *, runtime_ready: bool, @@ -117,10 +165,14 @@ def _generation_snapshot( return GenerationCapabilitySnapshot( runtime_installed=True, runtime_ready=runtime_ready, + model_compatible=True, model_installed=model_installed, model_ready=model_ready, workflow_ready=True, generation_ready=ready, + compatibility_contract_sha256=( + compatibility.COMPATIBILITY_CONTRACT_SHA256 + ), checked_at=datetime.now(timezone.utc).isoformat(), ) @@ -153,7 +205,9 @@ def test_hub_catalog_separates_domain_layers_and_actions(tmp_path, monkeypatch) assert comfyui["status"] == "not_installed" assert comfyui["ready"] is False assert comfyui["runtime_ready"] is False + assert comfyui["model_compatible"] is True assert comfyui["generation_ready"] is False + assert comfyui["machine_compatibility"]["status"] == "compatible_limited" assert comfyui["capabilities"] == [ "local_image_runtime", "teaching_illustration", @@ -167,6 +221,7 @@ def test_hub_catalog_separates_domain_layers_and_actions(tmp_path, monkeypatch) "hcs.teaching-illustration-sd15-core" ) assert "install_runtime" in comfyui["available_actions"] + assert "recheck_compatibility" in comfyui["available_actions"] def test_comfyui_generation_ready_requires_runtime_model_and_workflow( @@ -245,6 +300,47 @@ def test_comfyui_generation_ready_requires_runtime_model_and_workflow( assert "check_generation" in ready.available_actions +def test_unknown_machine_compatibility_hides_mutations_and_fails_closed( + tmp_path, monkeypatch +) -> None: + client = _isolate(tmp_path, monkeypatch) + unknown = _machine_snapshot(status="unknown") + monkeypatch.setattr( + hub, + "MACHINE_COMPATIBILITY_SNAPSHOT", + lambda **_kwargs: unknown, + ) + + def blocked(_action): + raise compatibility.LocalImageCompatibilityError( + "machine_compatibility_unknown", "fixture unknown" + ) + + monkeypatch.setattr(hub, "COMPATIBILITY_GUARD", blocked) + monkeypatch.setattr( + hub, + "runtime_snapshot", + lambda **_kwargs: _runtime_snapshot("not_installed", installed=False), + ) + item = hub._comfyui_package_item(hub.detect_hardware()) + assert item.model_compatible is False + assert item.machine_compatibility is not None + assert item.machine_compatibility.status == "unknown" + assert "install_runtime" not in item.available_actions + assert "recheck_compatibility" in item.available_actions + + install = client.post( + "/api/providers/hub/packages/hcs.comfyui-runtime/install" + ) + assert install.status_code == 409 + assert install.json()["detail"]["code"] == "machine_compatibility_unknown" + rechecked = client.post( + "/api/providers/hub/packages/hcs.comfyui-runtime/compatibility/check" + ) + assert rechecked.status_code == 200 + assert rechecked.json()["machine_compatibility"]["status"] == "unknown" + + def test_comfyui_runtime_install_task_and_failure_are_backend_authoritative(tmp_path, monkeypatch) -> None: client = _isolate(tmp_path, monkeypatch) state = {"status": "not_installed", "installed": False} diff --git a/providers/comfyui/local-image-compatibility.v1.json b/providers/comfyui/local-image-compatibility.v1.json new file mode 100644 index 0000000..36d6fc8 --- /dev/null +++ b/providers/comfyui/local-image-compatibility.v1.json @@ -0,0 +1,48 @@ +{ + "schema": "hanclassstudio.local_image_compatibility_contract.v1", + "contract_id": "hcs.local-basic-image-generation.compatibility", + "version": "1.0.0", + "capability_name": "Local basic image generation", + "requirements": { + "runtime_package_id": "hcs.comfyui-runtime", + "runtime_version": "0.28.0", + "runtime_source_commit": "700821e1364eaab0e8f21c538a2131719fec57bf", + "runtime_manifest_path": "providers/comfyui/runtime-manifest.v1.json", + "runtime_manifest_sha256": "e6550ecd7a4b43aa85c7312cb69cc006651ea832e184867e1494699760ac186a", + "model_package_id": "hcs.sd15-teaching-illustration-fp16", + "model_version": "1.5-fp16-emaonly", + "model_manifest_path": "providers/comfyui/model-package-sd15-fp16.v1.json", + "model_manifest_sha256": "b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658", + "workflow_pack_id": "hcs.teaching-illustration-sd15-core", + "workflow_version": "1.0.0", + "workflow_pack_path": "providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json", + "workflow_pack_sha256": "e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e" + }, + "platforms": [ + { + "operating_system": "macos", + "architecture": "arm64", + "minimum_os_version": "14.0", + "inference_backend": "mps", + "minimum_memory_bytes": 17179869184, + "support": "experimental", + "real_validation": { + "validated": true, + "validated_at": "2026-07-27", + "hardware": "Apple M4, 16 GB unified memory", + "evidence": "docs/comfyui-teaching-image-phase-2c.md" + } + } + ], + "disk": { + "runtime_download_bytes": 673583054, + "model_download_bytes": 2132711147, + "temporary_download_peak_bytes": 2132711147, + "full_install_minimum_free_bytes": 10737418240, + "runtime_install_minimum_free_bytes": 8589934592, + "model_install_minimum_free_bytes": 5368709120, + "generation_minimum_free_bytes": 1073741824 + }, + "cache_ttl_seconds": 300, + "cpu_fallback_allowed": false +} From c38b59b75c48f6ee9350502f903c8d3700b5cdf9 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:37:08 +0700 Subject: [PATCH 2/4] feat(provider-hub): show local image compatibility --- apps/web/src/api.ts | 7 ++ apps/web/src/components/ProviderHubDialog.tsx | 44 ++++++++- apps/web/src/i18n.tsx | 35 ++++++- apps/web/src/state.test.ts | 2 +- apps/web/src/styles.css | 8 ++ apps/web/src/types.ts | 57 ++++++++++- e2e/provider-hub.spec.mjs | 94 +++++++++++++++++-- 7 files changed, 230 insertions(+), 17 deletions(-) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 35ce873..a10a933 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -453,6 +453,13 @@ export async function checkProviderGeneration(packageId: string): Promise { + return request( + `/api/providers/hub/packages/${encodeURIComponent(packageId)}/compatibility/check`, + { method: "POST" } + ); +} + export async function startProviderRuntime(packageId: string): Promise { return request(`/api/providers/hub/packages/${encodeURIComponent(packageId)}/start`, { method: "POST" }); } diff --git a/apps/web/src/components/ProviderHubDialog.tsx b/apps/web/src/components/ProviderHubDialog.tsx index c5507c4..89d493a 100644 --- a/apps/web/src/components/ProviderHubDialog.tsx +++ b/apps/web/src/components/ProviderHubDialog.tsx @@ -17,6 +17,7 @@ import { mutateProviderModel, prepareProviderModelMutation, prepareProviderRuntimeMutation, + recheckProviderMachineCompatibility, repairProviderRuntime, saveOnlineProviderConfig, setOnlineProviderEnabled, @@ -290,6 +291,20 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => } } + async function recheckCompatibility(item: ProviderHubItem): Promise { + if (!beginMutation(item.id)) return; + setError(""); + try { + await recheckProviderMachineCompatibility(item.id); + await reload(); + } catch (nextError) { + setError(errorText(nextError, t, t("provider.hub.compatibilityCheckFailed"))); + await reload().catch(() => undefined); + } finally { + endMutation(item.id); + } + } + async function runtimeLifecycle(item: ProviderHubItem, action: "start" | "stop" | "force-stop"): Promise { if (!beginMutation(item.id)) return; setError(""); @@ -439,6 +454,8 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => const logs = runtimeLogs[item.id]; const runtimeNotice = runtimeNotices[item.id]; const generation = item.generation_details; + const machine = item.machine_compatibility; + const isLocalBasicImage = item.id === "hcs.comfyui-runtime"; const modelInstalled = generation?.model_installed ?? item.model_details?.installed ?? false; const workflowReady = generation?.workflow_ready ?? item.model_details?.workflow_ready ?? false; return ( @@ -467,10 +484,27 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => {task.error &&

{localizedTaskError === taskErrorKey ? task.error.message : localizedTaskError}

} )} + {machine && ( +
+
+ {t("provider.hub.machineCompatibility")} + {t(`provider.hub.machineCompatibility.${machine.status}`)} +
+

{machine.operating_system} {machine.os_version ?? ""} · {machine.architecture} · {t("provider.hub.machineMemory", { size: machine.total_memory_bytes ? formatBytes(machine.total_memory_bytes) : t("provider.hub.unknownValue") })} · {t("provider.hub.machineDisk", { size: machine.free_disk_bytes ? formatBytes(machine.free_disk_bytes) : t("provider.hub.unknownValue") })}

+ {machine.reasons.length > 0 ?
    + {machine.reasons.map((reason) => { + const key = `provider.hub.compatibilityReason.${reason.code}`; + const localized = t(key); + return
  • {localized === key ? reason.message : localized}
  • ; + })} +
:

{t("provider.hub.compatibilityRequirementsMet")}

} +
+ )} {item.runtime_details && (

{t("provider.hub.runtimeUsable")}{item.runtime_ready ? t("provider.hub.readyYes") : t("provider.hub.readyNo")}

+

{t("provider.hub.modelCompatible")}{item.model_compatible ? t("provider.hub.readyYes") : t("provider.hub.readyNo")}

{t("provider.hub.modelInstalled")}{modelInstalled ? t("provider.hub.readyYes") : t("provider.hub.readyNo")}

{t("provider.hub.workflowReady")}{workflowReady ? t("provider.hub.readyYes") : t("provider.hub.readyNo")}

{t("provider.hub.generationReady")}{item.generation_ready ? t("provider.hub.readyYes") : t("provider.hub.readyNo")}

@@ -503,6 +537,7 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => {hasProviderHubAction(item, "repair_model") && } {hasProviderHubAction(item, "uninstall_model") && } {hasProviderHubAction(item, "check_generation") && } + {hasProviderHubAction(item, "recheck_compatibility") && } {hasProviderHubAction(item, "cancel_install") && task && !TERMINAL_TASKS.has(task.state) && } {hasProviderHubAction(item, "configure") && } {hasProviderHubAction(item, "test_connection") && item.id === "hcs.online-image-high-quality" && } @@ -523,8 +558,8 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () =>
{t("provider.hub.type")}
{item.provider_type}
{t("provider.hub.version")}
{item.version ?? "—"} · {item.update_channel}
{item.publisher &&
{t("provider.hub.publisher")}
{item.publisher}
} - {item.runtime_details &&
{t("provider.hub.runtimeCommit")}
{item.runtime_details.source_commit}
} -
{t("provider.hub.capabilities")}
{item.capabilities.join(", ")}
+ {!isLocalBasicImage && item.runtime_details &&
{t("provider.hub.runtimeCommit")}
{item.runtime_details.source_commit}
} + {!isLocalBasicImage &&
{t("provider.hub.capabilities")}
{item.capabilities.join(", ")}
}
{t("provider.hub.license")}
{licenseUrl ? {item.license.name ?? t("provider.hub.licenseUnknownShort")} · {targetHost(licenseUrl)} : item.license.name ?? t("provider.hub.licenseUnknownShort")}
{t("provider.hub.registrySource")}
{item.registry_source}
{item.last_health_check_at &&
{t("provider.hub.lastHealthCheck")}
{new Date(item.last_health_check_at).toLocaleString()}
} @@ -532,7 +567,7 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () =>
{t("provider.hub.thirdPartyCode")}
{item.third_party_executable_code ? t("common.yes") : t("common.no")}
{sourceLinks.length > 0 &&
{sourceLinks.map((source) => {source.label} · {targetHost(source.url)})}
} - {item.capability_package && ( + {item.capability_package && !isLocalBasicImage && (
{item.capability_package.name} {item.capability_package.runtime && Runtime · {item.capability_package.runtime.name} {item.capability_package.runtime.version}} @@ -541,8 +576,9 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => Health Check · {item.capability_package.healthcheck}
)} + {isLocalBasicImage &&

{t("provider.hub.localImageFrozenBoundary")}

} {item.id === "hcs.comfyui-runtime" &&

{t("provider.hub.runtimeAttribution")}

} - {item.technical_error &&
{JSON.stringify(item.technical_error, null, 2)}
} + {item.technical_error && (isLocalBasicImage ?

:
{JSON.stringify(item.technical_error, null, 2)}
)} ); diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index 8795596..34ea1bb 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -504,6 +504,7 @@ const zh: Dict = { "provider.hub.runtimeLogsFailed": "无法读取受控 Runtime 日志。", "provider.hub.runtimeDirectoryFailed": "无法获取受控 Runtime 目录动作。", "provider.hub.generationCheckFailed": "教学图片能力未通过联合检查,请查看模型、工作流和 Runtime 状态。", + "provider.hub.compatibilityCheckFailed": "无法重新确认本机兼容性;相关操作继续保持关闭。", "provider.hub.configFailed": "Provider 配置失败。", "provider.hub.testFailed": "连接测试失败。", "provider.hub.deleteFailed": "无法删除 Provider 配置。", @@ -552,14 +553,25 @@ const zh: Dict = { "provider.hub.modelRepair": "修复图片模型", "provider.hub.modelUninstall": "卸载图片模型", "provider.hub.generationCheck": "检查生图能力", + "provider.hub.compatibilityRecheck": "重新检测兼容性", "provider.hub.runtimeViewLogs": "查看日志", "provider.hub.runtimeDirectory": "运行目录", "provider.hub.runtimeRepairConfirm": "修复会替换受控 Runtime 源码和 Python 环境,并移除外部添加的 custom nodes;未来独立模型目录不会被删除。继续吗?", "provider.hub.runtimeUninstallConfirm": "卸载只会删除 HanClassStudio 管理的 ComfyUI Runtime;项目资产和未来独立模型目录会保留。继续吗?", "provider.hub.modelRepairConfirm": "修复会重新下载并替换 HanClassStudio 管理的固定模型与许可证文件;Runtime、其他模型和项目资产会保留。继续吗?", "provider.hub.modelUninstallConfirm": "卸载只会删除 HanClassStudio 管理的固定教学图片模型与许可证文件;Runtime、其他模型和项目资产会保留。继续吗?", - "provider.hub.runtimeSummary": "ComfyUI Runtime 状态", + "provider.hub.runtimeSummary": "本地基础生图状态", + "provider.hub.machineCompatibility": "机器兼容性", + "provider.hub.machineCompatibility.compatible": "兼容", + "provider.hub.machineCompatibility.compatible_limited": "兼容但受限", + "provider.hub.machineCompatibility.incompatible": "不兼容", + "provider.hub.machineCompatibility.unknown": "无法判断", + "provider.hub.machineMemory": "总内存 {size}", + "provider.hub.machineDisk": "可用磁盘 {size}", + "provider.hub.unknownValue": "无法确认", + "provider.hub.compatibilityRequirementsMet": "固定平台、推理后端、内存、磁盘和能力身份要求均已通过。", "provider.hub.runtimeUsable": "本地运行环境", + "provider.hub.modelCompatible": "固定图片能力兼容", "provider.hub.modelInstalled": "教学图片模型", "provider.hub.workflowReady": "教学插图工作流", "provider.hub.generationReady": "当前可以生成图片", @@ -570,6 +582,24 @@ const zh: Dict = { "provider.hub.runtimePlatform": "平台支持级别:{support}", "provider.hub.runtimeModified": "检测到外部修改。HanClassStudio 不会执行未批准的 custom nodes,请先修复。", "provider.hub.generationBoundary": "只有 Runtime、固定模型与固定工作流同时就绪时,才会显示可以生成图片。", + "provider.hub.compatibilityReason.unsupported_os": "只支持 macOS;其他系统不会安装或启动本地生图。", + "provider.hub.compatibilityReason.unsupported_architecture": "只支持 Apple Silicon(arm64)。", + "provider.hub.compatibilityReason.os_version_too_old": "需要 macOS 14 或更高版本。", + "provider.hub.compatibilityReason.os_version_unknown": "无法确认 macOS 版本,已按不安全状态关闭操作。", + "provider.hub.compatibilityReason.mps_unavailable": "未检测到必需的 Apple Metal/MPS 推理后端,不会降级到 CPU。", + "provider.hub.compatibilityReason.mps_probe_failed": "无法验证 Apple Metal/MPS 推理后端,已关闭操作。", + "provider.hub.compatibilityReason.memory_below_minimum": "总内存低于固定的 16 GB 最低要求。", + "provider.hub.compatibilityReason.memory_unknown": "无法确认总内存,已关闭操作。", + "provider.hub.compatibilityReason.disk_below_runtime_minimum": "剩余磁盘不足以安全运行和保存临时产物。", + "provider.hub.compatibilityReason.disk_below_install_minimum": "可以运行已有安装,但完整安装需要至少 10 GB;安装和修复操作仍按各自固定磁盘门槛关闭。", + "provider.hub.compatibilityReason.disk_unknown": "无法确认可用磁盘,已关闭操作。", + "provider.hub.compatibilityReason.platform_not_real_validated": "当前平台尚未完成真实安装与生成生命周期验证。", + "provider.hub.compatibilityReason.experimental_support": "Apple Silicon 已真实验证,但当前仍属于受限的实验支持等级。", + "provider.hub.compatibilityReason.requirements_contract_unavailable": "固定兼容性要求清单不可用,已关闭操作。", + "provider.hub.compatibilityReason.requirements_contract_identity_mismatch": "固定兼容性要求清单身份发生变化,已关闭操作。", + "provider.hub.compatibilityReason.requirements_identity_mismatch": "Runtime、模型或工作流的固定身份与要求清单不一致。", + "provider.hub.localImageFrozenBoundary": "该能力已冻结为一个受控基础生图入口;不提供底层模型或参数选择,也不提供任意工作流和扩展市场。", + "provider.hub.localImageTechnicalIssue": "本地基础生图尚未通过后端联合检查;请依据上方状态和原因处理。", "provider.hub.runtimeDirectoryNotice": "后端已确认受控目录动作;当前 Web 入口不会暴露本机绝对路径。", "provider.hub.runtimeLogs": "受控 Runtime 日志摘要", "provider.hub.runtimeLogsEmpty": "暂无日志。", @@ -698,6 +728,9 @@ const zh: Dict = { "error.api.runtime_health_failed": "ComfyUI API 未通过身份与核心能力检查。", "error.api.runtime_modified": "运行环境含外部修改,未执行 custom nodes。", "error.api.unsupported_platform": "当前平台尚未开放真实安装。", + "error.api.machine_compatibility_unknown": "无法确认机器兼容性,安装、修复、启动和生成均已关闭。", + "error.api.machine_incompatible": "当前机器不满足固定本地生图要求。", + "error.api.insufficient_disk": "可用磁盘不足以执行该操作。", "error.api.port_conflict": "受控 loopback 端口范围已被占用。", "provider.hub.advancedProvider.offlineBlueprint": "离线课件结构生成", "provider.hub.advancedProvider.offlineBlueprintDescription": "用于在没有网络时生成稳定的测试课件结构。", diff --git a/apps/web/src/state.test.ts b/apps/web/src/state.test.ts index c433a15..a946401 100644 --- a/apps/web/src/state.test.ts +++ b/apps/web/src/state.test.ts @@ -240,7 +240,7 @@ const hubItem: ProviderHubItem = { id: "hcs.local-image-basic", provider_id: "fixture_local_image", name: "Local image", description: "fixture", provider_type: "offline", capabilities: ["text_to_image"], trust_level: "official_verified", registry_source: "official_registry", status: "not_installed", installed: false, configured: false, - ready: false, runtime_ready: false, generation_ready: false, compatible: "compatible", available_actions: ["view_details", "install"], recommended: true, + ready: false, runtime_ready: false, model_compatible: false, generation_ready: false, compatible: "compatible", available_actions: ["view_details", "install"], recommended: true, requires_download: true, requires_api_key: false, runs_locally: true, uploads_data: false, update_channel: "stable", source_links: {}, license: { redistribution_allowed: false, clear: false }, third_party_executable_code: false, redistributed_by_hanclassstudio: false, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index de6b0b8..629128c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -83,6 +83,14 @@ .button-link { display: inline-flex; align-items: center; gap: 6px; justify-content: center; text-decoration: none; } .provider-hub-warning { display: flex; align-items: flex-start; gap: 7px; padding: 9px 11px; border-radius: 10px; background: color-mix(in srgb, #f59e0b 12%, var(--surface, #fff)); color: #a16207; } .provider-hub-warning svg { flex: 0 0 auto; margin-top: 2px; } +.provider-hub-machine-compatibility { display: grid; gap: 7px; margin: 12px 0; padding: 12px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface, #fff); } +.provider-hub-machine-compatibility > div { display: flex; justify-content: space-between; gap: 12px; } +.provider-hub-machine-compatibility > div span { font-weight: 750; } +.provider-hub-machine-compatibility p, .provider-hub-machine-compatibility ul { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.45; } +.provider-hub-machine-compatibility ul { padding-inline-start: 18px; } +.provider-hub-machine-compatibility.compatibility-compatible, .provider-hub-machine-compatibility.compatibility-compatible_limited { border-color: color-mix(in srgb, #16a34a 35%, var(--line)); } +.provider-hub-machine-compatibility.compatibility-incompatible { border-color: color-mix(in srgb, #dc2626 45%, var(--line)); } +.provider-hub-machine-compatibility.compatibility-unknown { border-color: color-mix(in srgb, #f59e0b 45%, var(--line)); } .provider-hub-task { display: grid; gap: 7px; margin: 12px 0; padding: 11px; border-radius: 11px; background: var(--surface-soft); } .provider-hub-task > div { display: flex; justify-content: space-between; gap: 12px; } .provider-hub-task progress { width: 100%; accent-color: var(--primary); } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index df327cf..27ab1b2 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -193,7 +193,7 @@ export interface ProviderInstallLog { } export type ProviderHubStatus = "discovered" | "available" | "not_installed" | "installing" | "installed" | "not_configured" | "configured" | "checking" | "ready" | "degraded" | "incompatible" | "update_available" | "failed" | "disabled" | "unavailable" | "starting" | "runtime_ready" | "stopping" | "stopped" | "crashed" | "repair_required" | "unsupported_modified"; -export type ProviderHubAction = "view_details" | "open_project" | "open_api_application" | "configure" | "delete_configuration" | "test_connection" | "install" | "cancel_install" | "repair" | "check_health" | "disable" | "enable" | "view_logs" | "install_runtime" | "start_runtime" | "stop_runtime" | "force_stop_runtime" | "check_runtime" | "repair_runtime" | "uninstall_runtime" | "view_runtime_logs" | "open_runtime_directory" | "install_model" | "repair_model" | "uninstall_model" | "check_generation"; +export type ProviderHubAction = "view_details" | "open_project" | "open_api_application" | "configure" | "delete_configuration" | "test_connection" | "install" | "cancel_install" | "repair" | "check_health" | "disable" | "enable" | "view_logs" | "install_runtime" | "start_runtime" | "stop_runtime" | "force_stop_runtime" | "check_runtime" | "repair_runtime" | "uninstall_runtime" | "view_runtime_logs" | "open_runtime_directory" | "install_model" | "repair_model" | "uninstall_model" | "check_generation" | "recheck_compatibility"; export type ProviderTrustLevel = "official_verified" | "community_verified" | "discovered_unverified" | "user_added" | "deprecated" | "blocked"; export type ProviderCompatibility = "compatible" | "compatible_but_slow" | "unsupported" | "unknown"; @@ -236,6 +236,55 @@ export interface ProviderHardwareCapability { checked_at: string; } +export type LocalImageCompatibilityStatus = "compatible" | "compatible_limited" | "incompatible" | "unknown"; +export type LocalImageCompatibilityReasonCode = + | "unsupported_os" + | "unsupported_architecture" + | "os_version_too_old" + | "os_version_unknown" + | "mps_unavailable" + | "mps_probe_failed" + | "memory_below_minimum" + | "memory_unknown" + | "disk_below_runtime_minimum" + | "disk_below_install_minimum" + | "disk_unknown" + | "platform_not_real_validated" + | "experimental_support" + | "requirements_contract_unavailable" + | "requirements_contract_identity_mismatch" + | "requirements_identity_mismatch"; + +export interface LocalImageMachineCompatibility { + schema: "hanclassstudio.local_image_machine_compatibility.v1"; + contract_id: string; + contract_version?: string | null; + contract_sha256: string; + status: LocalImageCompatibilityStatus; + model_compatible: boolean; + operating_system: string; + os_version?: string | null; + architecture: string; + inference_backend?: string | null; + inference_backend_available?: boolean | null; + total_memory_bytes?: number | null; + free_disk_bytes?: number | null; + platform_support: "experimental" | "unavailable" | "unknown"; + platform_real_validated?: boolean | null; + requirements_identity_verified: boolean; + requirements?: Record | null; + disk_requirements?: Record | null; + reasons: Array<{ + code: LocalImageCompatibilityReasonCode; + blocking: boolean; + message: string; + observed?: string | null; + required?: string | null; + }>; + checked_at: string; + expires_at: string; +} + export interface ProviderHubItem { id: string; provider_id: string; @@ -268,10 +317,12 @@ export interface ProviderHubItem { technical_error?: { code?: string; message?: string; [key: string]: unknown } | null; last_health_check_at?: string | null; runtime_ready: boolean; + model_compatible: boolean; generation_ready: boolean; runtime_details?: ProviderRuntimeSnapshot | null; model_details?: ProviderModelSnapshot | null; generation_details?: ProviderGenerationSnapshot | null; + machine_compatibility?: LocalImageMachineCompatibility | null; } export interface ProviderModelSnapshot { @@ -294,13 +345,15 @@ export interface ProviderModelSnapshot { } export interface ProviderGenerationSnapshot { - schema: "hanclassstudio.local_image_generation_capability.v1"; + schema: "hanclassstudio.local_image_generation_capability.v2"; runtime_installed: boolean; runtime_ready: boolean; + model_compatible: boolean; model_installed: boolean; model_ready: boolean; workflow_ready: boolean; generation_ready: boolean; + compatibility_contract_sha256: string; checked_at: string; technical_error?: { code: string; message: string } | null; } diff --git a/e2e/provider-hub.spec.mjs b/e2e/provider-hub.spec.mjs index 9528a3f..d3b3e64 100644 --- a/e2e/provider-hub.spec.mjs +++ b/e2e/provider-hub.spec.mjs @@ -52,7 +52,7 @@ test("Provider Hub does not refresh on entry and renders a checksum failure with await expect(hub.locator(".provider-hub-card").filter({ hasText: "本地基础生图" }).first()).toBeVisible(); await expect.poll(() => refreshPosts).toBe(0); - const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地基础生图" }).first(); + const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地生图安装演练" }).first(); await localCard.getByRole("button", { name: "安装", exact: true }).click(); await expect(localCard.getByText("文件校验失败,未保留安装结果。", { exact: true })).toBeVisible(); await expect(localCard.getByText("当前可用", { exact: true })).toHaveCount(0); @@ -104,7 +104,7 @@ test("install start applies authoritative cancel action, cancels, and blocks rap await page.goto("/"); await page.getByRole("button", { name: "教学能力中心", exact: true }).first().click(); const hub = page.locator("dialog.provider-hub-dialog[open]"); - const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地基础生图" }).first(); + const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地生图安装演练" }).first(); const install = localCard.getByRole("button", { name: "安装", exact: true }); await install.evaluate((button) => { button.click(); button.click(); }); await expect.poll(() => startPosts).toBe(1); @@ -159,7 +159,7 @@ test("Provider Hub refresh summary, source details, real fixture install, and na const hub = page.locator("dialog.provider-hub-dialog[open]"); await expect.poll(() => hub.evaluate((element) => ({ scroll: element.scrollWidth, client: element.clientWidth }))).toEqual({ scroll: 390, client: 390 }); - const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地基础生图" }).first(); + const localCard = hub.locator(".provider-hub-card").filter({ hasText: "本地生图安装演练" }).first(); await localCard.getByText("高级信息", { exact: true }).click(); await expect(localCard).toContainText("Runtime · Fixture Runtime"); await expect(localCard).toContainText("Model Package"); @@ -186,6 +186,59 @@ test("Provider Hub refresh summary, source details, real fixture install, and na }); +test("local image machine compatibility renders all four contract states", async ({ page }) => { + const catalog = await (await page.request.get("http://127.0.0.1:8012/api/providers/hub")).json(); + const initial = catalog.providers.find((provider) => provider.id === "hcs.comfyui-runtime"); + const states = [ + ["compatible", "兼容"], + ["compatible_limited", "兼容但受限"], + ["incompatible", "不兼容"], + ["unknown", "无法判断"], + ]; + const providers = states.map(([status, label]) => ({ + ...initial, + id: `compatibility-${status}`, + provider_id: `compatibility-${status}`, + name: `机器状态 ${label}`, + status: status === "incompatible" ? "incompatible" : "stopped", + model_compatible: ["compatible", "compatible_limited"].includes(status), + runtime_details: null, + model_details: null, + generation_details: null, + capability_package: null, + available_actions: [], + machine_compatibility: { + ...initial.machine_compatibility, + status, + model_compatible: ["compatible", "compatible_limited"].includes(status), + reasons: status === "compatible" ? [] : [{ + code: status === "unknown" ? "mps_probe_failed" : status === "incompatible" ? "unsupported_os" : "experimental_support", + blocking: ["unknown", "incompatible"].includes(status), + message: status, + observed: null, + required: null, + }], + }, + })); + await page.route("**/api/providers/hub", async (route) => { + if (!route.request().url().endsWith("/api/providers/hub")) return route.continue(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...catalog, providers }), + }); + }); + + await page.goto("/"); + await page.getByRole("button", { name: "教学能力中心", exact: true }).first().click(); + const hub = page.locator("dialog.provider-hub-dialog[open]"); + for (const [, label] of states) { + const card = hub.locator(".provider-hub-card").filter({ hasText: `机器状态 ${label}` }); + await expect(card.getByText(label, { exact: true })).toBeVisible(); + } +}); + + test("ComfyUI Runtime and fixed teaching model expose truthful generation readiness", async ({ page }) => { const initialCatalog = await (await page.request.get("http://127.0.0.1:8012/api/providers/hub")).json(); const initial = initialCatalog.providers.find((provider) => provider.id === "hcs.comfyui-runtime"); @@ -193,10 +246,11 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin let installed = false; let activeTask = null; let modelInstalled = false; + let compatibilityChecks = 0; const actions = () => { - if (!installed) return ["install_runtime", "view_runtime_logs", "open_runtime_directory"]; - if (status === "runtime_ready") return ["stop_runtime", "force_stop_runtime", "check_runtime", ...(modelInstalled ? ["check_generation"] : []), "view_runtime_logs", "open_runtime_directory"]; - return ["start_runtime", "check_runtime", "repair_runtime", "uninstall_runtime", ...(modelInstalled ? ["repair_model", "uninstall_model"] : ["install_model"]), "view_runtime_logs", "open_runtime_directory"]; + if (!installed) return ["install_runtime", "view_runtime_logs", "open_runtime_directory", "recheck_compatibility"]; + if (status === "runtime_ready") return ["stop_runtime", "force_stop_runtime", "check_runtime", ...(modelInstalled ? ["check_generation"] : []), "view_runtime_logs", "open_runtime_directory", "recheck_compatibility"]; + return ["start_runtime", "check_runtime", "repair_runtime", "uninstall_runtime", ...(modelInstalled ? ["repair_model", "uninstall_model"] : ["install_model"]), "view_runtime_logs", "open_runtime_directory", "recheck_compatibility"]; }; const provider = () => ({ ...initial, @@ -205,6 +259,7 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin configured: installed && modelInstalled, ready: status === "runtime_ready" && modelInstalled, runtime_ready: status === "runtime_ready", + model_compatible: true, generation_ready: status === "runtime_ready" && modelInstalled, available_actions: actions(), model_details: { @@ -218,11 +273,18 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin ...initial.generation_details, runtime_installed: installed, runtime_ready: status === "runtime_ready", + model_compatible: true, model_installed: modelInstalled, model_ready: modelInstalled, workflow_ready: true, generation_ready: status === "runtime_ready" && modelInstalled, }, + machine_compatibility: { + ...initial.machine_compatibility, + status: "compatible_limited", + model_compatible: true, + reasons: [{ code: "experimental_support", blocking: false, message: "fixture limited", observed: null, required: null }], + }, runtime_details: { ...initial.runtime_details, status, @@ -275,6 +337,10 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin status = "stopped"; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(provider()) }); }); + await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/compatibility/check", async (route) => { + compatibilityChecks += 1; + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(provider()) }); + }); const modelUninstallIdentity = "d".repeat(64); const modelUninstallToken = "e".repeat(64); await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/model/prepare-uninstall", async (route) => { @@ -359,7 +425,12 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin await page.goto("/"); await page.getByRole("button", { name: "教学能力中心", exact: true }).first().click(); const hub = page.locator("dialog.provider-hub-dialog[open]"); - const card = hub.locator(".provider-hub-card").filter({ hasText: "ComfyUI 本地教学图片" }).first(); + const card = hub.locator(".provider-hub-card").filter({ hasText: "本地基础生图" }).first(); + await expect(card).toContainText("机器兼容性"); + await expect(card).toContainText("兼容但受限"); + await expect(card).toContainText("Apple Silicon 已真实验证,但当前仍属于受限的实验支持等级。"); + await card.getByRole("button", { name: "重新检测兼容性", exact: true }).click(); + await expect.poll(() => compatibilityChecks).toBe(1); await expect(card).toContainText("当前可以生成图片"); await expect(card).toContainText("未就绪"); await expect(card.getByRole("button", { name: /生成图片/ })).toHaveCount(0); @@ -373,8 +444,13 @@ test("ComfyUI Runtime and fixed teaching model expose truthful generation readin await expect(card).toContainText("当前可以生成图片"); await card.getByRole("button", { name: "启动", exact: true }).click(); await expect(card.getByText("当前可用", { exact: true })).toBeVisible(); - await expect(card.locator('.provider-hub-readiness-grid p[data-ready="true"]')).toHaveCount(4); + await expect(card.locator('.provider-hub-readiness-grid p[data-ready="true"]')).toHaveCount(5); await expect(card.getByRole("button", { name: /生成图片/ })).toHaveCount(0); + await card.getByText("高级信息", { exact: true }).click(); + await expect(card).not.toContainText("Model Package"); + await expect(card).not.toContainText("Workflow Pack"); + await expect(card).not.toContainText("Stable Diffusion"); + await expect(card).not.toContainText("LoRA"); await card.getByRole("button", { name: "停止", exact: true }).click(); await expect(card.getByText("已安装,当前停止", { exact: true })).toBeVisible(); await expect(card.getByText("当前可以生成图片", { exact: true }).locator("..")).toHaveAttribute("data-ready", "false"); @@ -439,7 +515,7 @@ test("ComfyUI archive security fixture never renders Runtime ready", async ({ pa await page.goto("/"); await page.getByRole("button", { name: "教学能力中心", exact: true }).first().click(); - const card = page.locator("dialog.provider-hub-dialog[open] .provider-hub-card").filter({ hasText: "ComfyUI 本地教学图片" }).first(); + const card = page.locator("dialog.provider-hub-dialog[open] .provider-hub-card").filter({ hasText: "本地基础生图" }).first(); await card.getByRole("button", { name: "安装运行环境", exact: true }).click(); await expect(card.getByText("archive 未通过安全检查,未发布 Runtime。", { exact: true })).toBeVisible(); await expect(card.getByText("运行环境可用", { exact: true })).toHaveCount(0); From a9550a327ad868c9e63a11dad81c6798f459051b Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:37:34 +0700 Subject: [PATCH 3/4] docs(comfyui): freeze local basic image capability --- docs/comfyui-teaching-image-phase-2c.md | 15 +- .../local-basic-image-capability-freeze-v1.md | 239 ++++++++++++++++++ docs/provider-hub.md | 38 ++- docs/roadmap.md | 17 +- 4 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 docs/local-basic-image-capability-freeze-v1.md diff --git a/docs/comfyui-teaching-image-phase-2c.md b/docs/comfyui-teaching-image-phase-2c.md index 3799410..db171a7 100644 --- a/docs/comfyui-teaching-image-phase-2c.md +++ b/docs/comfyui-teaching-image-phase-2c.md @@ -15,6 +15,12 @@ Runtime ready → Asset Manifest ``` +The production slice is closed by the versioned +[Local Basic Image Capability Freeze and Compatibility Contract v1](local-basic-image-capability-freeze-v1.md). +That contract adds backend-authoritative machine compatibility without changing +this fixed model, workflow, prompt profile, sampling, artifact, provenance, or +teacher-review boundary. + It is not a model marketplace or a generic ComfyUI API. Callers cannot provide a model URL, checkpoint name, workflow JSON, node, sampler, negative prompt, output path, batch size, or arbitrary graph. @@ -138,12 +144,13 @@ restart/repair, model mutation, or identity change returns to false until a live joint check succeeds. Failures are cached for that same identity so a Hub refresh cannot turn a failed check into a false success. -Provider Hub shows four independent teacher-facing facts: +Provider Hub shows five independent teacher-facing facts: 1. local Runtime usable; -2. teaching image model installed; -3. teaching illustration workflow ready; -4. image generation currently available. +2. fixed image capability compatible with this machine; +3. teaching image model installed; +4. teaching illustration workflow ready; +5. image generation currently available. Installing Runtime alone therefore never renders “currently available for generation.” diff --git a/docs/local-basic-image-capability-freeze-v1.md b/docs/local-basic-image-capability-freeze-v1.md new file mode 100644 index 0000000..c4308ab --- /dev/null +++ b/docs/local-basic-image-capability-freeze-v1.md @@ -0,0 +1,239 @@ +# Local Basic Image Capability Freeze and Compatibility Contract v1 + +Status: frozen production capability contract + +Contract version: `1.0.0` + +Contract schema: `hanclassstudio.local_image_compatibility_contract.v1` + +Contract file: `providers/comfyui/local-image-compatibility.v1.json` + +Contract SHA-256: +`493723bacabef0edf3b1fef69862c8fb1a9635cbf1729bbfe96cfe4e2a4662b8` + +This document closes the current local image-generation implementation as one +teacher-facing capability named **Local basic image generation**. It adds +machine compatibility as a backend-authoritative precondition without +broadening the model, workflow, prompt, sampling, or product surface. + +## Frozen capability identity + +The production capability is the following indivisible identity set: + +| Layer | Fixed identity | +| --- | --- | +| Runtime | `hcs.comfyui-runtime`, ComfyUI `0.28.0`, upstream commit `700821e1364eaab0e8f21c538a2131719fec57bf` | +| Runtime manifest | `providers/comfyui/runtime-manifest.v1.json`, SHA-256 `e6550ecd7a4b43aa85c7312cb69cc006651ea832e184867e1494699760ac186a` | +| Model Package | `hcs.sd15-teaching-illustration-fp16`, version `1.5-fp16-emaonly` | +| Model manifest | `providers/comfyui/model-package-sd15-fp16.v1.json`, SHA-256 `b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658` | +| Model payload | `v1-5-pruned-emaonly-fp16.safetensors`, 2,132,696,762 bytes, SHA-256 `e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916` | +| Workflow Pack | `hcs.teaching-illustration-sd15-core`, version `1.0.0` | +| Workflow manifest | `providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json`, SHA-256 `e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e` | +| Prompt profile | `soft-flat-educational-v1`, exact prefix, suffix, and negative prompt from the Workflow Pack | +| Sampling | one image; 20 steps; CFG 7; Euler; normal scheduler; denoise 1.0 | +| Dimensions | `512×512`, `512×384`, or `512×288`, selected only by the request aspect-ratio enum | +| Output | one PNG, at most 16 MiB, no PNG text metadata | +| Artifact | `hanclassstudio.verified_image_artifact.v1` plus a hash-bound provenance record and Asset Manifest registration | +| Review boundary | every generated candidate remains `pending_review`; generation never implies teacher acceptance or classroom suitability | + +The existing `TeachingImageRequest` is the only public generation input. +HanClassStudio continues to compile the fixed graph internally. Callers cannot +select or submit an implementation-level model, workflow, node, prompt profile, +sampling configuration, output path, or batch. + +SSD-1B and any style-adapter experiments remain evaluation evidence only. They +are not members of this identity set and cannot satisfy this contract. + +## Compatibility states + +The local-image compatibility probe has a capability-specific four-state +contract. It is separate from the Provider Hub's generic hardware summary. + +| State | Meaning | Mutation and generation behavior | +| --- | --- | --- | +| `compatible` | Every fixed requirement is known, met, identity-verified, and fully supported. | Allowed subject to the operation-specific disk reserve and normal lifecycle checks. No current platform row claims this unrestricted level. | +| `compatible_limited` | Every blocking requirement is met, but a known non-blocking limitation remains. | Allowed subject to operation-specific disk reserve and normal lifecycle checks. The current validated Apple Silicon row is in this state because support remains experimental. | +| `incompatible` | A known blocking requirement is not met. | Install, repair, start, and generation fail closed. Stop and owned uninstall remain available for recovery. | +| `unknown` | A required fact or fixed contract identity cannot be verified. | Same fail-closed behavior as incompatible. The UI cannot override it. | + +`model_compatible` is true only for `compatible` and +`compatible_limited`. A compatibility label is never inferred from whether the +model happens to be installed. + +## Supported matrix + +| OS | Architecture | Backend | Memory | Validation | Result | +| --- | --- | --- | --- | --- | --- | +| macOS 14 or newer | Apple Silicon `arm64` | verified Apple Metal/MPS | at least 16 GiB unified memory | real lifecycle passed on Apple M4 / 16 GB on 2026-07-27 | `compatible_limited` when fixed identities and the action's disk reserve also pass | +| macOS with a known older version | any | any | any | not supported | `incompatible` | +| macOS | non-`arm64` | any | any | not supported | `incompatible` | +| Windows or Linux | any | any | any | no production implementation | `incompatible` | +| Any row with unknown OS version, memory, disk, backend, or contract identity | any | unknown | unknown | unverifiable | `unknown` | + +There is no silent CPU fallback, substitute model, lower-memory profile, or +unverified platform adapter. + +## Disk contract + +The contract records both payload evidence and action gates: + +| Requirement | Bytes | Purpose | +| --- | ---: | --- | +| Fixed Runtime download | 673,583,054 | reviewed Runtime artifact total | +| Fixed model package download | 2,132,711,147 | checkpoint plus pinned license payload | +| Temporary download peak | 2,132,711,147 | largest single staged download | +| Full Runtime install | 10,737,418,240 (10 GiB) free | reserve before first Runtime install | +| Runtime repair | 8,589,934,592 (8 GiB) free | reserve before Runtime replacement | +| Model install or repair | 5,368,709,120 (5 GiB) free | reserve for staged model publication | +| Start or generate | 1,073,741,824 (1 GiB) free | minimum operating reserve | + +Insufficient space for a full installation can produce +`compatible_limited` while still allowing an existing complete installation to +start and generate. Every action independently applies its own threshold. + +## Reason codes + +Reasons are structured backend facts. The WebUI localizes the code and does not +derive safety decisions from the message. + +| Code | Classification | +| --- | --- | +| `unsupported_os` | known incompatible OS | +| `unsupported_architecture` | known incompatible CPU architecture | +| `os_version_too_old` | known incompatible macOS version | +| `os_version_unknown` | required macOS version could not be verified | +| `mps_unavailable` | required Metal/MPS backend is known to be unavailable | +| `mps_probe_failed` | Metal/MPS availability could not be verified | +| `memory_below_minimum` | known memory below 16 GiB | +| `memory_unknown` | total memory could not be verified | +| `disk_below_runtime_minimum` | free disk below the 1 GiB run/generation reserve | +| `disk_below_install_minimum` | non-blocking capability limitation; one or more installation actions may still be gated by their stricter threshold | +| `disk_unknown` | free disk could not be verified | +| `platform_not_real_validated` | platform lacks required real lifecycle evidence | +| `experimental_support` | non-blocking limitation for the current validated Apple Silicon row | +| `requirements_contract_unavailable` | versioned compatibility contract could not be safely loaded | +| `requirements_contract_identity_mismatch` | compatibility contract bytes no longer match the code-pinned SHA-256 | +| `requirements_identity_mismatch` | Runtime, Model Package, or Workflow Pack manifest bytes no longer match the compatibility contract | + +Operation refusal uses stable public codes: +`machine_compatibility_unknown`, `machine_incompatible`, and +`insufficient_disk`. + +## Readiness and operation gates + +The states remain deliberately non-equivalent: + +```text +runtime_ready +!= model_compatible +!= model_installed +!= workflow_ready +!= generation_ready +``` + +The generation capability schema is +`hanclassstudio.local_image_generation_capability.v2` and reports all five +facts. Its positive condition is: + +```text +generation_ready = + live_deep_check_requested + AND runtime_ready + AND model_compatible + AND model_installed + AND exact model installation is ready + AND workflow_ready + AND no technical error +``` + +The compatibility contract SHA-256 participates in the cached joint-capability +fingerprint. A Runtime-only installation therefore cannot become +generation-ready, and an installed model on an incompatible machine remains +unavailable. + +The backend force-refreshes compatibility: + +- before Runtime install and repair; +- before Runtime start; +- before model install and repair; +- at generation deep preflight; +- after provider execution and before an image can be published; +- when the teacher explicitly selects **Recheck compatibility**. + +Provider Hub pre-gates displayed actions, but this is only presentation. The +mutation worker or generation path checks again immediately before the +operation. No frontend state authorizes an operation. + +Stop, force-stop, model uninstall, and Runtime uninstall remain governed by +their existing ownership and confirmation contracts and are intentionally +available on an unsupported machine so an owned installation can be made safe +or removed. + +## Probe, expiry, and invalidation + +The probe reads: + +- normalized OS and architecture; +- macOS version; +- total physical memory; +- free space at the nearest existing ancestor of the managed Runtime root; +- Metal capability from the bounded, fixed-argument + `system_profiler SPDisplaysDataType -json` probe; +- the compatibility-contract SHA-256; +- the exact Runtime, Model Package, and Workflow Pack manifest SHA-256 values. + +Normal catalog reads may reuse a process-local result for at most 300 seconds. +An expired entry is re-probed. The explicit recheck and every compatibility- +gated install, repair, start, or generation path bypass the cache. Any +unreadable fact, malformed response, probe failure, manifest change, or +compatibility-contract change invalidates a positive result and fails closed. + +The current machine probe is not a performance benchmark. A compatible result +means only that the fixed capability meets its installation and execution +preconditions; it does not predict image quality, latency, or pedagogical +fitness. + +### Detector validation — 2026-07-28 + +The real probe on the development Apple Silicon host reported macOS `26.5.2`, +`arm64`, 17,179,869,184 bytes of unified memory, an available Metal/MPS +backend, more than the 10 GiB full-install reserve, and matching identities for +all three fixed manifests. The result was `compatible_limited` with the sole +non-blocking reason `experimental_support`. No Runtime, model, image, cache, or +real-generation artifact was installed or produced for this detector check. + +## Product and storage boundary + +Provider Hub presents one teacher-facing **Local basic image generation** card +with machine status, reasons, recheck, and the five readiness facts. It does not +present implementation-level model, sampling, extension, or marketplace +controls. + +Physical ownership remains: + +```text +runtime/providers/hcs.comfyui-runtime/ managed executable Runtime +runtime/provider-models/comfyui/ managed fixed model and license +runtime/provider-data/comfyui/ managed input/output/temp/user data +runtime/projects// teacher project assets and manifests +``` + +No model, generated image, cache, Runtime tree, temporary download, or real +validation report belongs in Git. + +## Frozen non-goals + +This capability freeze does not add or promote: + +- another production model or any model selection; +- alternate prompt or sampling profiles; +- arbitrary workflows, custom nodes, or extension packages; +- image editing, consistency controls, batches, or automatic lesson images; +- cloud fallback; +- Windows or Linux installation; +- a local performance guarantee; +- automatic teacher scoring or acceptance. + +Changes to any fixed identity, platform row, threshold, prompt, sampling value, +or teacher-review boundary require a new versioned contract and a separate +real-validation decision. They are not maintenance changes to v1. diff --git a/docs/provider-hub.md b/docs/provider-hub.md index f6afe2b..0242585 100644 --- a/docs/provider-hub.md +++ b/docs/provider-hub.md @@ -23,9 +23,11 @@ those entries and adds the new layered capability-package contract. Existing settings and Registry routes remain compatible. The backend is authoritative for `status`, `compatible`, `ready`, and -`available_actions`. The WebUI renders only returned actions. Opening the Hub -performs local `GET` requests only; it never refreshes a remote source, saves a -configuration, starts an installation, or tests a connection implicitly. +`available_actions`. The fixed local-image entry additionally reports the +separate `model_compatible` fact and its capability-specific machine snapshot. +The WebUI renders only returned actions. Opening the Hub performs local `GET` +requests only; it never refreshes a remote source, saves a configuration, +starts an installation, or tests a connection implicitly. The teaching-video entry uses a process-local `VideoCapabilityProbeCache` instead of launching the full FFmpeg/font probe on every catalog read. A normal @@ -162,11 +164,13 @@ fixture installer or authorize registry-provided archives. ## Phase 2B Runtime and Phase 2C fixed teaching image -The ComfyUI card presents one capability package without collapsing its -layers. `RuntimeSnapshot.generation_ready` stays false. The Hub projection adds -the fixed model/workflow details and computes `generation_ready` only when -Runtime, model, and workflow are jointly ready. Runtime-only installation never -sets legacy `ready` or generation readiness. +The ComfyUI card is presented to teachers as **Local basic image generation**. +It keeps its internal layers separate without exposing implementation-level +model or sampling controls. `RuntimeSnapshot.generation_ready` stays false. The +Hub projection adds the fixed compatibility/model/workflow facts and computes +`generation_ready` only when Runtime, machine compatibility, model, and +workflow are jointly ready. Runtime-only installation never sets legacy +`ready` or generation readiness. Backend actions are specific to the lifecycle: @@ -177,6 +181,7 @@ check_runtime / repair_runtime / uninstall_runtime view_runtime_logs / open_runtime_directory install_model / repair_model / uninstall_model check_generation +recheck_compatibility ``` Runtime and model install, repair, and uninstall return the common asynchronous @@ -204,6 +209,9 @@ ownership, loopback networking, recovery, repair/uninstall, real-host evidence, attribution, and limits are documented in [Controlled ComfyUI Runtime — Phase 2B](comfyui-runtime-phase-2b.md) and [Controlled ComfyUI Teaching Image — Phase 2C](comfyui-teaching-image-phase-2c.md). +The complete frozen identity, four-state machine contract, action-specific disk +reserves, reason codes, expiry, and invalidation rules are versioned in +[Local Basic Image Capability Freeze and Compatibility Contract v1](local-basic-image-capability-freeze-v1.md). ## Online configuration and secrets @@ -255,6 +263,14 @@ NVIDIA GPU/CUDA, Apple MPS, and the DirectML platform signal. Results are Probe failures degrade to `unknown` and never hide the catalog. No runtime speed estimate is shown because phase 1 has no representative benchmark. +The fixed local-image capability does not use this generic result as its +security gate. Its backend-specific probe reports `compatible`, +`compatible_limited`, `incompatible`, or `unknown`; verifies the exact frozen +Runtime/model/workflow requirements; and applies separate disk reserves to +install, repair, start, and generation. `unknown` fails closed. The result may +be cached for 300 seconds on read, while explicit recheck and every operation +force a new probe. No CPU downgrade or substitute model is permitted. + ## Add an online Provider 1. Add reviewed Provider metadata and links in the built-in catalog or validated @@ -292,9 +308,9 @@ estimate is shown because phase 1 has no representative benchmark. - Frontend state: exact action gating, teacher-facing filters, and direct safe/ legacy error-envelope parsing tests. - Playwright: no startup refresh, explicit refresh, failed install never ready, - real fixture install, complete fake Runtime/model lifecycle with four truthful - readiness facts, unsafe archive never ready, no generic generation action, - mobile overflow, Escape/focus + real fixture install, all four local-image machine states, complete fake + Runtime/model lifecycle with five truthful readiness facts, unsafe archive + never ready, no generic generation action, mobile overflow, Escape/focus restoration, and explicit configuration without secret rendering or placeholder-model inheritance. - Repository gate: full `npm test` plus full Playwright E2E. diff --git a/docs/roadmap.md b/docs/roadmap.md index 708e6ae..c9c793f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -99,10 +99,18 @@ Provider Hub local Runtime status: Phase 2C adds exactly one commit-pinned Comfy Org SD 1.5 FP16 SafeTensors Model Package and one digest-pinned seven-core-node teaching illustration Workflow Pack for macOS Apple Silicon; -- `generation_ready` requires the managed Runtime, exact model, and exact - workflow together; a controlled `TeachingImageRequest` can produce one - verified PNG with complete Runtime/model/workflow/request/plan provenance and - Asset Manifest registration; +- the production capability is now frozen as **Local basic image generation**; + its versioned backend compatibility contract checks OS/architecture, + Metal/MPS, memory, action-specific disk reserve, real-validation status, and + the exact Runtime/model/workflow manifest identities; +- `runtime_ready`, `model_compatible`, `model_installed`, `workflow_ready`, and + `generation_ready` remain separate; `unknown` compatibility fails closed and + install, repair, start, and generation each force a new backend probe; +- `generation_ready` requires the managed Runtime, compatible machine, exact + model, and exact workflow together; a controlled `TeachingImageRequest` can + produce one verified PNG with complete + Runtime/model/workflow/request/plan provenance and Asset Manifest + registration; - the real macOS arm64 install/start/generate/stop/repair/restart/revalidate/ stop/model-uninstall/Runtime-uninstall lifecycle passed on 2026-07-27 Asia/Bangkok; the 512×384 artifact and provenance hashes were verified and @@ -113,6 +121,7 @@ Provider Hub local Runtime status: binding, system-driver installation, or Windows/Linux model install is enabled; - see [Controlled ComfyUI Runtime — Phase 2B](comfyui-runtime-phase-2b.md). - see [Controlled ComfyUI Teaching Image — Phase 2C](comfyui-teaching-image-phase-2c.md). +- see [Local Basic Image Capability Freeze and Compatibility Contract v1](local-basic-image-capability-freeze-v1.md). ## Artifact Ownership From 7b2c07fb93dd57bb11d8e5d2b37e820ee632086a Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:51:17 +0700 Subject: [PATCH 4/4] Harden local image capability boundaries --- apps/api/src/hcs_api/comfyui_compatibility.py | 118 ++++++++++++++++-- apps/api/src/hcs_api/comfyui_model.py | 3 +- apps/api/src/hcs_api/comfyui_runtime.py | 13 ++ .../api/src/hcs_api/comfyui_teaching_image.py | 2 + apps/api/src/hcs_api/provider_hub.py | 13 +- apps/api/tests/test_comfyui_compatibility.py | 97 ++++++++++++++ apps/api/tests/test_comfyui_model.py | 35 ++++++ apps/api/tests/test_comfyui_runtime.py | 89 ++++++++++++- apps/api/tests/test_comfyui_teaching_image.py | 53 ++++++++ apps/api/tests/test_provider_hub.py | 77 +++++++++++- apps/web/src/components/ProviderHubDialog.tsx | 2 +- apps/web/src/i18n.tsx | 2 +- docs/comfyui-teaching-image-phase-2c.md | 13 +- .../local-basic-image-capability-freeze-v1.md | 53 +++++++- 14 files changed, 538 insertions(+), 32 deletions(-) diff --git a/apps/api/src/hcs_api/comfyui_compatibility.py b/apps/api/src/hcs_api/comfyui_compatibility.py index 4092a91..edb994f 100644 --- a/apps/api/src/hcs_api/comfyui_compatibility.py +++ b/apps/api/src/hcs_api/comfyui_compatibility.py @@ -193,9 +193,19 @@ class LocalImageMachineCompatibility(_StrictModel): checked_at: str expires_at: str + @model_validator(mode="after") + def _fail_closed_state_invariants(self) -> LocalImageMachineCompatibility: + compatible = self.status in {"compatible", "compatible_limited"} + if self.model_compatible != compatible: + raise ValueError("machine compatibility state is inconsistent") + if compatible and not self.requirements_identity_verified: + raise ValueError("compatible state requires verified fixed identities") + return self + _CACHE_LOCK = threading.RLock() _CACHE: LocalImageMachineCompatibility | None = None +_CACHE_INPUT_IDENTITY: str | None = None def compatibility_contract_path() -> Path: @@ -223,6 +233,46 @@ def _sha256_regular_file(path: Path, maximum_bytes: int) -> str: return hashlib.sha256(_read_bounded_regular_file(path, maximum_bytes)).hexdigest() +def _fixed_inputs_identity() -> str: + """Fingerprint code-owned inputs so a positive cache cannot outlive them.""" + digest = hashlib.sha256() + inputs = ( + ( + "providers/comfyui/local-image-compatibility.v1.json", + compatibility_contract_path(), + 64 * 1024, + ), + ( + "providers/comfyui/runtime-manifest.v1.json", + storage.ROOT_DIR / "providers/comfyui/runtime-manifest.v1.json", + 512 * 1024, + ), + ( + "providers/comfyui/model-package-sd15-fp16.v1.json", + storage.ROOT_DIR + / "providers/comfyui/model-package-sd15-fp16.v1.json", + 512 * 1024, + ), + ( + "providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json", + storage.ROOT_DIR + / "providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json", + 512 * 1024, + ), + ) + for label, path, maximum_bytes in inputs: + digest.update(label.encode()) + digest.update(b"\0") + try: + payload = _read_bounded_regular_file(path, maximum_bytes) + except OSError: + digest.update(b"unavailable") + else: + digest.update(hashlib.sha256(payload).digest()) + digest.update(b"\0") + return digest.hexdigest() + + def load_compatibility_contract() -> LocalImageCompatibilityContract: path = compatibility_contract_path() try: @@ -291,14 +341,31 @@ def _probe_mps() -> tuple[bool | None, str | None]: displays = payload.get("SPDisplaysDataType") if not isinstance(displays, list): return None, None + saw_metal_key = False + saw_unverifiable_value = False for display in displays: if not isinstance(display, dict): continue - if any( - key.startswith(("spdisplays_metal", "spdisplays_mtl")) - for key in display - ): + for key, value in display.items(): + if not key.startswith(("spdisplays_metal", "spdisplays_mtl")): + continue + saw_metal_key = True + if isinstance(value, bool): + if value: + return True, "system_profiler_metal" + continue + if not isinstance(value, str) or not value.strip(): + saw_unverifiable_value = True + continue + normalized = value.casefold() + if any( + marker in normalized + for marker in ("not supported", "unsupported", "unavailable") + ): + continue return True, "system_profiler_metal" + if saw_metal_key and saw_unverifiable_value: + return None, None return False, "system_profiler_no_metal" except (OSError, subprocess.SubprocessError, UnicodeError, json.JSONDecodeError): return None, None @@ -613,25 +680,53 @@ def _probe() -> LocalImageMachineCompatibility: def reset_machine_compatibility_cache() -> None: - global _CACHE + global _CACHE, _CACHE_INPUT_IDENTITY with _CACHE_LOCK: _CACHE = None + _CACHE_INPUT_IDENTITY = None def machine_compatibility_snapshot( *, force_refresh: bool = False, ) -> LocalImageMachineCompatibility: - global _CACHE + global _CACHE, _CACHE_INPUT_IDENTITY now = datetime.now(timezone.utc) with _CACHE_LOCK: + input_identity = _fixed_inputs_identity() if not force_refresh and _CACHE is not None: try: - if datetime.fromisoformat(_CACHE.expires_at) > now: + if ( + _CACHE_INPUT_IDENTITY == input_identity + and datetime.fromisoformat(_CACHE.expires_at) > now + ): return _CACHE except ValueError: pass - _CACHE = _probe() + snapshot = _probe() + current_input_identity = _fixed_inputs_identity() + if current_input_identity != input_identity: + snapshot = snapshot.model_copy( + update={ + "status": "incompatible", + "model_compatible": False, + "requirements_identity_verified": False, + "reasons": [ + *[ + reason + for reason in snapshot.reasons + if reason.code != "requirements_identity_mismatch" + ], + _reason( + "requirements_identity_mismatch", + "A fixed requirement identity changed during compatibility probing", + blocking=True, + ), + ], + } + ) + _CACHE = snapshot + _CACHE_INPUT_IDENTITY = current_input_identity return _CACHE @@ -639,7 +734,12 @@ def compatibility_allows_action( snapshot: LocalImageMachineCompatibility, action: CompatibilityAction, ) -> bool: - if not snapshot.model_compatible or snapshot.free_disk_bytes is None: + if ( + snapshot.status not in {"compatible", "compatible_limited"} + or not snapshot.model_compatible + or not snapshot.requirements_identity_verified + or snapshot.free_disk_bytes is None + ): return False disk = snapshot.disk_requirements if disk is None: diff --git a/apps/api/src/hcs_api/comfyui_model.py b/apps/api/src/hcs_api/comfyui_model.py index 1402463..1af7f29 100644 --- a/apps/api/src/hcs_api/comfyui_model.py +++ b/apps/api/src/hcs_api/comfyui_model.py @@ -724,7 +724,7 @@ def _tree_identity(record: ModelInstallationRecord) -> str: def _validate_platform( _manifest: ComfyUIModelManifest, - action: Literal["install_model", "repair_model"] = "install_model", + action: Literal["install_model", "repair_model", "generate"] = "install_model", ) -> None: try: COMPATIBILITY_GUARD(action) @@ -1268,6 +1268,7 @@ def download_progress(current_bytes: int, total_bytes: int) -> None: cancel() progress("installing_workflow", 82, "正在校验固定官方节点工作流", total, total) WORKFLOW_PACK_LOADER() + _validate_platform(manifest, "generate") journal.phase = "publish_prepared" _write_journal(journal) if current: diff --git a/apps/api/src/hcs_api/comfyui_runtime.py b/apps/api/src/hcs_api/comfyui_runtime.py index 42c4e77..015b495 100644 --- a/apps/api/src/hcs_api/comfyui_runtime.py +++ b/apps/api/src/hcs_api/comfyui_runtime.py @@ -2447,6 +2447,7 @@ def run_runtime_install( RUNTIME_VALIDATOR(payload, manifest) _update_journal(journal, "runtime_validated") cancel() + _require_machine_compatibility("start_runtime") _update_journal(journal, "publish_prepared") progress("publishing_runtime", 92, "正在发布受控运行环境", None, None) backup_identity = _optional_managed_directory_identity( @@ -3321,6 +3322,17 @@ def _health_probe(process: ComfyUIRuntimeProcess, manifest: ComfyUIRuntimeManife )[2:] if not isinstance(api_argv, list) or [str(value) for value in api_argv] != expected_without_python: raise ComfyUIRuntimeError("runtime_identity_mismatch", "ComfyUI API arguments do not match the managed process") + devices = system_stats.get("devices") + if ( + not isinstance(devices, list) + or not devices + or not isinstance(devices[0], dict) + or devices[0].get("type") != "mps" + ): + raise ComfyUIRuntimeError( + "runtime_identity_mismatch", + "The fixed Runtime did not report MPS as its primary inference device", + ) object_info = _read_http_json( process.ownership.port, "/object_info", @@ -3521,6 +3533,7 @@ def start_runtime() -> RuntimeHealthSnapshot: supervisor_script_sha256 = sha256_file(supervisor_script) except OSError as exc: raise ComfyUIRuntimeError("runtime_validation_failed", "Managed process supervisor cannot be verified") from exc + _require_machine_compatibility("start_runtime") request_path = _supervisor_request_path(nonce) _atomic_json(request_path, { "argv": runtime_argv, diff --git a/apps/api/src/hcs_api/comfyui_teaching_image.py b/apps/api/src/hcs_api/comfyui_teaching_image.py index a76c04a..c117a56 100644 --- a/apps/api/src/hcs_api/comfyui_teaching_image.py +++ b/apps/api/src/hcs_api/comfyui_teaching_image.py @@ -1065,12 +1065,14 @@ def generate_teaching_image( try: if before_publication is not None: before_publication() + _revalidate_generation_context(plan) _write_new_file(image_path, payload) wrote.append(image_path) _write_new_file(provenance_path, provenance_bytes) wrote.append(provenance_path) if before_publication is not None: before_publication() + _revalidate_generation_context(plan) manifest_path = _real_directory(project_root, ("assets", "data")) / "asset_manifest.json" _write_manifest(manifest_path, manifest) except Exception: diff --git a/apps/api/src/hcs_api/provider_hub.py b/apps/api/src/hcs_api/provider_hub.py index 889e755..0cfb6f0 100644 --- a/apps/api/src/hcs_api/provider_hub.py +++ b/apps/api/src/hcs_api/provider_hub.py @@ -718,10 +718,17 @@ def _comfyui_package_item( generation = generation_capability_snapshot() latest = latest_install_task(_COMFYUI_PACKAGE_ID, recover_interrupted=True) mutating = bool(latest and latest.state in {"queued", "running"}) + generation_ready = ( + machine.status in {"compatible", "compatible_limited"} + and machine.model_compatible + and machine.requirements_identity_verified + and generation.model_compatible + and generation.generation_ready + ) status: HubStatus if mutating: status = "installing" - elif generation.generation_ready: + elif generation_ready: status = "ready" elif machine.status == "incompatible": status = "incompatible" @@ -785,10 +792,10 @@ def _comfyui_package_item( status=status, installed=snapshot.installed, configured=snapshot.installed and model.installed, - ready=generation.generation_ready, + ready=generation_ready, runtime_ready=snapshot.runtime_ready, model_compatible=machine.model_compatible, - generation_ready=generation.generation_ready, + generation_ready=generation_ready, runtime_details=snapshot, model_details=model, generation_details=generation, diff --git a/apps/api/tests/test_comfyui_compatibility.py b/apps/api/tests/test_comfyui_compatibility.py index 5cdcb9f..f4bf245 100644 --- a/apps/api/tests/test_comfyui_compatibility.py +++ b/apps/api/tests/test_comfyui_compatibility.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +from types import SimpleNamespace + import hcs_api.comfyui_compatibility as compatibility import pytest @@ -50,6 +53,44 @@ def test_fixed_contract_binds_runtime_model_workflow_and_disk() -> None: assert contract.cpu_fallback_allowed is False +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("spdisplays_metal4", True), + ("Metal supported", True), + ("Metal not supported", False), + (False, False), + (None, None), + ], +) +def test_mps_probe_requires_positive_system_profiler_evidence( + monkeypatch, value: object, expected: bool | None +) -> None: + monkeypatch.setattr( + compatibility.shutil, + "which", + lambda _name: "/usr/bin/system_profiler", + ) + monkeypatch.setattr( + compatibility.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps( + { + "SPDisplaysDataType": [ + {"spdisplays_mtlgpufamilysupport": value} + ] + } + ), + ), + ) + + available, _evidence = compatibility._probe_mps() + + assert available is expected + + def test_real_validated_macos_arm64_is_compatible_but_limited( monkeypatch, ) -> None: @@ -131,3 +172,59 @@ def backend() -> tuple[bool, str]: assert calls == 1 compatibility.machine_compatibility_snapshot(force_refresh=True) assert calls == 2 + + +def test_fixed_manifest_change_invalidates_unexpired_positive_cache( + monkeypatch, +) -> None: + _supported(monkeypatch) + state = {"identity": "fixed-a", "requirements_match": True} + calls = 0 + + def backend() -> tuple[bool, str]: + nonlocal calls + calls += 1 + return True, "fixture_metal" + + monkeypatch.setattr(compatibility, "INFERENCE_BACKEND_PROBER", backend) + monkeypatch.setattr( + compatibility, + "_fixed_inputs_identity", + lambda: state["identity"], + ) + monkeypatch.setattr( + compatibility, + "_requirements_match", + lambda _requirements: state["requirements_match"], + ) + + assert compatibility.machine_compatibility_snapshot().model_compatible is True + assert compatibility.machine_compatibility_snapshot().model_compatible is True + assert calls == 1 + + state.update(identity="fixed-b", requirements_match=False) + changed = compatibility.machine_compatibility_snapshot() + + assert calls == 2 + assert changed.status == "incompatible" + assert changed.model_compatible is False + assert changed.requirements_identity_verified is False + + +def test_fixed_inputs_changing_during_probe_fail_closed(monkeypatch) -> None: + _supported(monkeypatch) + identities = iter(("before", "after")) + monkeypatch.setattr( + compatibility, + "_fixed_inputs_identity", + lambda: next(identities), + ) + + snapshot = compatibility.machine_compatibility_snapshot(force_refresh=True) + + assert snapshot.status == "incompatible" + assert snapshot.model_compatible is False + assert snapshot.requirements_identity_verified is False + assert "requirements_identity_mismatch" in { + reason.code for reason in snapshot.reasons + } diff --git a/apps/api/tests/test_comfyui_model.py b/apps/api/tests/test_comfyui_model.py index cdcd2d5..174303c 100644 --- a/apps/api/tests/test_comfyui_model.py +++ b/apps/api/tests/test_comfyui_model.py @@ -126,6 +126,34 @@ def blocked(action): assert actions == ["repair_model"] +def test_model_install_rechecks_compatibility_before_publish( + tmp_path, monkeypatch +) -> None: + manifest, _weight, _license = _isolate(tmp_path, monkeypatch) + actions: list[str] = [] + + def validate(_manifest, action="install_model"): + actions.append(action) + if action == "generate": + raise model.ComfyUIModelError( + "machine_incompatible", "fixture changed before publish" + ) + + monkeypatch.setattr(model, "_validate_platform", validate) + + with pytest.raises(model.ComfyUIModelError) as error: + model.run_model_install("blocked-before-publish") + + assert error.value.code == "machine_incompatible" + assert actions == ["install_model", "generate"] + assert not ( + storage.RUNTIME_DIR + / "provider-models/comfyui/checkpoints" + / manifest.source.installed_file_name + ).exists() + assert model.model_snapshot().installed is False + + def test_model_install_repair_uninstall_and_tamper_detection(tmp_path, monkeypatch) -> None: manifest, weight, _license = _isolate(tmp_path, monkeypatch) installed = model.run_model_install("install-fixture") @@ -162,6 +190,13 @@ def test_model_install_repair_uninstall_and_tamper_detection(tmp_path, monkeypat ) model.validate_model_installation(deep=True) + monkeypatch.setattr( + model, + "COMPATIBILITY_GUARD", + lambda _action: (_ for _ in ()).throw( + AssertionError("owned uninstall must bypass machine compatibility") + ), + ) confirmation = model.prepare_model_operation("uninstall") summary = model.consume_model_operation_confirmation( "uninstall", diff --git a/apps/api/tests/test_comfyui_runtime.py b/apps/api/tests/test_comfyui_runtime.py index 9b2e397..a97d556 100644 --- a/apps/api/tests/test_comfyui_runtime.py +++ b/apps/api/tests/test_comfyui_runtime.py @@ -173,6 +173,31 @@ def blocked(action): assert runtime._read_process() is None +def test_runtime_install_rechecks_compatibility_before_publish( + tmp_path: Path, monkeypatch +) -> None: + _isolate(tmp_path, monkeypatch) + manifest, _archive = _install_fakes(tmp_path, monkeypatch) + actions: list[str] = [] + + def compatibility_guard(action: str) -> None: + actions.append(action) + if action == "start_runtime": + raise LocalImageCompatibilityError( + "machine_incompatible", "fixture changed before publish" + ) + + monkeypatch.setattr(runtime, "COMPATIBILITY_GUARD", compatibility_guard) + + with pytest.raises(runtime.ComfyUIRuntimeError) as error: + runtime.run_runtime_install("blocked-before-publish") + + assert error.value.code == "machine_incompatible" + assert actions == ["install_runtime", "start_runtime"] + assert not runtime._version_root(manifest).exists() + assert runtime._read_state().installed is False + + def test_install_uses_durable_journal_atomic_publish_and_runtime_only_boundary(tmp_path: Path, monkeypatch) -> None: _isolate(tmp_path, monkeypatch) manifest, archive = _install_fakes(tmp_path, monkeypatch) @@ -839,7 +864,10 @@ def test_archive_failures_map_to_stable_public_runtime_errors(internal: str, pub class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/system_stats': - body = {'system': {'comfyui_version': '0.28.0', 'argv': sys.argv}} + body = { + 'system': {'comfyui_version': '0.28.0', 'argv': sys.argv}, + 'devices': [{'name': 'mps fixture', 'type': 'mps', 'index': 0}], + } elif self.path == '/object_info': body = {'KSampler': {}, 'CheckpointLoaderSimple': {}, 'SaveImage': {}} else: @@ -918,6 +946,30 @@ def _prepare_fake_runtime(tmp_path: Path, monkeypatch) -> ComfyUIRuntimeManifest return manifest +def test_runtime_start_rechecks_compatibility_immediately_before_spawn( + tmp_path: Path, monkeypatch +) -> None: + _prepare_fake_runtime(tmp_path, monkeypatch) + actions: list[str] = [] + + def compatibility_guard(action: str) -> None: + actions.append(action) + if len(actions) == 2: + raise LocalImageCompatibilityError( + "machine_compatibility_unknown", "fixture changed before spawn" + ) + + monkeypatch.setattr(runtime, "COMPATIBILITY_GUARD", compatibility_guard) + + with pytest.raises(runtime.ComfyUIRuntimeError) as error: + runtime.start_runtime() + + assert error.value.code == "machine_compatibility_unknown" + assert actions == ["start_runtime", "start_runtime"] + assert runtime._read_process() is None + assert runtime._PROCESS_HANDLES == {} + + @pytest.mark.skipif(os.name != "posix", reason="process-group fixture currently targets POSIX CI") def test_supervisor_starts_health_checks_loopback_identity_and_stops(tmp_path: Path, monkeypatch) -> None: manifest = _prepare_fake_runtime(tmp_path, monkeypatch) @@ -952,6 +1004,41 @@ def test_supervisor_starts_health_checks_loopback_identity_and_stops(tmp_path: P assert runtime_data.read_text(encoding="utf-8") == "preserved" +@pytest.mark.skipif(os.name != "posix", reason="process-group fixture currently targets POSIX CI") +def test_live_runtime_health_rejects_cpu_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _prepare_fake_runtime(tmp_path, monkeypatch) + runtime.start_runtime() + read_http_json = runtime._read_http_json + + def report_cpu(port, path, **kwargs): + payload = read_http_json(port, path, **kwargs) + if path == "/system_stats": + payload["devices"] = [ + {"name": "cpu fixture", "type": "cpu", "index": None} + ] + return payload + + monkeypatch.setattr(runtime, "_read_http_json", report_cpu) + try: + health = runtime.check_runtime_health() + + assert health.healthy is False + assert health.status == "repair_required" + assert health.identity_verified is False + assert health.error == { + "code": "runtime_identity_mismatch", + "message": ( + "The fixed Runtime did not report MPS as its primary " + "inference device" + ), + } + assert runtime.runtime_snapshot(recover=False).runtime_ready is False + finally: + runtime.stop_runtime(force=True) + + @pytest.mark.skipif(os.name != "posix", reason="process-group fixture currently targets POSIX CI") def test_supervisor_detects_crash_and_does_not_return_runtime_ready(tmp_path: Path, monkeypatch) -> None: _prepare_fake_runtime(tmp_path, monkeypatch) diff --git a/apps/api/tests/test_comfyui_teaching_image.py b/apps/api/tests/test_comfyui_teaching_image.py index fead0e5..4c88a5a 100644 --- a/apps/api/tests/test_comfyui_teaching_image.py +++ b/apps/api/tests/test_comfyui_teaching_image.py @@ -455,6 +455,59 @@ def finish_after_machine_change(_plan, _port, _maximum): assert not (project / "assets/images").exists() +def test_machine_change_at_final_manifest_boundary_rolls_back_files( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + allowed = {"value": True} + callbacks = 0 + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr( + images, "validate_model_installation", lambda **_kwargs: record + ) + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda _plan, _port, _maximum: ( + _png(), + "123e4567-e89b-12d3-a456-426614174000", + ), + ) + + def compatibility_guard(_action): + if not allowed["value"]: + raise images.LocalImageCompatibilityError( + "machine_incompatible", "fixture changed before manifest publish" + ) + + def before_publication() -> None: + nonlocal callbacks + callbacks += 1 + if callbacks == 3: + allowed["value"] = False + + monkeypatch.setattr(images, "COMPATIBILITY_GUARD", compatibility_guard) + + with pytest.raises(images.TeachingImageError) as error: + images.generate_teaching_image( + project, + _request(), + before_publication=before_publication, + ) + + assert error.value.code == "machine_incompatible" + assert callbacks == 3 + assert not (project / "assets/data/asset_manifest.json").exists() + assert list(project.rglob("*.png")) == [] + assert list(project.rglob("*.provenance.json")) == [] + + def test_invalid_png_and_manifest_failure_leave_no_partial_artifact_then_retry( tmp_path: Path, monkeypatch ) -> None: diff --git a/apps/api/tests/test_provider_hub.py b/apps/api/tests/test_provider_hub.py index 2cd9069..f452e97 100644 --- a/apps/api/tests/test_provider_hub.py +++ b/apps/api/tests/test_provider_hub.py @@ -141,12 +141,11 @@ def _machine_snapshot( disk_requirements=contract.disk, reasons=[ compatibility.CompatibilityReason( - code=( - "mps_probe_failed" - if status == "unknown" - else "experimental_support" - ), - blocking=status == "unknown", + code={ + "unknown": "mps_probe_failed", + "incompatible": "memory_below_minimum", + }.get(status, "experimental_support"), + blocking=status in {"unknown", "incompatible"}, message="fixture compatibility", ) ], @@ -341,6 +340,64 @@ def blocked(_action): assert rechecked.json()["machine_compatibility"]["status"] == "unknown" +@pytest.mark.parametrize("machine_status", ["unknown", "incompatible"]) +def test_incompatible_machine_never_reuses_ready_state_but_keeps_recovery_actions( + tmp_path, monkeypatch, machine_status +) -> None: + _isolate(tmp_path, monkeypatch) + machine = _machine_snapshot(status=machine_status) + runtime_state = {"status": "runtime_ready"} + monkeypatch.setattr( + hub, + "MACHINE_COMPATIBILITY_SNAPSHOT", + lambda **_kwargs: machine, + ) + monkeypatch.setattr( + hub, + "runtime_snapshot", + lambda **_kwargs: _runtime_snapshot( + runtime_state["status"], installed=True + ), + ) + monkeypatch.setattr( + hub, + "model_snapshot", + lambda **_kwargs: _model_snapshot(installed=True, ready=True), + ) + monkeypatch.setattr( + hub, + "generation_capability_snapshot", + lambda **_kwargs: _generation_snapshot( + runtime_ready=True, + model_installed=True, + model_ready=True, + ready=True, + ), + ) + + running = hub._comfyui_package_item(hub.detect_hardware()) + + assert running.ready is False + assert running.generation_ready is False + assert {"stop_runtime", "force_stop_runtime"}.issubset( + running.available_actions + ) + assert "check_generation" not in running.available_actions + + runtime_state["status"] = "stopped" + stopped = hub._comfyui_package_item(hub.detect_hardware()) + + assert {"uninstall_runtime", "uninstall_model"}.issubset( + stopped.available_actions + ) + assert { + "start_runtime", + "repair_runtime", + "repair_model", + "check_generation", + }.isdisjoint(stopped.available_actions) + + def test_comfyui_runtime_install_task_and_failure_are_backend_authoritative(tmp_path, monkeypatch) -> None: client = _isolate(tmp_path, monkeypatch) state = {"status": "not_installed", "installed": False} @@ -467,6 +524,14 @@ def repair(_task_id, *, operation, progress, cancel, confirmation): ) assert _wait_install(client, repaired.json()["task"]["task_id"])["state"] == "completed" + monkeypatch.setattr( + hub, + "COMPATIBILITY_GUARD", + lambda _action: (_ for _ in ()).throw( + AssertionError("owned uninstall must bypass machine compatibility") + ), + ) + def uninstall(_task_id, *, progress, cancel, confirmation): assert confirmation == summaries["uninstall"] cancel() diff --git a/apps/web/src/components/ProviderHubDialog.tsx b/apps/web/src/components/ProviderHubDialog.tsx index 89d493a..907fb10 100644 --- a/apps/web/src/components/ProviderHubDialog.tsx +++ b/apps/web/src/components/ProviderHubDialog.tsx @@ -490,7 +490,7 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => {t("provider.hub.machineCompatibility")} {t(`provider.hub.machineCompatibility.${machine.status}`)}
-

{machine.operating_system} {machine.os_version ?? ""} · {machine.architecture} · {t("provider.hub.machineMemory", { size: machine.total_memory_bytes ? formatBytes(machine.total_memory_bytes) : t("provider.hub.unknownValue") })} · {t("provider.hub.machineDisk", { size: machine.free_disk_bytes ? formatBytes(machine.free_disk_bytes) : t("provider.hub.unknownValue") })}

+

{machine.operating_system} {machine.os_version ?? ""} · {machine.architecture} · {t("provider.hub.machineMemory", { size: machine.total_memory_bytes != null ? formatBytes(machine.total_memory_bytes) : t("provider.hub.unknownValue") })} · {t("provider.hub.machineDisk", { size: machine.free_disk_bytes != null ? formatBytes(machine.free_disk_bytes) : t("provider.hub.unknownValue") })}

{machine.reasons.length > 0 ?
    {machine.reasons.map((reason) => { const key = `provider.hub.compatibilityReason.${reason.code}`; diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index 34ea1bb..1b18ac8 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -676,7 +676,7 @@ const zh: Dict = { "provider.hub.trust.deprecated": "已弃用", "provider.hub.trust.blocked": "已阻止", "provider.hub.compatibility.compatible": "当前设备可以运行", - "provider.hub.compatibility.compatible_but_slow": "可以运行,但可能较慢", + "provider.hub.compatibility.compatible_but_slow": "兼容,但支持范围受限", "provider.hub.compatibility.unsupported": "当前设备不支持", "provider.hub.compatibility.unknown": "无法确认兼容性", "provider.hub.phase.preflight": "正在检查安装条件", diff --git a/docs/comfyui-teaching-image-phase-2c.md b/docs/comfyui-teaching-image-phase-2c.md index db171a7..79ab3a2 100644 --- a/docs/comfyui-teaching-image-phase-2c.md +++ b/docs/comfyui-teaching-image-phase-2c.md @@ -135,8 +135,9 @@ generation_ready = ``` An explicit generation health check additionally verifies the live managed -process, pristine custom-node baseline, all seven required node classes, and -that the fixed checkpoint appears exactly once in +process, MPS as the primary Runtime inference device, pristine custom-node +baseline, all seven required node classes, and that the fixed checkpoint +appears exactly once in `CheckpointLoaderSimple`'s inventory. The process-local result is cached only for the same Runtime port/identity, model identity/timestamp, and workflow digest. A new backend process, Runtime @@ -184,8 +185,12 @@ one IDAT, terminal IEND, maximum 16 MiB, no trailing bytes, and no `tEXt`/`zTXt`/`iTXt` metadata. It records exact bytes and SHA-256. After byte validation and before any project file is written, it revalidates the Runtime installation, process and port, model file installation, and fixed -workflow identities captured in the compiled plan. A restart or identity -change rejects the result. +workflow identities captured in the compiled plan, together with a fresh +machine-compatibility conclusion. It repeats that validation immediately +before writing image/provenance files and again immediately before Asset +Manifest publication. A restart, CPU fallback, compatibility failure, or +identity change rejects the result; already-written candidate files are +removed if the final publication check fails. The result is a `VerifiedImageArtifact` containing: diff --git a/docs/local-basic-image-capability-freeze-v1.md b/docs/local-basic-image-capability-freeze-v1.md index c4308ab..48959dc 100644 --- a/docs/local-basic-image-capability-freeze-v1.md +++ b/docs/local-basic-image-capability-freeze-v1.md @@ -68,7 +68,8 @@ model happens to be installed. | macOS with a known older version | any | any | any | not supported | `incompatible` | | macOS | non-`arm64` | any | any | not supported | `incompatible` | | Windows or Linux | any | any | any | no production implementation | `incompatible` | -| Any row with unknown OS version, memory, disk, backend, or contract identity | any | unknown | unknown | unverifiable | `unknown` | +| Any row with an unreadable OS version, memory, disk, backend, or compatibility contract | any | unknown | unknown | unverifiable | `unknown` | +| Any row with a known Runtime, Model Package, or Workflow Pack manifest mismatch | any | any | any | identity rejected | `incompatible` | There is no silent CPU fallback, substitute model, lower-memory profile, or unverified platform adapter. @@ -139,6 +140,7 @@ facts. Its positive condition is: generation_ready = live_deep_check_requested AND runtime_ready + AND live Runtime primary inference device is MPS AND model_compatible AND model_installed AND exact model installation is ready @@ -154,10 +156,14 @@ unavailable. The backend force-refreshes compatibility: - before Runtime install and repair; +- again after Runtime staging and immediately before owned publication; - before Runtime start; +- again immediately before spawning the managed process; - before model install and repair; +- again after model staging and immediately before owned publication; - at generation deep preflight; -- after provider execution and before an image can be published; +- after provider execution, before project files are written, and immediately + before Asset Manifest publication; - when the teacher explicitly selects **Recheck compatibility**. Provider Hub pre-gates displayed actions, but this is only presentation. The @@ -183,16 +189,51 @@ The probe reads: - the exact Runtime, Model Package, and Workflow Pack manifest SHA-256 values. Normal catalog reads may reuse a process-local result for at most 300 seconds. -An expired entry is re-probed. The explicit recheck and every compatibility- -gated install, repair, start, or generation path bypass the cache. Any -unreadable fact, malformed response, probe failure, manifest change, or -compatibility-contract change invalidates a positive result and fails closed. +An expired entry is re-probed. The four code-owned contract and manifest files +also form a cache-input fingerprint, so changing, removing, or restoring any of +them bypasses an otherwise unexpired entry. Inputs are fingerprinted both +before and after a probe; a change during probing is an identity mismatch and +fails closed. The explicit recheck and every compatibility-gated install, +repair, start, or generation path bypass the cache. Any unreadable fact, +malformed response, probe failure, manifest change, or compatibility-contract +change invalidates a positive result and fails closed. + +The pre-install probe uses positive, bounded `system_profiler` Metal evidence. +Once ComfyUI is running, the pinned `/system_stats` contract must additionally +report `devices[0].type == "mps"`. A missing device list, an ambiguous response, +or `cpu` as the primary device is a Runtime identity failure; it cannot become +`runtime_ready` or `generation_ready`. The current machine probe is not a performance benchmark. A compatible result means only that the fixed capability meets its installation and execution preconditions; it does not predict image quality, latency, or pedagogical fitness. +## Accepted P2 operational boundaries + +The final boundary audit accepts the following limitations without weakening +any backend gate: + +- Volatile catalog facts such as free disk and pre-start Metal evidence may be + displayed for up to the 300-second cache TTL. Every install, repair, start, + generation, and publication boundary force-refreshes before it can mutate or + authorize output; code-owned manifest changes invalidate the cache + immediately. +- Disk evidence is the operating system's free-byte count for the nearest + existing ancestor of the managed Runtime root. It does not predict APFS + purgeable space, external quota changes, or concurrent writers. Staged writes + remain bounded and a failed reserve check or write rolls back without + claiming installation or generation readiness. +- Stop, force-stop, and owned uninstall deliberately bypass machine + compatibility so an unsupported machine can recover. They do not bypass + process, file, root, or destructive-confirmation ownership checks. If the + code-owned Runtime or model manifest itself is unavailable or has the wrong + identity, recovery fails closed rather than stopping an unknown process or + deleting an unowned file; restore the exact reviewed checkout and retry. +- Metal/MPS compatibility and a technically valid artifact are not image + quality evidence. Every result remains `pending_review`; **Local basic image + generation** is never a teaching-quality guarantee. + ### Detector validation — 2026-07-28 The real probe on the development Apple Silicon host reported macOS `26.5.2`,