diff --git a/README.md b/README.md index f5f2dfe..1611e4c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ npm run test:api # 仅后端测试 npm run build:web # 前端构建校验 ``` -当前测试:**498 passed,1 skipped**(后端测试 + 前端构建 + 状态契约)。 +当前 Phase 2C 门禁:**647 passed,3 skipped**(后端)+ 前端构建/状态契约 + **14 passed** Playwright;大型真实生图生命周期为显式 opt-in。 --- @@ -171,6 +171,7 @@ npm run build:web # 前端构建校验 | [架构总览](docs/architecture-overview.md) | 系统设计与组件关系 | | [HanClass Provider Hub](docs/provider-hub.md) | Provider 能力模型、刷新/安装任务、安全边界与扩展指南 | | [受控 ComfyUI Runtime](docs/comfyui-runtime-phase-2b.md) | Phase 2B 固定来源、安全解包、隔离环境、进程监督与无模型边界 | +| [受控 ComfyUI 教学图片](docs/comfyui-teaching-image-phase-2c.md) | Phase 2C 固定模型、官方核心节点 workflow、联合 readiness、真实生成与资产 provenance | | [Codex Provider 桥接](docs/codex-provider-bridge.md) | Codex ChatGPT / Image 的鉴权、任务与验证契约 | | [冒烟测试报告](docs/smoke-test-v0.2.1.md) | v0.2.1-alpha 端到端验证 | | [演示脚本](docs/demo-script.md) | 3–5 分钟快速演示稿 | diff --git a/apps/api/src/hcs_api/comfyui_model.py b/apps/api/src/hcs_api/comfyui_model.py new file mode 100644 index 0000000..c8fe718 --- /dev/null +++ b/apps/api/src/hcs_api/comfyui_model.py @@ -0,0 +1,1473 @@ +"""Fixed Stable Diffusion model package and fixed ComfyUI workflow lifecycle.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import re +import secrets +import shutil +import stat +import threading +import time +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Literal +from urllib.parse import urlparse + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from . import storage +from .comfyui_runtime import ( + ComfyUIRuntimeError, + RuntimeDirectoryIdentity, + _atomic_json, + _download_exact_artifact, + runtime_snapshot, +) + + +MODEL_MANIFEST_SHA256 = "b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658" +WORKFLOW_PACK_SHA256 = "e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e" +MODEL_PACKAGE_ID = "hcs.sd15-teaching-illustration-fp16" +WORKFLOW_PACK_ID = "hcs.teaching-illustration-sd15-core" +MODEL_STATE_FILE = "comfyui_model_state.json" +MODEL_INSTALLATION_FILE = "comfyui_model_installation.json" +MODEL_JOURNAL_FILE = "comfyui_model_journal.json" +MODEL_DOWNLOAD_TIMEOUT_SECONDS = 30 * 60 +MODEL_CONFIRMATION_TTL_SECONDS = 5 * 60 + +ModelStatus = Literal["not_installed", "installing", "model_ready", "repair_required", "failed"] +ModelOperation = Literal["install", "repair", "uninstall"] +ModelJournalPhase = Literal[ + "prepared", + "downloading", + "verified", + "publish_prepared", + "model_published", + "state_committed", + "uninstalling", + "completed", + "rolling_back", + "rolled_back", + "failed", +] +ProgressCallback = Callable[[str, int, str, int | None, int | None], None] +CancellationCheck = Callable[[], None] + + +class ComfyUIModelError(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 ModelSource(_StrictModel): + repository_url: str + revision: str = Field(pattern=r"^[0-9a-f]{40}$") + file_url: str + file_name: Literal["v1-5-pruned-emaonly-fp16.safetensors"] + installed_file_name: Literal["hcs-sd-v1-5-pruned-emaonly-fp16.safetensors"] + size: int = Field(gt=0) + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + xet_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + uploaded_at: str + allowed_redirect_hosts: list[str] = Field(min_length=1, max_length=8) + + @model_validator(mode="after") + def _fixed_hugging_face_source(self) -> "ModelSource": + repository = urlparse(self.repository_url) + artifact = urlparse(self.file_url) + expected_path = ( + f"/Comfy-Org/stable-diffusion-v1-5-archive/resolve/{self.revision}/{self.file_name}" + ) + if ( + repository.scheme != "https" + or repository.netloc != "huggingface.co" + or repository.path != "/Comfy-Org/stable-diffusion-v1-5-archive" + or repository.query + or repository.fragment + or artifact.scheme != "https" + or artifact.netloc != "huggingface.co" + or artifact.path != expected_path + or artifact.query + or artifact.fragment + ): + raise ValueError("model source must remain the exact commit-pinned Comfy Org artifact") + approved = { + "us.aws.cdn.hf.co", + "cdn-lfs.huggingface.co", + "cdn-lfs-us-1.huggingface.co", + "cas-bridge.xethub.hf.co", + } + if set(self.allowed_redirect_hosts) - approved: + raise ValueError("model redirects must remain in the reviewed Hugging Face origins") + return self + + +class ModelLicense(_StrictModel): + spdx: Literal["LicenseRef-CreativeML-OpenRAIL-M"] + name: Literal["CreativeML Open RAIL-M"] + source_revision: str = Field(pattern=r"^[0-9a-f]{40}$") + url: str + text_url: str + installed_file_name: Literal["CreativeML-OpenRAIL-M.txt"] + size: int = Field(gt=0) + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + redistribution_review: Literal["approved_with_attribution_notice_and_use_restrictions"] + + @model_validator(mode="after") + def _fixed_license_source(self) -> "ModelLicense": + expected_base = ( + "https://huggingface.co/spaces/CompVis/stable-diffusion-license/" + ) + if self.url != f"{expected_base}blob/{self.source_revision}/license.txt": + raise ValueError("model license page must remain commit-pinned") + if self.text_url != f"{expected_base}raw/{self.source_revision}/license.txt": + raise ValueError("model license text must remain commit-pinned") + return self + + +class SafetensorsPolicy(_StrictModel): + header_size: int = Field(gt=0, le=1024 * 1024) + tensor_count: int = Field(gt=0, le=10_000) + allowed_dtypes: list[Literal["F16"]] + architecture: Literal["stable-diffusion-v1"] + resolution: Literal["512x512"] + format: Literal["pt"] + + +class ModelRuntimeContract(_StrictModel): + runtime_id: Literal["comfyui"] + version: Literal["0.28.0"] + source_commit: Literal["700821e1364eaab0e8f21c538a2131719fec57bf"] + checkpoint_directory: Literal["checkpoints"] + + +class ModelPlatform(_StrictModel): + operating_system: Literal["macos"] + architecture: Literal["arm64"] + minimum_os_version: Literal["14.0"] + support: Literal["experimental"] + install_enabled: Literal[True] + minimum_memory_mb: int = Field(ge=16_384) + minimum_free_disk_bytes: int = Field(ge=5 * 1024**3) + + +class ComfyUIModelManifest(_StrictModel): + schema_: Literal["hanclassstudio.comfyui_model_package.v1"] = Field(alias="schema") + package_id: Literal["hcs.sd15-teaching-illustration-fp16"] + model_id: Literal["stable-diffusion-v1-5"] + name: str + version: Literal["1.5-fp16-emaonly"] + publisher: str + source: ModelSource + license: ModelLicense + safetensors: SafetensorsPolicy + runtime: ModelRuntimeContract + platforms: list[ModelPlatform] = Field(min_length=1, max_length=1) + capabilities: list[ + Literal["teaching_illustration", "vocabulary_image", "classroom_scene"] + ] = Field(min_length=3, max_length=3) + + +class WorkflowRuntimeContract(_StrictModel): + runtime_id: Literal["comfyui"] + version: Literal["0.28.0"] + source_commit: Literal["700821e1364eaab0e8f21c538a2131719fec57bf"] + + +class WorkflowNode(_StrictModel): + id: Literal["checkpoint", "positive", "negative", "latent", "sampler", "decode", "save"] + class_type: Literal[ + "CheckpointLoaderSimple", + "CLIPTextEncode", + "EmptyLatentImage", + "KSampler", + "VAEDecode", + "SaveImage", + ] + + +class WorkflowSampling(_StrictModel): + steps: Literal[20] + cfg: Literal[7.0] + sampler_name: Literal["euler"] + scheduler: Literal["normal"] + denoise: Literal[1.0] + batch_size: Literal[1] + + +class WorkflowPromptProfile(_StrictModel): + id: Literal["soft-flat-educational-v1"] + positive_prefix: str + positive_suffix: str + negative: str + + +class WorkflowOutput(_StrictModel): + mime_type: Literal["image/png"] + maximum_bytes: int = Field(gt=0, le=32 * 1024**2) + reject_text_metadata: Literal[True] + + +class ComfyUIWorkflowPack(_StrictModel): + schema_: Literal["hanclassstudio.comfyui_workflow_pack.v1"] = Field(alias="schema") + pack_id: Literal["hcs.teaching-illustration-sd15-core"] + name: str + version: Literal["1.0.0"] + runtime: WorkflowRuntimeContract + model_package_id: Literal["hcs.sd15-teaching-illustration-fp16"] + capabilities: list[ + Literal["teaching_illustration", "vocabulary_image", "classroom_scene"] + ] = Field(min_length=3, max_length=3) + nodes: list[WorkflowNode] = Field(min_length=7, max_length=7) + sampling: WorkflowSampling + dimensions: dict[Literal["1:1", "4:3", "16:9"], tuple[int, int]] + prompt_profile: WorkflowPromptProfile + output: WorkflowOutput + + @model_validator(mode="after") + def _fixed_core_graph_contract(self) -> "ComfyUIWorkflowPack": + expected = [ + ("checkpoint", "CheckpointLoaderSimple"), + ("positive", "CLIPTextEncode"), + ("negative", "CLIPTextEncode"), + ("latent", "EmptyLatentImage"), + ("sampler", "KSampler"), + ("decode", "VAEDecode"), + ("save", "SaveImage"), + ] + if [(node.id, node.class_type) for node in self.nodes] != expected: + raise ValueError("workflow node order and core-node identities are fixed") + if self.dimensions != { + "1:1": (512, 512), + "4:3": (512, 384), + "16:9": (512, 288), + }: + raise ValueError("workflow dimensions are fixed") + if any(value % 8 for pair in self.dimensions.values() for value in pair): + raise ValueError("workflow dimensions must be latent-grid aligned") + return self + + +class ModelFileIdentity(_StrictModel): + device: int = Field(ge=0) + inode: int = Field(gt=0) + size: int = Field(ge=0) + mtime_ns: int = Field(ge=0) + ctime_ns: int = Field(ge=0) + + +class ModelInstallationRecord(_StrictModel): + schema_: Literal["hanclassstudio.comfyui_model_installation.v1"] = Field( + default="hanclassstudio.comfyui_model_installation.v1", alias="schema" + ) + package_id: Literal["hcs.sd15-teaching-illustration-fp16"] = MODEL_PACKAGE_ID + version: Literal["1.5-fp16-emaonly"] = "1.5-fp16-emaonly" + manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + model_relative_path: Literal[ + "checkpoints/hcs-sd-v1-5-pruned-emaonly-fp16.safetensors" + ] + model_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + model_size: int = Field(gt=0) + license_relative_path: Literal["licenses/CreativeML-OpenRAIL-M.txt"] + license_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + workflow_pack_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + parent_directory_identity: RuntimeDirectoryIdentity + root_identity: RuntimeDirectoryIdentity + model_identity: ModelFileIdentity + license_identity: ModelFileIdentity + installed_at: str + + +class ModelStateRecord(_StrictModel): + schema_: Literal["hanclassstudio.comfyui_model_state.v1"] = Field( + default="hanclassstudio.comfyui_model_state.v1", alias="schema" + ) + installed: bool = False + status: ModelStatus = "not_installed" + package_id: str | None = None + version: str | None = None + installation_identity: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") + installed_at: str | None = None + checked_at: str | None = None + error: dict[str, str] | None = None + + +class ModelInstallJournal(_StrictModel): + schema_: Literal["hanclassstudio.comfyui_model_journal.v1"] = Field( + default="hanclassstudio.comfyui_model_journal.v1", alias="schema" + ) + transaction_id: str = Field(pattern=r"^[0-9a-f]{32}$") + task_id: str + operation: ModelOperation + manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + phase: ModelJournalPhase = "prepared" + staging_model_relative_path: str + staging_license_relative_path: str + backup_model_relative_path: str + backup_license_relative_path: str + staged_model_identity: ModelFileIdentity | None = None + staged_license_identity: ModelFileIdentity | None = None + previous_installation: dict[str, Any] | None = None + created_at: str + updated_at: str + error_code: str | None = None + + @field_validator( + "staging_model_relative_path", + "staging_license_relative_path", + "backup_model_relative_path", + "backup_license_relative_path", + ) + @classmethod + def _relative_model_path(cls, value: str) -> str: + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise ValueError("model journal paths must remain relative") + return path.as_posix() + + +class ModelOperationSummary(_StrictModel): + operation: Literal["repair", "uninstall"] + package_id: Literal["hcs.sd15-teaching-illustration-fp16"] = MODEL_PACKAGE_ID + version: Literal["1.5-fp16-emaonly"] = "1.5-fp16-emaonly" + installation_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + model_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + tree_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + replaces_model_files: bool + preserves_runtime: Literal[True] = True + preserves_projects: Literal[True] = True + preserves_other_models: Literal[True] = True + + +class ModelOperationConfirmation(_StrictModel): + summary: ModelOperationSummary + confirmation_token: str = Field(pattern=r"^[0-9a-f]{64}$") + expires_at: str + + +class _ModelConfirmationRecord(_StrictModel): + token_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + summary: ModelOperationSummary + expires_at_epoch: float + + +class ModelPackageSnapshot(_StrictModel): + package_id: Literal["hcs.sd15-teaching-illustration-fp16"] = MODEL_PACKAGE_ID + name: str + version: str + status: ModelStatus + installed: bool + model_ready: bool + workflow_pack_id: Literal["hcs.teaching-illustration-sd15-core"] = WORKFLOW_PACK_ID + workflow_version: str = "1.0.0" + workflow_ready: bool + model_size: int + model_sha256: str + model_source_revision: str + model_license: str + estimated_download_bytes: int + checked_at: str | None = None + technical_error: dict[str, str] | None = None + + +_MODEL_MUTATION_LOCK = threading.RLock() +_CONFIRMATION_LOCK = threading.RLock() +_CONFIRMATIONS: dict[str, _ModelConfirmationRecord] = {} + + +def _iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def model_manifest_path() -> Path: + return storage.ROOT_DIR / "providers" / "comfyui" / "model-package-sd15-fp16.v1.json" + + +def workflow_pack_path() -> Path: + return ( + storage.ROOT_DIR + / "providers" + / "comfyui" + / "workflows" + / "teaching-illustration-sd15-core.v1.json" + ) + + +def _load_json_contract(path: Path, expected_sha256: str, maximum_bytes: int) -> dict[str, Any]: + try: + payload = path.read_bytes() + except OSError as exc: + raise ComfyUIModelError("model_manifest_invalid", "A fixed model contract is unavailable") from exc + if len(payload) > maximum_bytes or hashlib.sha256(payload).hexdigest() != expected_sha256: + raise ComfyUIModelError("model_manifest_invalid", "A fixed model contract identity changed") + try: + value = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ComfyUIModelError("model_manifest_invalid", "A fixed model contract is invalid") from exc + if not isinstance(value, dict): + raise ComfyUIModelError("model_manifest_invalid", "A fixed model contract must be an object") + return value + + +def load_model_manifest() -> ComfyUIModelManifest: + try: + return ComfyUIModelManifest.model_validate( + _load_json_contract(model_manifest_path(), MODEL_MANIFEST_SHA256, 64 * 1024) + ) + except ValueError as exc: + raise ComfyUIModelError("model_manifest_invalid", "The fixed model package is invalid") from exc + + +def load_workflow_pack() -> ComfyUIWorkflowPack: + try: + return ComfyUIWorkflowPack.model_validate( + _load_json_contract(workflow_pack_path(), WORKFLOW_PACK_SHA256, 64 * 1024) + ) + except ValueError as exc: + raise ComfyUIModelError("workflow_pack_invalid", "The fixed workflow pack is invalid") from exc + + +MODEL_MANIFEST_LOADER: Callable[[], ComfyUIModelManifest] = load_model_manifest +WORKFLOW_PACK_LOADER: Callable[[], ComfyUIWorkflowPack] = load_workflow_pack +RUNTIME_SNAPSHOT = runtime_snapshot +DISK_USAGE = shutil.disk_usage + + +def _model_root() -> Path: + return storage.RUNTIME_DIR / "provider-models" / "comfyui" + + +def _model_path(manifest: ComfyUIModelManifest) -> Path: + return _model_root() / manifest.runtime.checkpoint_directory / manifest.source.installed_file_name + + +def _license_path(manifest: ComfyUIModelManifest) -> Path: + return _model_root() / "licenses" / manifest.license.installed_file_name + + +def _config_path(name: str) -> Path: + return storage.CONFIG_DIR / name + + +def _assert_real_directory(path: Path, *, create: bool = False) -> RuntimeDirectoryIdentity: + if create: + path.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + info = path.lstat() + except OSError as exc: + raise ComfyUIModelError("model_identity_mismatch", "A managed model directory is unavailable") from exc + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise ComfyUIModelError("model_identity_mismatch", "A managed model directory identity changed") + return RuntimeDirectoryIdentity(device=info.st_dev, inode=info.st_ino) + + +def _ensure_model_directories(manifest: ComfyUIModelManifest) -> RuntimeDirectoryIdentity: + _assert_real_directory(storage.RUNTIME_DIR, create=True) + _assert_real_directory(storage.RUNTIME_DIR / "provider-models", create=True) + root = _model_root() + root_identity = _assert_real_directory(root, create=True) + _assert_real_directory(_model_path(manifest).parent, create=True) + _assert_real_directory(_license_path(manifest).parent, create=True) + return root_identity + + +def _file_identity(path: Path) -> ModelFileIdentity: + try: + info = path.lstat() + except OSError as exc: + raise ComfyUIModelError("model_identity_mismatch", "A managed model file is unavailable") from exc + if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise ComfyUIModelError("model_identity_mismatch", "A managed model file is not a regular file") + return ModelFileIdentity( + device=info.st_dev, + inode=info.st_ino, + size=info.st_size, + mtime_ns=info.st_mtime_ns, + ctime_ns=info.st_ctime_ns, + ) + + +def _same_file(actual: ModelFileIdentity, expected: ModelFileIdentity) -> bool: + return actual == expected + + +def _same_owned_inode(actual: ModelFileIdentity, expected: ModelFileIdentity) -> bool: + return (actual.device, actual.inode) == (expected.device, expected.inode) + + +def _sha256_regular_file( + path: Path, + *, + expected_identity: ModelFileIdentity | None = None, + maximum_bytes: int, +) -> tuple[str, ModelFileIdentity]: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + raise ComfyUIModelError("model_identity_mismatch", "A managed model file could not be opened safely") from exc + digest = hashlib.sha256() + try: + before = os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_size > maximum_bytes: + raise ComfyUIModelError("unsafe_model_artifact", "A managed model artifact is invalid") + identity = ModelFileIdentity( + device=before.st_dev, + inode=before.st_ino, + size=before.st_size, + mtime_ns=before.st_mtime_ns, + ctime_ns=before.st_ctime_ns, + ) + if expected_identity is not None and not _same_file(identity, expected_identity): + raise ComfyUIModelError("model_identity_mismatch", "A managed model file identity changed") + total = 0 + while True: + chunk = os.read(fd, 1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > maximum_bytes: + raise ComfyUIModelError("unsafe_model_artifact", "A managed model artifact is too large") + digest.update(chunk) + after = os.fstat(fd) + if ( + before.st_dev, + before.st_ino, + before.st_size, + before.st_mtime_ns, + before.st_ctime_ns, + ) != ( + after.st_dev, + after.st_ino, + after.st_size, + after.st_mtime_ns, + after.st_ctime_ns, + ): + raise ComfyUIModelError("model_identity_mismatch", "A managed model file changed during validation") + return digest.hexdigest(), identity + finally: + os.close(fd) + + +_DTYPE_BYTES = {"F16": 2} + + +def inspect_safetensors(path: Path, manifest: ComfyUIModelManifest) -> None: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors artifact could not be opened") from exc + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size != manifest.source.size: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors artifact size is invalid") + prefix = os.read(fd, 8) + if len(prefix) != 8: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors header is truncated") + header_size = int.from_bytes(prefix, "little", signed=False) + if header_size != manifest.safetensors.header_size: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors header identity changed") + header = bytearray() + while len(header) < header_size: + chunk = os.read(fd, min(64 * 1024, header_size - len(header))) + if not chunk: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors header is truncated") + header.extend(chunk) + try: + decoded = json.loads(header) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors header is invalid JSON") from exc + if not isinstance(decoded, dict): + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors header must be an object") + metadata = decoded.pop("__metadata__", None) + if not isinstance(metadata, dict): + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors metadata is missing") + required_metadata = { + "modelspec.architecture": manifest.safetensors.architecture, + "modelspec.resolution": manifest.safetensors.resolution, + "modelspec.license": manifest.license.name, + "format": manifest.safetensors.format, + } + if any(metadata.get(key) != value for key, value in required_metadata.items()): + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors model metadata changed") + if len(decoded) != manifest.safetensors.tensor_count: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor inventory changed") + data_size = info.st_size - 8 - header_size + ranges: list[tuple[int, int]] = [] + for name, tensor in decoded.items(): + if ( + not isinstance(name, str) + or not name + or len(name) > 512 + or not isinstance(tensor, dict) + or set(tensor) != {"dtype", "shape", "data_offsets"} + or tensor.get("dtype") not in manifest.safetensors.allowed_dtypes + ): + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor metadata is invalid") + shape = tensor.get("shape") + offsets = tensor.get("data_offsets") + if ( + not isinstance(shape, list) + or any(not isinstance(value, int) or value < 0 for value in shape) + or not isinstance(offsets, list) + or len(offsets) != 2 + or any(not isinstance(value, int) for value in offsets) + ): + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor shape is invalid") + start, end = offsets + if start < 0 or end < start or end > data_size: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor offsets are invalid") + elements = 1 + for value in shape: + elements *= value + if elements > data_size: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor shape is too large") + if elements * _DTYPE_BYTES[tensor["dtype"]] != end - start: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor byte length is invalid") + ranges.append((start, end)) + ranges.sort() + cursor = 0 + for start, end in ranges: + if start != cursor: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors tensor data is not contiguous") + cursor = end + if cursor != data_size: + raise ComfyUIModelError("unsafe_model_artifact", "SafeTensors data length is inconsistent") + finally: + os.close(fd) + + +def _read_json_model(path: Path, model: type[_StrictModel]) -> Any: + try: + raw = path.read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + raise ComfyUIModelError("model_state_invalid", "Managed model state could not be read") from exc + if len(raw) > 256 * 1024: + raise ComfyUIModelError("model_state_invalid", "Managed model state is too large") + try: + return model.model_validate_json(raw) + except ValueError as exc: + raise ComfyUIModelError("model_state_invalid", "Managed model state is invalid") from exc + + +def _read_state() -> ModelStateRecord: + return _read_json_model(_config_path(MODEL_STATE_FILE), ModelStateRecord) or ModelStateRecord() + + +def _write_state(state: ModelStateRecord) -> None: + _atomic_json(_config_path(MODEL_STATE_FILE), state.model_dump(mode="json", by_alias=True)) + + +def _read_installation() -> ModelInstallationRecord | None: + return _read_json_model(_config_path(MODEL_INSTALLATION_FILE), ModelInstallationRecord) + + +def _write_installation(record: ModelInstallationRecord) -> None: + _atomic_json( + _config_path(MODEL_INSTALLATION_FILE), + record.model_dump(mode="json", by_alias=True), + ) + + +def _read_journal() -> ModelInstallJournal | None: + return _read_json_model(_config_path(MODEL_JOURNAL_FILE), ModelInstallJournal) + + +def _write_journal(journal: ModelInstallJournal) -> None: + journal.updated_at = _iso() + _atomic_json(_config_path(MODEL_JOURNAL_FILE), journal.model_dump(mode="json", by_alias=True)) + + +def _installation_identity(record: ModelInstallationRecord) -> str: + payload = json.dumps( + record.model_dump(mode="json", by_alias=True), + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() + + +def model_installation_identity(record: ModelInstallationRecord | None = None) -> str: + """Return the identity of a verified fixed model installation.""" + return _installation_identity(record or validate_model_installation(deep=True)) + + +def _tree_identity(record: ModelInstallationRecord) -> str: + payload = json.dumps( + { + "parent": record.parent_directory_identity.model_dump(mode="json"), + "root": record.root_identity.model_dump(mode="json"), + "model": record.model_identity.model_dump(mode="json"), + "license": record.license_identity.model_dump(mode="json"), + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + 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") + 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") + + +def _assert_runtime_stopped(manifest: ComfyUIModelManifest, *, require_installed: bool) -> None: + try: + snapshot = RUNTIME_SNAPSHOT() + except ComfyUIRuntimeError as exc: + raise ComfyUIModelError(exc.code, exc.message) from exc + if require_installed and not snapshot.installed: + raise ComfyUIModelError("runtime_not_installed", "Install the fixed ComfyUI Runtime first") + if snapshot.version != manifest.runtime.version or snapshot.source_commit != manifest.runtime.source_commit: + raise ComfyUIModelError("runtime_identity_mismatch", "The installed Runtime identity is not approved by the model package") + if snapshot.actual_port is not None or snapshot.status in {"starting", "runtime_ready", "stopping"}: + raise ComfyUIModelError("runtime_must_be_stopped", "Stop the managed Runtime before changing its fixed model package") + + +def validate_model_installation( + manifest: ComfyUIModelManifest | None = None, + *, + deep: bool = True, +) -> ModelInstallationRecord: + manifest = manifest or MODEL_MANIFEST_LOADER() + workflow = WORKFLOW_PACK_LOADER() + record = _read_installation() + if record is None: + raise ComfyUIModelError("model_not_installed", "The fixed teaching image model is not installed") + root_identity = _assert_real_directory(_model_root()) + parent_identity = _assert_real_directory(storage.RUNTIME_DIR / "provider-models") + if ( + root_identity != record.root_identity + or parent_identity != record.parent_directory_identity + ): + raise ComfyUIModelError("model_identity_mismatch", "The managed model root identity changed") + if ( + record.manifest_sha256 != MODEL_MANIFEST_SHA256 + or record.model_sha256 != manifest.source.sha256 + or record.model_size != manifest.source.size + or record.license_sha256 != manifest.license.sha256 + or record.workflow_pack_sha256 != WORKFLOW_PACK_SHA256 + or workflow.model_package_id != manifest.package_id + ): + raise ComfyUIModelError("model_identity_mismatch", "The model installation record does not match the fixed contracts") + model_identity = _file_identity(_model_path(manifest)) + license_identity = _file_identity(_license_path(manifest)) + if ( + not _same_file(model_identity, record.model_identity) + or not _same_file(license_identity, record.license_identity) + or model_identity.size != manifest.source.size + or license_identity.size != manifest.license.size + ): + raise ComfyUIModelError("model_identity_mismatch", "A managed model package file changed") + if deep: + model_sha, _ = _sha256_regular_file( + _model_path(manifest), + expected_identity=record.model_identity, + maximum_bytes=manifest.source.size, + ) + license_sha, _ = _sha256_regular_file( + _license_path(manifest), + expected_identity=record.license_identity, + maximum_bytes=manifest.license.size, + ) + if model_sha != manifest.source.sha256 or license_sha != manifest.license.sha256: + raise ComfyUIModelError("model_checksum_mismatch", "A managed model package checksum changed") + inspect_safetensors(_model_path(manifest), manifest) + return record + + +def _download_model_artifacts( + model_destination: Path, + license_destination: Path, + manifest: ComfyUIModelManifest, + progress: Callable[[int, int], None], + cancel: CancellationCheck, +) -> None: + total = manifest.source.size + manifest.license.size + _download_exact_artifact( + model_destination, + source_url=manifest.source.file_url, + expected_size=manifest.source.size, + expected_sha256=manifest.source.sha256, + allowed_redirect_hosts=frozenset(manifest.source.allowed_redirect_hosts), + progress=lambda current, _size: progress(current, total), + cancel=cancel, + size_error_code="model_size_mismatch", + checksum_error_code="model_checksum_mismatch", + max_download_seconds=MODEL_DOWNLOAD_TIMEOUT_SECONDS, + ) + _download_exact_artifact( + license_destination, + source_url=manifest.license.text_url, + expected_size=manifest.license.size, + expected_sha256=manifest.license.sha256, + allowed_redirect_hosts=frozenset(), + progress=lambda current, _size: progress(manifest.source.size + current, total), + cancel=cancel, + size_error_code="license_size_mismatch", + checksum_error_code="license_checksum_mismatch", + max_download_seconds=60, + ) + + +MODEL_DOWNLOADER = _download_model_artifacts + + +def _journal_path(relative: str) -> Path: + path = (_model_root() / PurePosixPath(relative)).resolve(strict=False) + try: + path.relative_to(_model_root().resolve()) + except ValueError as exc: + raise ComfyUIModelError("model_identity_mismatch", "A model journal path escaped its root") from exc + return path + + +def _unlink_if_owned(path: Path, identity: ModelFileIdentity | None) -> None: + if identity is None or not path.exists(): + return + if not _same_owned_inode(_file_identity(path), identity): + raise ComfyUIModelError("model_identity_mismatch", "Refusing to remove a model file with changed ownership") + path.unlink() + + +def _restore_previous(journal: ModelInstallJournal, manifest: ComfyUIModelManifest) -> None: + previous = ( + ModelInstallationRecord.model_validate(journal.previous_installation) + if journal.previous_installation + else None + ) + model = _model_path(manifest) + license_file = _license_path(manifest) + staged_model = journal.staged_model_identity + staged_license = journal.staged_license_identity + if model.exists() and staged_model and _same_owned_inode(_file_identity(model), staged_model): + model.unlink() + if ( + license_file.exists() + and staged_license + and _same_owned_inode(_file_identity(license_file), staged_license) + ): + license_file.unlink() + backup_model = _journal_path(journal.backup_model_relative_path) + backup_license = _journal_path(journal.backup_license_relative_path) + if previous: + if backup_model.exists(): + if not _same_owned_inode( + _file_identity(backup_model), previous.model_identity + ): + raise ComfyUIModelError("model_identity_mismatch", "The retained model backup identity changed") + os.replace(backup_model, model) + if backup_license.exists(): + if not _same_owned_inode( + _file_identity(backup_license), previous.license_identity + ): + raise ComfyUIModelError("model_identity_mismatch", "The retained license backup identity changed") + os.replace(backup_license, license_file) + previous = previous.model_copy( + update={ + "model_identity": _file_identity(model), + "license_identity": _file_identity(license_file), + } + ) + _write_installation(previous) + try: + validate_model_installation(manifest, deep=True) + restored_status: ModelStatus = "model_ready" + restored_error = None + except ComfyUIModelError as exc: + restored_status = "repair_required" + restored_error = {"code": exc.code, "message": exc.message} + _write_state( + ModelStateRecord( + installed=True, + status=restored_status, + package_id=previous.package_id, + version=previous.version, + installation_identity=_installation_identity(previous), + installed_at=previous.installed_at, + checked_at=_iso(), + error=restored_error, + ) + ) + else: + _config_path(MODEL_INSTALLATION_FILE).unlink(missing_ok=True) + _write_state(ModelStateRecord()) + + +def _cleanup_journal_files(journal: ModelInstallJournal) -> None: + _unlink_if_owned(_journal_path(journal.staging_model_relative_path), journal.staged_model_identity) + _unlink_if_owned(_journal_path(journal.staging_license_relative_path), journal.staged_license_identity) + previous = ( + ModelInstallationRecord.model_validate(journal.previous_installation) + if journal.previous_installation + else None + ) + _unlink_if_owned( + _journal_path(journal.backup_model_relative_path), + previous.model_identity if previous else None, + ) + _unlink_if_owned( + _journal_path(journal.backup_license_relative_path), + previous.license_identity if previous else None, + ) + + +def _capture_staging_identities(journal: ModelInstallJournal) -> None: + """Bind partial downloads to the journal before an owned rollback removes them.""" + for path, field in ( + (_journal_path(journal.staging_model_relative_path), "staged_model_identity"), + (_journal_path(journal.staging_license_relative_path), "staged_license_identity"), + ): + if getattr(journal, field) is None and path.exists(): + setattr(journal, field, _file_identity(path)) + + +def recover_model_installations() -> list[str]: + with _MODEL_MUTATION_LOCK: + journal = _read_journal() + if journal is None: + return [] + manifest = MODEL_MANIFEST_LOADER() + if journal.manifest_sha256 != MODEL_MANIFEST_SHA256: + raise ComfyUIModelError("model_identity_mismatch", "An interrupted model transaction has an unknown contract") + if journal.phase == "completed": + _cleanup_journal_files(journal) + return ["completed"] + if journal.operation == "uninstall" and journal.phase == "uninstalling": + previous = ModelInstallationRecord.model_validate(journal.previous_installation) + for path, identity in ( + (_model_path(manifest), previous.model_identity), + (_license_path(manifest), previous.license_identity), + ): + if path.exists(): + _unlink_if_owned(path, identity) + _config_path(MODEL_INSTALLATION_FILE).unlink(missing_ok=True) + _write_state(ModelStateRecord()) + journal.phase = "completed" + _write_journal(journal) + return ["uninstall_completed"] + if journal.phase in {"model_published", "state_committed"}: + try: + model_identity = _file_identity(_model_path(manifest)) + license_identity = _file_identity(_license_path(manifest)) + if ( + journal.staged_model_identity is None + or journal.staged_license_identity is None + or not _same_owned_inode( + model_identity, journal.staged_model_identity + ) + or not _same_owned_inode( + license_identity, journal.staged_license_identity + ) + ): + raise ComfyUIModelError("model_identity_mismatch", "Published model identity is incomplete") + model_sha, _ = _sha256_regular_file( + _model_path(manifest), + expected_identity=model_identity, + maximum_bytes=manifest.source.size, + ) + license_sha, _ = _sha256_regular_file( + _license_path(manifest), + expected_identity=license_identity, + maximum_bytes=manifest.license.size, + ) + if model_sha != manifest.source.sha256 or license_sha != manifest.license.sha256: + raise ComfyUIModelError("model_checksum_mismatch", "Published model checksum is invalid") + inspect_safetensors(_model_path(manifest), manifest) + record = ModelInstallationRecord( + manifest_sha256=MODEL_MANIFEST_SHA256, + model_relative_path=f"checkpoints/{manifest.source.installed_file_name}", + model_sha256=manifest.source.sha256, + model_size=manifest.source.size, + license_relative_path=f"licenses/{manifest.license.installed_file_name}", + license_sha256=manifest.license.sha256, + workflow_pack_sha256=WORKFLOW_PACK_SHA256, + parent_directory_identity=_assert_real_directory( + storage.RUNTIME_DIR / "provider-models" + ), + root_identity=_assert_real_directory(_model_root()), + model_identity=model_identity, + license_identity=license_identity, + installed_at=journal.created_at, + ) + _write_installation(record) + _write_state( + ModelStateRecord( + installed=True, + status="model_ready", + package_id=record.package_id, + version=record.version, + installation_identity=_installation_identity(record), + installed_at=record.installed_at, + checked_at=_iso(), + ) + ) + _cleanup_journal_files(journal) + journal.phase = "completed" + _write_journal(journal) + return ["published_state_committed"] + except ComfyUIModelError: + pass + journal.phase = "rolling_back" + _write_journal(journal) + _restore_previous(journal, manifest) + _cleanup_journal_files(journal) + journal.phase = "rolled_back" + _write_journal(journal) + return ["rolled_back"] + + +def _default_progress( + _phase: str, + _percent: int, + _message: str, + _current: int | None, + _total: int | None, +) -> None: + return + + +def _default_cancel() -> None: + return + + +def _new_journal( + task_id: str, + operation: ModelOperation, + previous: ModelInstallationRecord | None, +) -> ModelInstallJournal: + transaction_id = secrets.token_hex(16) + prefix = f".hcs-{transaction_id}" + now = _iso() + return ModelInstallJournal( + transaction_id=transaction_id, + task_id=task_id, + operation=operation, + manifest_sha256=MODEL_MANIFEST_SHA256, + staging_model_relative_path=f"{prefix}.model.download", + staging_license_relative_path=f"{prefix}.license.download", + backup_model_relative_path=f"{prefix}.model.backup", + backup_license_relative_path=f"{prefix}.license.backup", + previous_installation=( + previous.model_dump(mode="json", by_alias=True) if previous else None + ), + created_at=now, + updated_at=now, + ) + + +def assert_model_operation_identity(summary: ModelOperationSummary) -> ModelInstallationRecord: + record = _model_operation_record() + if ( + _installation_identity(record) != summary.installation_identity + or _tree_identity(record) != summary.tree_identity + or record.model_sha256 != summary.model_sha256 + ): + raise ComfyUIModelError("confirmation_stale", "The managed model identity changed after confirmation") + return record + + +def _model_operation_record( + manifest: ComfyUIModelManifest | None = None, +) -> ModelInstallationRecord: + """Bind a destructive operation to real files in the unchanged owned root. + + Repair must remain possible after checksum or mtime damage, so this check + validates the installation contract, root ownership, and current regular + file identities without claiming that the payload is generation-ready. + """ + manifest = manifest or MODEL_MANIFEST_LOADER() + workflow = WORKFLOW_PACK_LOADER() + record = _read_installation() + if record is None: + raise ComfyUIModelError( + "model_not_installed", "The fixed teaching image model is not installed" + ) + if ( + _assert_real_directory(_model_root()) != record.root_identity + or _assert_real_directory(storage.RUNTIME_DIR / "provider-models") + != record.parent_directory_identity + ): + raise ComfyUIModelError( + "model_identity_mismatch", "The managed model root identity changed" + ) + if ( + record.manifest_sha256 != MODEL_MANIFEST_SHA256 + or record.model_sha256 != manifest.source.sha256 + or record.license_sha256 != manifest.license.sha256 + or record.workflow_pack_sha256 != WORKFLOW_PACK_SHA256 + or workflow.model_package_id != manifest.package_id + ): + raise ComfyUIModelError( + "model_identity_mismatch", + "The model installation record does not match the fixed contracts", + ) + return record.model_copy( + update={ + "model_identity": _file_identity(_model_path(manifest)), + "license_identity": _file_identity(_license_path(manifest)), + } + ) + + +def prepare_model_operation( + operation: Literal["repair", "uninstall"], +) -> ModelOperationConfirmation: + with _MODEL_MUTATION_LOCK: + manifest = MODEL_MANIFEST_LOADER() + _assert_runtime_stopped(manifest, require_installed=False) + record = _model_operation_record(manifest) + summary = ModelOperationSummary( + operation=operation, + installation_identity=_installation_identity(record), + model_sha256=record.model_sha256, + tree_identity=_tree_identity(record), + replaces_model_files=operation == "repair", + ) + token = secrets.token_hex(32) + expires = time.time() + MODEL_CONFIRMATION_TTL_SECONDS + with _CONFIRMATION_LOCK: + _CONFIRMATIONS[hashlib.sha256(token.encode()).hexdigest()] = _ModelConfirmationRecord( + token_sha256=hashlib.sha256(token.encode()).hexdigest(), + summary=summary, + expires_at_epoch=expires, + ) + return ModelOperationConfirmation( + summary=summary, + confirmation_token=token, + expires_at=datetime.fromtimestamp(expires, timezone.utc).isoformat(), + ) + + +def consume_model_operation_confirmation( + operation: Literal["repair", "uninstall"], + token: str, + expected_model_identity: str, +) -> ModelOperationSummary: + token_hash = hashlib.sha256(token.encode()).hexdigest() + with _CONFIRMATION_LOCK: + record = _CONFIRMATIONS.pop(token_hash, None) + if record is None or record.token_sha256 != token_hash: + raise ComfyUIModelError("confirmation_invalid", "The model confirmation token is invalid or already used") + if record.expires_at_epoch < time.time(): + raise ComfyUIModelError("confirmation_expired", "The model confirmation token expired") + if ( + record.summary.operation != operation + or record.summary.installation_identity != expected_model_identity + ): + raise ComfyUIModelError("confirmation_invalid", "The model confirmation does not match this operation") + return record.summary + + +def run_model_install( + task_id: str, + *, + operation: Literal["install", "repair"] = "install", + progress: ProgressCallback = _default_progress, + cancel: CancellationCheck = _default_cancel, + confirmation: ModelOperationSummary | None = None, +) -> ModelInstallationRecord: + manifest = MODEL_MANIFEST_LOADER() + WORKFLOW_PACK_LOADER() + with _MODEL_MUTATION_LOCK: + recover_model_installations() + _validate_platform(manifest) + _assert_runtime_stopped(manifest, require_installed=True) + current = _read_installation() + if operation == "install" and current is not None: + raise ComfyUIModelError("model_already_installed", "The fixed model is already installed; use repair") + if operation == "repair": + if confirmation is None or confirmation.operation != "repair": + raise ComfyUIModelError("confirmation_invalid", "Model repair requires backend confirmation") + current = assert_model_operation_identity(confirmation) + root_identity = _ensure_model_directories(manifest) + if DISK_USAGE(_model_root()).free < manifest.platforms[0].minimum_free_disk_bytes: + raise ComfyUIModelError("insufficient_disk", "Not enough free disk for the fixed model package") + model = _model_path(manifest) + license_file = _license_path(manifest) + if current is None and (model.exists() or license_file.exists()): + raise ComfyUIModelError("model_path_conflict", "The fixed model target is occupied by an unowned file") + journal = _new_journal(task_id, operation, current) + _write_journal(journal) + _write_state( + ModelStateRecord( + installed=current is not None, + status="installing", + package_id=manifest.package_id, + version=manifest.version, + installation_identity=_installation_identity(current) if current else None, + installed_at=current.installed_at if current else None, + ) + ) + staging_model = _journal_path(journal.staging_model_relative_path) + staging_license = _journal_path(journal.staging_license_relative_path) + backup_model = _journal_path(journal.backup_model_relative_path) + backup_license = _journal_path(journal.backup_license_relative_path) + total = manifest.source.size + manifest.license.size + try: + cancel() + progress("preflight", 5, "正在确认固定模型包、许可证和磁盘边界", 0, total) + journal.phase = "downloading" + _write_journal(journal) + + def download_progress(current_bytes: int, total_bytes: int) -> None: + cancel() + percent = 5 + int(current_bytes * 65 / total_bytes) + progress("installing_model", percent, "正在下载固定教学图片模型", current_bytes, total_bytes) + + MODEL_DOWNLOADER( + staging_model, + staging_license, + manifest, + download_progress, + cancel, + ) + journal.staged_model_identity = _file_identity(staging_model) + journal.staged_license_identity = _file_identity(staging_license) + _write_journal(journal) + progress("verifying", 75, "正在校验 SafeTensors、许可证和固定身份", total, total) + inspect_safetensors(staging_model, manifest) + model_sha, _ = _sha256_regular_file( + staging_model, + expected_identity=journal.staged_model_identity, + maximum_bytes=manifest.source.size, + ) + license_sha, _ = _sha256_regular_file( + staging_license, + expected_identity=journal.staged_license_identity, + maximum_bytes=manifest.license.size, + ) + if model_sha != manifest.source.sha256 or license_sha != manifest.license.sha256: + raise ComfyUIModelError("model_checksum_mismatch", "The fixed model package checksum changed") + journal.phase = "verified" + _write_journal(journal) + cancel() + progress("installing_workflow", 82, "正在校验固定官方节点工作流", total, total) + WORKFLOW_PACK_LOADER() + journal.phase = "publish_prepared" + _write_journal(journal) + if current: + os.replace(model, backup_model) + os.replace(license_file, backup_license) + current = current.model_copy( + update={ + "model_identity": _file_identity(backup_model), + "license_identity": _file_identity(backup_license), + } + ) + journal.previous_installation = current.model_dump( + mode="json", by_alias=True + ) + _write_journal(journal) + os.replace(staging_model, model) + os.replace(staging_license, license_file) + model.chmod(0o600) + license_file.chmod(0o600) + journal.staged_model_identity = _file_identity(model) + journal.staged_license_identity = _file_identity(license_file) + journal.phase = "model_published" + _write_journal(journal) + record = ModelInstallationRecord( + manifest_sha256=MODEL_MANIFEST_SHA256, + model_relative_path=f"checkpoints/{manifest.source.installed_file_name}", + model_sha256=manifest.source.sha256, + model_size=manifest.source.size, + license_relative_path=f"licenses/{manifest.license.installed_file_name}", + license_sha256=manifest.license.sha256, + workflow_pack_sha256=WORKFLOW_PACK_SHA256, + parent_directory_identity=_assert_real_directory( + storage.RUNTIME_DIR / "provider-models" + ), + root_identity=root_identity, + model_identity=_file_identity(model), + license_identity=_file_identity(license_file), + installed_at=_iso(), + ) + _write_installation(record) + _write_state( + ModelStateRecord( + installed=True, + status="model_ready", + package_id=record.package_id, + version=record.version, + installation_identity=_installation_identity(record), + installed_at=record.installed_at, + checked_at=_iso(), + ) + ) + journal.phase = "state_committed" + _write_journal(journal) + _cleanup_journal_files(journal) + journal.phase = "completed" + _write_journal(journal) + progress("completed", 100, "固定教学图片模型和工作流已就绪", total, total) + return record + except ComfyUIModelError as exc: + if journal.phase not in {"state_committed", "completed"}: + _capture_staging_identities(journal) + journal.phase = "rolling_back" + journal.error_code = exc.code + _write_journal(journal) + _restore_previous(journal, manifest) + _cleanup_journal_files(journal) + journal.phase = "rolled_back" + _write_journal(journal) + raise + except (OSError, ComfyUIRuntimeError) as exc: + if journal.phase not in {"state_committed", "completed"}: + _capture_staging_identities(journal) + journal.phase = "rolling_back" + journal.error_code = "model_install_failed" + _write_journal(journal) + _restore_previous(journal, manifest) + _cleanup_journal_files(journal) + journal.phase = "rolled_back" + _write_journal(journal) + raise ComfyUIModelError("model_install_failed", "The fixed model package could not be installed safely") from exc + + +def run_model_uninstall( + task_id: str, + *, + progress: ProgressCallback = _default_progress, + cancel: CancellationCheck = _default_cancel, + confirmation: ModelOperationSummary | None = None, +) -> None: + manifest = MODEL_MANIFEST_LOADER() + with _MODEL_MUTATION_LOCK: + recover_model_installations() + _assert_runtime_stopped(manifest, require_installed=False) + if confirmation is None or confirmation.operation != "uninstall": + raise ComfyUIModelError("confirmation_invalid", "Model uninstall requires backend confirmation") + record = assert_model_operation_identity(confirmation) + journal = _new_journal(task_id, "uninstall", record) + journal.phase = "uninstalling" + _write_journal(journal) + progress("preflight", 10, "正在复核模型所有权与保留边界", None, None) + try: + cancel() + progress("uninstalling_model", 55, "正在移除 HanClassStudio 管理的固定模型", None, None) + _unlink_if_owned(_model_path(manifest), record.model_identity) + _unlink_if_owned(_license_path(manifest), record.license_identity) + _config_path(MODEL_INSTALLATION_FILE).unlink(missing_ok=True) + _write_state(ModelStateRecord()) + journal.phase = "completed" + _write_journal(journal) + progress("completed", 100, "固定教学图片模型已卸载", None, None) + except ComfyUIModelError: + raise + except OSError as exc: + journal.phase = "failed" + journal.error_code = "model_uninstall_failed" + _write_journal(journal) + raise ComfyUIModelError("model_uninstall_failed", "The fixed model could not be uninstalled safely") from exc + + +def model_snapshot(*, recover: bool = True, deep: bool = False) -> ModelPackageSnapshot: + try: + manifest = MODEL_MANIFEST_LOADER() + workflow = WORKFLOW_PACK_LOADER() + except ComfyUIModelError as exc: + return ModelPackageSnapshot( + name="Stable Diffusion v1.5 FP16 教学插图模型", + version="1.5-fp16-emaonly", + status="repair_required", + installed=False, + model_ready=False, + workflow_ready=False, + model_size=2_132_696_762, + model_sha256="e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + model_source_revision="4fddeb7f9096623f1b77f4708feb96126a08a0cf", + model_license="CreativeML Open RAIL-M", + estimated_download_bytes=2_132_711_147, + technical_error={"code": exc.code, "message": exc.message}, + ) + if recover: + try: + recover_model_installations() + except ComfyUIModelError as exc: + return ModelPackageSnapshot( + name=manifest.name, + version=manifest.version, + status="repair_required", + installed=_read_installation() is not None, + model_ready=False, + workflow_ready=True, + model_size=manifest.source.size, + model_sha256=manifest.source.sha256, + model_source_revision=manifest.source.revision, + model_license=manifest.license.name, + estimated_download_bytes=manifest.source.size + manifest.license.size, + technical_error={"code": exc.code, "message": exc.message}, + ) + state = _read_state() + installed = _read_installation() is not None + try: + record = validate_model_installation(manifest, deep=deep) + return ModelPackageSnapshot( + name=manifest.name, + version=manifest.version, + status="model_ready", + installed=True, + model_ready=True, + workflow_ready=workflow.pack_id == WORKFLOW_PACK_ID, + model_size=manifest.source.size, + model_sha256=manifest.source.sha256, + model_source_revision=manifest.source.revision, + model_license=manifest.license.name, + estimated_download_bytes=manifest.source.size + manifest.license.size, + checked_at=state.checked_at or record.installed_at, + ) + except ComfyUIModelError as exc: + occupied = _model_path(manifest).exists() or _license_path(manifest).exists() + status: ModelStatus = "repair_required" if installed or occupied else "not_installed" + return ModelPackageSnapshot( + name=manifest.name, + version=manifest.version, + status=status, + installed=installed, + model_ready=False, + workflow_ready=True, + model_size=manifest.source.size, + model_sha256=manifest.source.sha256, + model_source_revision=manifest.source.revision, + model_license=manifest.license.name, + estimated_download_bytes=manifest.source.size + manifest.license.size, + checked_at=state.checked_at, + technical_error=( + {"code": exc.code, "message": exc.message} + if status == "repair_required" + else None + ), + ) + + +def model_generation_guard() -> threading.RLock: + """Share the package mutation lock with one controlled generation.""" + return _MODEL_MUTATION_LOCK diff --git a/apps/api/src/hcs_api/comfyui_runtime.py b/apps/api/src/hcs_api/comfyui_runtime.py index af9d203..d2021e4 100644 --- a/apps/api/src/hcs_api/comfyui_runtime.py +++ b/apps/api/src/hcs_api/comfyui_runtime.py @@ -426,6 +426,7 @@ class RuntimeSnapshot(_StrictModel): compatible: bool available_actions: list[RuntimeAction] actual_port: int | None = None + process_identity: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") estimated_download_bytes: int no_model_message: str = "运行环境可用,但尚未安装图片模型。" modified: bool = False @@ -1040,6 +1041,7 @@ def _download_exact_artifact( checksum_error_code: str, require_content_length: bool = True, managed_root_identity: RuntimeDirectoryIdentity | None = None, + max_download_seconds: int = _MAX_DOWNLOAD_SECONDS, ) -> None: current_url = source_url response: http.client.HTTPResponse | None = None @@ -1105,7 +1107,7 @@ def receive(fd: int, cleanup: Callable[[os.stat_result], None]) -> None: with os.fdopen(fd, "wb", closefd=False) as output: while True: cancel() - if time.monotonic() - started > _MAX_DOWNLOAD_SECONDS: + if time.monotonic() - started > max_download_seconds: raise ComfyUIRuntimeError( "download_failed", "Pinned artifact download timed out" ) @@ -2899,6 +2901,12 @@ def _installation_identity(record: RuntimeInstallationRecord) -> str: ).hexdigest() +def runtime_installation_identity() -> str: + """Return the identity of the currently verified managed Runtime tree.""" + manifest = _runtime_manifest() + return _installation_identity(RUNTIME_VALIDATOR(_version_root(manifest), manifest)) + + def _expected_runtime_argv(manifest: ComfyUIRuntimeManifest, port: int, nonce: str) -> list[str]: version = _version_root(manifest) data = _runtime_data_root() @@ -3882,6 +3890,25 @@ def runtime_snapshot(*, recover: bool = True) -> RuntimeSnapshot: compatible=enabled, available_actions=actions, actual_port=process.ownership.port if process and _process_alive(process.ownership.pid) else None, + process_identity=( + hashlib.sha256( + json.dumps( + { + "pid": process.ownership.pid, + "process_start_token": process.ownership.process_start_token, + "nonce": process.ownership.nonce, + "expected_argv_sha256": process.ownership.expected_argv_sha256, + "installation_identity_sha256": ( + process.ownership.installation_identity_sha256 + ), + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + if process and _process_alive(process.ownership.pid) + else None + ), estimated_download_bytes=_estimated_download_bytes(manifest), modified=modified, last_health=last_health, diff --git a/apps/api/src/hcs_api/comfyui_teaching_image.py b/apps/api/src/hcs_api/comfyui_teaching_image.py new file mode 100644 index 0000000..2fdae37 --- /dev/null +++ b/apps/api/src/hcs_api/comfyui_teaching_image.py @@ -0,0 +1,1043 @@ +"""Controlled teaching-image generation over the one fixed ComfyUI package.""" + +from __future__ import annotations + +import hashlib +import http.client +import json +import os +import secrets +import stat +import threading +import time +import urllib.parse +import uuid +import zlib +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .comfyui_model import ( + MODEL_MANIFEST_SHA256, + WORKFLOW_PACK_SHA256, + ComfyUIModelError, + ComfyUIWorkflowPack, + ModelInstallationRecord, + load_workflow_pack, + model_generation_guard, + model_installation_identity, + model_snapshot, + validate_model_installation, +) +from .comfyui_runtime import ( + ComfyUIRuntimeError, + check_runtime_health, + runtime_installation_identity, + runtime_snapshot, +) +from .models import ( + AssetCandidate, + AssetFile, + AssetManifest, + GeneratedImage, + TeachingImageProvenance, + VerifiedImageArtifact, +) + +_CHECKPOINT_NAME = "hcs-sd-v1-5-pruned-emaonly-fp16.safetensors" +_REQUIRED_CORE_NODES = frozenset( + { + "CheckpointLoaderSimple", + "CLIPTextEncode", + "EmptyLatentImage", + "KSampler", + "VAEDecode", + "SaveImage", + } +) +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_GENERATION_TIMEOUT_SECONDS = 15 * 60 +_HISTORY_RESPONSE_LIMIT = 8 * 1024 * 1024 +_OBJECT_INFO_LIMIT = 24 * 1024 * 1024 +_ASSET_MANIFEST_LIMIT = 16 * 1024 * 1024 +_CAPABILITY_CACHE_LOCK = threading.RLock() +_CAPABILITY_CACHE: dict[str, GenerationCapabilitySnapshot] = {} + + +class TeachingImageError(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 TeachingImageRequest(_StrictModel): + """Teacher intent accepted by the controlled image endpoint.""" + + schema_: Literal["hanclassstudio.teaching_image_request.v1"] = Field( + default="hanclassstudio.teaching_image_request.v1", alias="schema" + ) + asset_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$") + purpose: Literal["classroom_scene", "vocabulary_image", "teaching_illustration"] + subject: str = Field(min_length=1, max_length=240) + action: str = Field(min_length=1, max_length=240) + environment: str = Field(min_length=1, max_length=240) + aspect_ratio: Literal["1:1", "4:3", "16:9"] = "4:3" + seed: int = Field(default=1, ge=0, le=2**63 - 1) + source_trace: list[str] = Field(min_length=1, max_length=20) + + @field_validator("subject", "action", "environment") + @classmethod + def _bounded_single_line_intent(cls, value: str) -> str: + if any(ord(character) < 32 for character in value): + raise ValueError("teaching image intent must be plain single-line text") + normalized = " ".join(value.strip().split()) + if ( + not normalized + or " list[str]: + normalized: list[str] = [] + for item in value: + clean = item.strip() + if ( + not clean + or len(clean) > 300 + or "\n" in clean + or "\r" in clean + or any(ord(character) < 32 for character in clean) + ): + raise ValueError("source_trace entries must be bounded single-line references") + normalized.append(clean) + return normalized + + +class CompiledTeachingImagePlan(_StrictModel): + """Internal fixed plan. It deliberately contains no caller-supplied graph.""" + + request_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + execution_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_version: str + runtime_source_commit: str = Field(pattern=r"^[0-9a-f]{40}$") + runtime_installation_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_process_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_port: int = Field(ge=1024, le=65535) + model_package_id: str + model_version: str + model_installation_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + model_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + workflow_pack_id: str + workflow_version: str + width: int + height: int + seed: int + positive_prompt: str + negative_prompt: str + steps: int + cfg: float + sampler_name: str + scheduler: str + denoise: float + output_prefix: str + source_trace: list[str] + + +class GenerationCapabilitySnapshot(_StrictModel): + schema_: Literal["hanclassstudio.local_image_generation_capability.v1"] = Field( + default="hanclassstudio.local_image_generation_capability.v1", alias="schema" + ) + runtime_installed: bool + runtime_ready: bool + model_installed: bool + model_ready: bool + workflow_ready: bool + generation_ready: bool + checked_at: str + technical_error: dict[str, str] | None = None + + +class VerifiedPng(_StrictModel): + width: int + height: int + size_bytes: int + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +def _iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256_payload(value: Any) -> str: + return hashlib.sha256(_canonical(value)).hexdigest() + + +def _http_json( + port: int, + method: Literal["GET", "POST"], + path: str, + *, + payload: dict[str, Any] | None = None, + maximum_bytes: int, + timeout: float, +) -> Any: + if not 1024 <= port <= 65535 or not path.startswith("/"): + raise TeachingImageError("runtime_identity_mismatch", "Managed Runtime endpoint is invalid") + body = _canonical(payload) if payload is not None else None + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) + try: + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + headers["Content-Length"] = str(len(body)) + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + if response.status != 200: + raise TeachingImageError( + "generation_failed", "The managed ComfyUI API rejected the fixed image request" + ) + declared = response.headers.get("Content-Length") + if declared and int(declared) > maximum_bytes: + raise TeachingImageError("generation_failed", "ComfyUI response exceeded its fixed limit") + encoded = response.read(maximum_bytes + 1) + if len(encoded) > maximum_bytes: + raise TeachingImageError("generation_failed", "ComfyUI response exceeded its fixed limit") + decoded = json.loads(encoded) + return decoded + except TeachingImageError: + raise + except (OSError, TimeoutError, http.client.HTTPException, ValueError, json.JSONDecodeError) as exc: + raise TeachingImageError( + "generation_failed", "The managed ComfyUI API could not be read safely" + ) from exc + finally: + connection.close() + + +def _http_image(port: int, query: str, maximum_bytes: int) -> bytes: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=60) + try: + connection.request("GET", f"/view?{query}", headers={"Accept": "image/png"}) + response = connection.getresponse() + if response.status != 200: + raise TeachingImageError("generation_failed", "Generated image could not be retrieved") + content_type = response.headers.get_content_type() + if content_type != "image/png": + raise TeachingImageError("image_format_invalid", "Generated output is not PNG") + declared = response.headers.get("Content-Length") + if declared and int(declared) > maximum_bytes: + raise TeachingImageError("image_too_large", "Generated PNG exceeds its fixed limit") + payload = response.read(maximum_bytes + 1) + if len(payload) > maximum_bytes: + raise TeachingImageError("image_too_large", "Generated PNG exceeds its fixed limit") + return payload + except TeachingImageError: + raise + except (OSError, TimeoutError, http.client.HTTPException, ValueError) as exc: + raise TeachingImageError("generation_failed", "Generated image could not be retrieved") from exc + finally: + connection.close() + + +def _checkpoint_is_available(object_info: Any) -> bool: + if not isinstance(object_info, dict) or not _REQUIRED_CORE_NODES.issubset(object_info): + return False + try: + choices = object_info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0] + except (KeyError, IndexError, TypeError): + return False + return ( + isinstance(choices, list) + and choices.count(_CHECKPOINT_NAME) == 1 + and all(isinstance(value, str) for value in choices) + ) + + +def _capability_fingerprint(runtime: Any, model: Any) -> str: + return _sha256_payload( + { + "runtime_version": runtime.version, + "runtime_source_commit": runtime.source_commit, + "runtime_port": runtime.actual_port, + "runtime_process_identity": runtime.process_identity, + "runtime_ready": runtime.runtime_ready, + "model_package_id": model.package_id, + "model_version": model.version, + "model_sha256": model.model_sha256, + "model_checked_at": model.checked_at, + "model_ready": model.model_ready, + "workflow_pack_id": model.workflow_pack_id, + "workflow_version": model.workflow_version, + "workflow_sha256": WORKFLOW_PACK_SHA256, + } + ) + + +def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilitySnapshot: + runtime = runtime_snapshot() + model = model_snapshot(deep=deep) + workflow_ready = model.workflow_ready + error = model.technical_error + runtime_ready = runtime.runtime_ready + fingerprint = ( + _capability_fingerprint(runtime, model) + if deep or (runtime_ready and model.model_ready and workflow_ready) + else "" + ) + if ( + not deep + and runtime_ready + and model.model_ready + and workflow_ready + and error is None + ): + with _CAPABILITY_CACHE_LOCK: + cached = _CAPABILITY_CACHE.get(fingerprint) + if cached is not None: + return cached + if deep: + try: + health = check_runtime_health() + runtime_ready = health.healthy and health.identity_verified and health.custom_nodes_pristine + if not runtime_ready or health.port is None: + raise TeachingImageError( + health.error["code"] if health.error else "runtime_not_ready", + health.error["message"] if health.error else "The fixed Runtime is not ready", + ) + validate_model_installation(deep=True) + workflow = load_workflow_pack() + object_info = _http_json( + health.port, + "GET", + "/object_info", + maximum_bytes=_OBJECT_INFO_LIMIT, + timeout=10, + ) + if not _checkpoint_is_available(object_info): + raise TeachingImageError( + "workflow_not_ready", + "The fixed core nodes or managed checkpoint are unavailable", + ) + workflow_ready = workflow.pack_id == model.workflow_pack_id + error = None + except (TeachingImageError, ComfyUIModelError, ComfyUIRuntimeError) as exc: + runtime_ready = runtime_ready and getattr(exc, "code", "") not in { + "runtime_identity_mismatch", + "runtime_modified", + "runtime_not_ready", + "runtime_not_running", + } + workflow_ready = False + error = {"code": exc.code, "message": exc.message} + ready = ( + deep + and runtime_ready + and model.model_ready + and workflow_ready + and error is None + ) + snapshot = GenerationCapabilitySnapshot( + runtime_installed=runtime.installed, + runtime_ready=runtime_ready, + model_installed=model.installed, + model_ready=model.model_ready, + workflow_ready=workflow_ready, + generation_ready=ready, + checked_at=_iso(), + technical_error=error, + ) + if deep: + with _CAPABILITY_CACHE_LOCK: + _CAPABILITY_CACHE.clear() + _CAPABILITY_CACHE[fingerprint] = snapshot + return snapshot + + +def _require_generation_context() -> tuple[int, ModelInstallationRecord, ComfyUIWorkflowPack]: + capability = generation_capability_snapshot(deep=True) + if not capability.generation_ready: + error = capability.technical_error or { + "code": "generation_not_ready", + "message": "Runtime, model, and workflow are not jointly ready", + } + raise TeachingImageError(error["code"], error["message"]) + current_runtime = runtime_snapshot(recover=False) + if not current_runtime.runtime_ready or current_runtime.actual_port is None: + raise TeachingImageError("runtime_not_ready", "The managed Runtime is not ready") + return ( + current_runtime.actual_port, + validate_model_installation(deep=False), + load_workflow_pack(), + ) + + +def compile_teaching_image_request( + request: TeachingImageRequest, + *, + model_record: ModelInstallationRecord, + workflow: ComfyUIWorkflowPack, +) -> CompiledTeachingImagePlan: + runtime = runtime_snapshot(recover=False) + if ( + not runtime.runtime_ready + or runtime.actual_port is None + or runtime.process_identity is None + or runtime.version != workflow.runtime.version + or runtime.source_commit != workflow.runtime.source_commit + or model_record.package_id != workflow.model_package_id + or model_record.workflow_pack_sha256 != WORKFLOW_PACK_SHA256 + ): + raise TeachingImageError( + "generation_identity_mismatch", + "Runtime, model, and workflow identities do not match the fixed package", + ) + purpose = { + "classroom_scene": "classroom situation scene", + "vocabulary_image": "clear vocabulary concept image", + "teaching_illustration": "teaching courseware illustration", + }[request.purpose] + profile = workflow.prompt_profile + positive = ( + f"{profile.positive_prefix}, {purpose}, subject: {request.subject}, " + f"action: {request.action}, environment: {request.environment}, " + f"{profile.positive_suffix}" + ) + request_payload = request.model_dump(mode="json", by_alias=True) + request_sha = _sha256_payload(request_payload) + width, height = workflow.dimensions[request.aspect_ratio] + unsigned = { + "request_sha256": request_sha, + "runtime_version": runtime.version, + "runtime_source_commit": runtime.source_commit, + "runtime_installation_identity": runtime_installation_identity(), + "runtime_process_identity": runtime.process_identity, + "runtime_port": runtime.actual_port, + "model_package_id": model_record.package_id, + "model_version": model_record.version, + "model_installation_identity": model_installation_identity(model_record), + "model_sha256": model_record.model_sha256, + "workflow_pack_id": workflow.pack_id, + "workflow_version": workflow.version, + "width": width, + "height": height, + "seed": request.seed, + "positive_prompt": positive, + "negative_prompt": profile.negative, + "steps": workflow.sampling.steps, + "cfg": workflow.sampling.cfg, + "sampler_name": workflow.sampling.sampler_name, + "scheduler": workflow.sampling.scheduler, + "denoise": workflow.sampling.denoise, + # SaveImage participates in ComfyUI's cross-prompt cache. A controlled + # per-execution prefix forces this output node to publish a fresh file. + "output_prefix": f"hcs_{request_sha[:20]}_{uuid.uuid4().hex[:12]}", + "source_trace": request.source_trace, + } + return CompiledTeachingImagePlan( + **unsigned, + execution_plan_sha256=_sha256_payload(unsigned), + ) + + +def _fixed_graph(plan: CompiledTeachingImagePlan) -> dict[str, Any]: + """Build the sole allowed graph. Never accept a graph from the caller.""" + return { + "1": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": _CHECKPOINT_NAME}, + }, + "2": { + "class_type": "CLIPTextEncode", + "inputs": {"text": plan.positive_prompt, "clip": ["1", 1]}, + }, + "3": { + "class_type": "CLIPTextEncode", + "inputs": {"text": plan.negative_prompt, "clip": ["1", 1]}, + }, + "4": { + "class_type": "EmptyLatentImage", + "inputs": {"width": plan.width, "height": plan.height, "batch_size": 1}, + }, + "5": { + "class_type": "KSampler", + "inputs": { + "seed": plan.seed, + "steps": plan.steps, + "cfg": plan.cfg, + "sampler_name": plan.sampler_name, + "scheduler": plan.scheduler, + "denoise": plan.denoise, + "model": ["1", 0], + "positive": ["2", 0], + "negative": ["3", 0], + "latent_image": ["4", 0], + }, + }, + "6": { + "class_type": "VAEDecode", + "inputs": {"samples": ["5", 0], "vae": ["1", 2]}, + }, + "7": { + "class_type": "SaveImage", + "inputs": {"filename_prefix": plan.output_prefix, "images": ["6", 0]}, + }, + } + + +def _execute_fixed_plan( + plan: CompiledTeachingImagePlan, + port: int, + maximum_bytes: int, +) -> tuple[bytes, str]: + if port != plan.runtime_port: + raise TeachingImageError( + "runtime_identity_mismatch", "Managed Runtime endpoint changed before execution" + ) + prompt_id = str(uuid.uuid4()) + client_id = uuid.uuid4().hex + graph = _fixed_graph(plan) + queued = _http_json( + port, + "POST", + "/prompt", + payload={ + "prompt": graph, + "client_id": client_id, + "prompt_id": prompt_id, + }, + maximum_bytes=2 * 1024 * 1024, + timeout=15, + ) + if ( + not isinstance(queued, dict) + or queued.get("prompt_id") != prompt_id + or queued.get("node_errors") not in ({}, None) + ): + raise TeachingImageError("generation_failed", "ComfyUI returned an invalid queue receipt") + try: + deadline = time.monotonic() + _GENERATION_TIMEOUT_SECONDS + result: Any = None + while time.monotonic() < deadline: + history = _http_json( + port, + "GET", + f"/history/{urllib.parse.quote(prompt_id, safe='')}", + maximum_bytes=_HISTORY_RESPONSE_LIMIT, + timeout=15, + ) + if isinstance(history, dict) and prompt_id in history: + result = history[prompt_id] + break + time.sleep(0.25) + if not isinstance(result, dict): + raise TeachingImageError( + "generation_timeout", "Fixed teaching image generation timed out" + ) + status = result.get("status") + history_prompt = result.get("prompt") + if ( + not isinstance(status, dict) + or status.get("status_str") != "success" + or status.get("completed") is not True + or not isinstance(history_prompt, list) + or len(history_prompt) != 5 + or history_prompt[1] != prompt_id + or history_prompt[2] != graph + or not isinstance(history_prompt[3], dict) + or history_prompt[3].get("client_id") != client_id + or not isinstance(history_prompt[4], list) + or history_prompt[4] != ["7"] + ): + raise TeachingImageError( + "generation_failed", "ComfyUI history did not match the submitted fixed job" + ) + outputs = result.get("outputs") + if not isinstance(outputs, dict) or set(outputs) != {"7"}: + raise TeachingImageError( + "generation_failed", "ComfyUI returned unexpected workflow outputs" + ) + images = outputs["7"].get("images") if isinstance(outputs["7"], dict) else None + if ( + not isinstance(images, list) + or len(images) != 1 + or not isinstance(images[0], dict) + ): + raise TeachingImageError( + "generation_failed", "ComfyUI did not return exactly one image" + ) + image = images[0] + filename = image.get("filename") + subfolder = image.get("subfolder") + output_type = image.get("type") + if ( + not isinstance(filename, str) + or Path(filename).name != filename + or not filename.startswith(f"{plan.output_prefix}_") + or not filename.endswith(".png") + or subfolder not in {"", None} + or output_type != "output" + ): + raise TeachingImageError( + "generation_failed", "ComfyUI output identity escaped the fixed workflow" + ) + query = urllib.parse.urlencode( + {"filename": filename, "subfolder": "", "type": "output"} + ) + return _http_image(port, query, maximum_bytes), prompt_id + except TeachingImageError: + _cancel_job_if_still_owned(plan, prompt_id) + raise + + +def _cancel_job_if_still_owned( + plan: CompiledTeachingImagePlan, + prompt_id: str, +) -> None: + """Best-effort targeted cancellation without touching a replacement Runtime.""" + try: + runtime = runtime_snapshot(recover=False) + if ( + not runtime.runtime_ready + or runtime.actual_port != plan.runtime_port + or runtime.process_identity != plan.runtime_process_identity + ): + return + _http_json( + plan.runtime_port, + "POST", + f"/api/jobs/{urllib.parse.quote(prompt_id, safe='')}/cancel", + maximum_bytes=64 * 1024, + timeout=5, + ) + except (TeachingImageError, ComfyUIRuntimeError): + return + + +IMAGE_EXECUTOR: Callable[ + [CompiledTeachingImagePlan, int, int], tuple[bytes, str] +] = _execute_fixed_plan + + +def verify_png( + payload: bytes, + *, + expected_width: int, + expected_height: int, + maximum_bytes: int, +) -> VerifiedPng: + if not 0 < len(payload) <= maximum_bytes or not payload.startswith(_PNG_SIGNATURE): + raise TeachingImageError("image_format_invalid", "Generated output is not a bounded PNG") + offset = len(_PNG_SIGNATURE) + width = height = 0 + seen_ihdr = seen_idat = seen_iend = False + while offset < len(payload): + if len(payload) - offset < 12: + raise TeachingImageError("image_format_invalid", "PNG chunk is truncated") + length = int.from_bytes(payload[offset : offset + 4], "big") + chunk_type = payload[offset + 4 : offset + 8] + offset += 8 + if length > maximum_bytes or offset + length + 4 > len(payload): + raise TeachingImageError("image_format_invalid", "PNG chunk length is invalid") + data = payload[offset : offset + length] + expected_crc = int.from_bytes(payload[offset + length : offset + length + 4], "big") + if zlib.crc32(chunk_type + data) & 0xFFFFFFFF != expected_crc: + raise TeachingImageError("image_format_invalid", "PNG chunk checksum is invalid") + offset += length + 4 + if not seen_ihdr: + if chunk_type != b"IHDR" or length != 13: + raise TeachingImageError("image_format_invalid", "PNG IHDR is invalid") + width = int.from_bytes(data[0:4], "big") + height = int.from_bytes(data[4:8], "big") + bit_depth = data[8] + color_type = data[9] + valid_depths = { + 0: {1, 2, 4, 8, 16}, + 2: {8, 16}, + 3: {1, 2, 4, 8}, + 4: {8, 16}, + 6: {8, 16}, + } + if ( + bit_depth not in valid_depths.get(color_type, set()) + or data[10] not in {0} + or data[11] != 0 + or data[12] not in {0, 1} + ): + raise TeachingImageError("image_format_invalid", "PNG coding method is unsupported") + seen_ihdr = True + elif chunk_type == b"IHDR": + raise TeachingImageError("image_format_invalid", "PNG contains duplicate IHDR") + if chunk_type in {b"tEXt", b"zTXt", b"iTXt"}: + raise TeachingImageError("image_metadata_rejected", "PNG text metadata is not allowed") + if chunk_type == b"IDAT": + seen_idat = True + if chunk_type == b"IEND": + if length != 0 or offset != len(payload): + raise TeachingImageError("image_format_invalid", "PNG IEND is invalid") + seen_iend = True + break + if ( + not seen_ihdr + or not seen_idat + or not seen_iend + or width != expected_width + or height != expected_height + ): + raise TeachingImageError( + "image_dimensions_invalid", "Generated PNG dimensions or structure changed" + ) + return VerifiedPng( + width=width, + height=height, + size_bytes=len(payload), + sha256=hashlib.sha256(payload).hexdigest(), + ) + + +def _real_directory(root: Path, relative: tuple[str, ...]) -> Path: + try: + info = root.lstat() + except OSError as exc: + raise TeachingImageError("project_identity_mismatch", "Project root is unavailable") from exc + if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode): + raise TeachingImageError("project_identity_mismatch", "Project root is not a real directory") + current = root + for name in relative: + current = current / name + try: + current.mkdir(mode=0o700) + except FileExistsError: + pass + try: + child = current.lstat() + except OSError as exc: + raise TeachingImageError( + "project_identity_mismatch", "Project asset directory is unavailable" + ) from exc + if not stat.S_ISDIR(child.st_mode) or stat.S_ISLNK(child.st_mode): + raise TeachingImageError( + "project_identity_mismatch", "Project asset directory is not owned safely" + ) + return current + + +def _write_new_file(path: Path, payload: bytes) -> None: + try: + path.lstat() + except FileNotFoundError: + pass + else: + raise TeachingImageError("asset_id_conflict", "A generated asset path already exists") + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, "wb", closefd=False) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.link(temporary, path, follow_symlinks=False) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + pass + except OSError as exc: + raise TeachingImageError("asset_persist_failed", "Verified image could not be persisted") from exc + finally: + os.close(fd) + temporary.unlink(missing_ok=True) + + +def _write_manifest(path: Path, manifest: AssetManifest) -> None: + try: + existing = path.lstat() + if stat.S_ISLNK(existing.st_mode) or not stat.S_ISREG(existing.st_mode): + raise TeachingImageError( + "project_identity_mismatch", "Asset Manifest identity is unsafe" + ) + except FileNotFoundError: + pass + encoded = json.dumps( + manifest.model_dump(mode="json"), + ensure_ascii=False, + indent=2, + ).encode("utf-8") + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, "wb", closefd=False) as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + pass + except OSError as exc: + raise TeachingImageError( + "manifest_registration_failed", "Verified image could not enter the Asset Manifest" + ) from exc + finally: + os.close(fd) + temporary.unlink(missing_ok=True) + + +def _read_project_manifest(project_root: Path) -> AssetManifest: + manifest_path = ( + _real_directory(project_root, ("assets", "data")) / "asset_manifest.json" + ) + try: + fd = os.open(manifest_path, os.O_RDONLY | os.O_NOFOLLOW) + except FileNotFoundError: + return AssetManifest() + except OSError as exc: + raise TeachingImageError( + "project_identity_mismatch", "Asset Manifest identity is unsafe" + ) from exc + try: + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > _ASSET_MANIFEST_LIMIT: + raise TeachingImageError( + "manifest_invalid", "Asset Manifest is not a bounded regular file" + ) + with os.fdopen(fd, "rb", closefd=False) as handle: + encoded = handle.read(_ASSET_MANIFEST_LIMIT + 1) + if len(encoded) > _ASSET_MANIFEST_LIMIT: + raise TeachingImageError( + "manifest_invalid", "Asset Manifest exceeded its fixed limit" + ) + return AssetManifest.model_validate_json(encoded) + except TeachingImageError: + raise + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise TeachingImageError( + "manifest_invalid", "Asset Manifest could not be validated" + ) from exc + finally: + os.close(fd) + + +def _assert_asset_id_available(manifest: AssetManifest, asset_id: str) -> None: + if any( + asset.id == asset_id + for assets in ( + manifest.images, + manifest.audio, + manifest.video, + manifest.fonts, + ) + for asset in assets + ): + raise TeachingImageError( + "asset_id_conflict", "Asset Manifest already contains this asset id" + ) + + +def _revalidate_generation_context(plan: CompiledTeachingImagePlan) -> None: + """Reject output if any approved execution identity changed while it ran.""" + try: + 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 (ComfyUIRuntimeError, ComfyUIModelError) as exc: + raise TeachingImageError( + "generation_identity_mismatch", + "Runtime, model, or workflow identity changed during generation", + ) from exc + if ( + not runtime.runtime_ready + or runtime.actual_port != plan.runtime_port + or runtime.process_identity != plan.runtime_process_identity + or runtime.version != plan.runtime_version + or runtime.source_commit != plan.runtime_source_commit + or current_runtime_identity != plan.runtime_installation_identity + or model_record.package_id != plan.model_package_id + or model_record.version != plan.model_version + or model_record.model_sha256 != plan.model_sha256 + or current_model_identity != plan.model_installation_identity + or workflow.pack_id != plan.workflow_pack_id + or workflow.version != plan.workflow_version + or workflow.model_package_id != plan.model_package_id + or workflow.runtime.version != plan.runtime_version + or workflow.runtime.source_commit != plan.runtime_source_commit + ): + raise TeachingImageError( + "generation_identity_mismatch", + "Runtime, model, or workflow identity changed during generation", + ) + + +def generate_teaching_image( + project_root: Path, + request: TeachingImageRequest, + *, + before_publication: Callable[[], None] | None = None, +) -> VerifiedImageArtifact: + """Execute, verify, persist, and register exactly one teaching image.""" + with model_generation_guard(): + _assert_asset_id_available( + _read_project_manifest(project_root), request.asset_id + ) + port, model_record, workflow = _require_generation_context() + plan = compile_teaching_image_request( + request, + model_record=model_record, + workflow=workflow, + ) + payload, prompt_id = IMAGE_EXECUTOR(plan, port, workflow.output.maximum_bytes) + verified = verify_png( + payload, + expected_width=plan.width, + expected_height=plan.height, + maximum_bytes=workflow.output.maximum_bytes, + ) + _revalidate_generation_context(plan) + if before_publication is not None: + before_publication() + manifest = _read_project_manifest(project_root) + _assert_asset_id_available(manifest, request.asset_id) + provenance = TeachingImageProvenance( + runtime_version=plan.runtime_version, + runtime_source_commit=plan.runtime_source_commit, + runtime_installation_identity=plan.runtime_installation_identity, + runtime_process_identity=plan.runtime_process_identity, + runtime_port=plan.runtime_port, + model_package_id=plan.model_package_id, + model_version=plan.model_version, + model_manifest_sha256=MODEL_MANIFEST_SHA256, + model_sha256=plan.model_sha256, + model_installation_identity=plan.model_installation_identity, + workflow_pack_id=plan.workflow_pack_id, + workflow_version=plan.workflow_version, + workflow_pack_sha256=WORKFLOW_PACK_SHA256, + request_sha256=plan.request_sha256, + execution_plan_sha256=plan.execution_plan_sha256, + prompt_profile_id=workflow.prompt_profile.id, + positive_prompt=plan.positive_prompt, + negative_prompt=plan.negative_prompt, + seed=plan.seed, + steps=plan.steps, + cfg=plan.cfg, + sampler_name=plan.sampler_name, + scheduler=plan.scheduler, + denoise=plan.denoise, + output_prefix=plan.output_prefix, + prompt_id=prompt_id, + source_trace=plan.source_trace, + ) + provenance_bytes = _canonical( + provenance.model_dump(mode="json", by_alias=True) + ) + provenance_sha = hashlib.sha256(provenance_bytes).hexdigest() + suffix = verified.sha256[:12] + image_name = f"{request.asset_id}-{suffix}.png" + provenance_name = f"{request.asset_id}-{suffix}.provenance.json" + image_relative = f"assets/images/{image_name}" + provenance_relative = f"assets/data/image-provenance/{provenance_name}" + artifact = VerifiedImageArtifact( + artifact_id=f"img-{hashlib.sha256((verified.sha256 + plan.request_sha256).encode()).hexdigest()[:24]}", + asset_id=request.asset_id, + path=image_relative, + width=verified.width, + height=verified.height, + size_bytes=verified.size_bytes, + sha256=verified.sha256, + provenance_ref=provenance_relative, + provenance_sha256=provenance_sha, + provenance=provenance, + ) + image_dir = _real_directory(project_root, ("assets", "images")) + provenance_dir = _real_directory( + project_root, ("assets", "data", "image-provenance") + ) + image_path = image_dir / image_name + provenance_path = provenance_dir / provenance_name + generated = GeneratedImage( + provider="hcs.local.comfyui", + model=plan.model_package_id, + local_path=image_relative, + mime_type="image/png", + width=plan.width, + height=plan.height, + prompt=plan.positive_prompt, + brief_version="teaching_image_request.v1", + style_profile=workflow.prompt_profile.id, + style_profile_version=workflow.version, + seed=plan.seed, + content_hash=verified.sha256, + provider_request_id=prompt_id, + source_trace=plan.source_trace, + ) + candidate = AssetCandidate( + id=f"generated-{verified.sha256[:12]}", + path=image_relative, + mime_type="image/png", + content_hash=verified.sha256, + source="generated", + generation=generated, + ) + asset = AssetFile( + id=request.asset_id, + kind="image", + path=image_relative, + placeholder=False, + prompt=plan.positive_prompt, + mime_type="image/png", + content_hash=verified.sha256, + generation=generated, + review_state="pending_review", + selected_candidate_id=candidate.id, + candidates=[candidate], + request_fingerprint=plan.request_sha256, + verified_image_artifact=artifact, + ) + wrote: list[Path] = [] + manifest.images.append(asset) + try: + if before_publication is not None: + before_publication() + _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() + manifest_path = _real_directory(project_root, ("assets", "data")) / "asset_manifest.json" + _write_manifest(manifest_path, manifest) + except Exception: + manifest.images.pop() + for path in reversed(wrote): + path.unlink(missing_ok=True) + raise + return artifact diff --git a/apps/api/src/hcs_api/main.py b/apps/api/src/hcs_api/main.py index dc72433..719e1b5 100644 --- a/apps/api/src/hcs_api/main.py +++ b/apps/api/src/hcs_api/main.py @@ -54,8 +54,16 @@ SafeValidationErrorEnvelope, SourceMaterial, StateFirstTeacherSummary, + VerifiedImageArtifact, VideoProviderSettings, ) +from .comfyui_teaching_image import ( + TeachingImageError, + TeachingImageRequest, + generate_teaching_image, +) +from .comfyui_model import ComfyUIModelError, model_generation_guard +from .comfyui_runtime import ComfyUIRuntimeError from .parser import parse_source from .source_understanding import OCRPolicy, get_engine_status from .providers import ProviderError, provider_capability_catalog @@ -81,6 +89,8 @@ ) from .provider_hub import ( OnlineProviderConfigRequest, + ModelMutationConfirmationRequest, + ModelOperationConfirmation, ProviderHubCatalog, ProviderHubError, ProviderHubItem, @@ -93,6 +103,7 @@ RuntimeMutationConfirmationRequest, RuntimeOperationConfirmation, cancel_fixture_install, + check_comfyui_generation_package, check_local_health, comfyui_runtime_directory, comfyui_runtime_logs, @@ -103,9 +114,11 @@ get_refresh_task, hub_catalog, prepare_comfyui_mutation, + prepare_comfyui_model_mutation, save_online_config, set_online_disabled, start_comfyui_mutation, + start_comfyui_model_mutation, start_comfyui_runtime_package, start_fixture_install, start_refresh, @@ -1092,7 +1105,12 @@ def _provider_hub_http_error(error: ProviderHubError) -> HTTPException: "cancelled": 409, "runtime_not_found": 404, "runtime_not_installed": 409, + "model_not_installed": 409, + "model_already_installed": 409, + "generation_not_ready": 409, + "workflow_not_ready": 409, "runtime_identity_mismatch": 409, + "model_identity_mismatch": 409, "confirmation_invalid": 409, "confirmation_expired": 409, "confirmation_stale": 409, @@ -1246,6 +1264,111 @@ def prepare_uninstall_provider_runtime(package_id: str) -> RuntimeOperationConfi raise _provider_hub_http_error(error) from error +@app.post( + "/api/providers/hub/packages/{package_id}/model/install", + response_model=ProviderInstallStartResponse, +) +def install_provider_model(package_id: str) -> ProviderInstallStartResponse: + if package_id != "hcs.comfyui-runtime": + raise HTTPException( + status_code=404, + detail={"code": "model_not_found", "message": "Model package was not found"}, + ) + try: + return start_comfyui_model_mutation("install") + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + +@app.post( + "/api/providers/hub/packages/{package_id}/model/prepare-repair", + response_model=ModelOperationConfirmation, +) +def prepare_repair_provider_model(package_id: str) -> ModelOperationConfirmation: + if package_id != "hcs.comfyui-runtime": + raise HTTPException( + status_code=404, + detail={"code": "model_not_found", "message": "Model package was not found"}, + ) + try: + return prepare_comfyui_model_mutation("repair") + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + +@app.post( + "/api/providers/hub/packages/{package_id}/model/prepare-uninstall", + response_model=ModelOperationConfirmation, +) +def prepare_uninstall_provider_model(package_id: str) -> ModelOperationConfirmation: + if package_id != "hcs.comfyui-runtime": + raise HTTPException( + status_code=404, + detail={"code": "model_not_found", "message": "Model package was not found"}, + ) + try: + return prepare_comfyui_model_mutation("uninstall") + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + +@app.post( + "/api/providers/hub/packages/{package_id}/model/repair", + response_model=ProviderInstallStartResponse, +) +def repair_provider_model( + package_id: str, + request: ModelMutationConfirmationRequest, +) -> ProviderInstallStartResponse: + if package_id != "hcs.comfyui-runtime": + raise HTTPException( + status_code=404, + detail={"code": "model_not_found", "message": "Model package was not found"}, + ) + try: + return start_comfyui_model_mutation( + "repair", + confirmation_token=request.confirmation_token, + expected_model_identity=request.expected_model_identity, + ) + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + +@app.post( + "/api/providers/hub/packages/{package_id}/model/uninstall", + response_model=ProviderInstallStartResponse, +) +def uninstall_provider_model( + package_id: str, + request: ModelMutationConfirmationRequest, +) -> ProviderInstallStartResponse: + if package_id != "hcs.comfyui-runtime": + raise HTTPException( + status_code=404, + detail={"code": "model_not_found", "message": "Model package was not found"}, + ) + try: + return start_comfyui_model_mutation( + "uninstall", + confirmation_token=request.confirmation_token, + expected_model_identity=request.expected_model_identity, + ) + except ProviderHubError as error: + raise _provider_hub_http_error(error) from error + + +@app.post( + "/api/providers/hub/packages/{package_id}/generation/health", + response_model=ProviderHubItem, +) +def check_provider_generation_health(package_id: str) -> dict[str, Any]: + try: + return check_comfyui_generation_package(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: @@ -1688,6 +1811,58 @@ def read_media_manifest(project_id: str) -> AssetManifest: return read_model(project_id, "asset_manifest.json", AssetManifest) or AssetManifest() +@app.post( + "/api/projects/{project_id}/media/teaching-image", + response_model=VerifiedImageArtifact, +) +def create_teaching_image( + project_id: str, + request: TeachingImageRequest, + expected_revision: int | None = Query(default=None), +) -> VerifiedImageArtifact: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", project_id): + raise HTTPException(status_code=404, detail="Project not found") + root = _assert_project(project_id) + try: + if root.resolve(strict=True).parent != PROJECTS_DIR.resolve(strict=True): + raise HTTPException(status_code=404, detail="Project not found") + except OSError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + try: + with model_generation_guard(): + _assert_expected_revision(project_id, expected_revision) + artifact = generate_teaching_image( + root, + request, + before_publication=lambda: _assert_expected_revision( + project_id, expected_revision + ), + ) + invalidate_downstream( + project_id, + "media", + "A verified local teaching image changed; render, quality, and export are stale.", + ) + clear_stale_state(project_id, stages={"presentation", "media"}) + bump_project_revision(project_id) + except (TeachingImageError, ComfyUIModelError, ComfyUIRuntimeError) as exc: + status = { + "asset_id_conflict": 409, + "generation_timeout": 504, + "generation_failed": 502, + "runtime_not_ready": 409, + "runtime_not_running": 409, + "model_not_installed": 409, + "generation_not_ready": 409, + "workflow_not_ready": 409, + }.get(exc.code, 409) + raise HTTPException( + status_code=status, + detail={"code": exc.code, "message": exc.message}, + ) from exc + return artifact + + @app.get("/api/projects/{project_id}/media/review", response_class=HTMLResponse) def media_review_page(project_id: str) -> HTMLResponse: _assert_project(project_id) diff --git a/apps/api/src/hcs_api/models.py b/apps/api/src/hcs_api/models.py index 6146917..018f6ff 100644 --- a/apps/api/src/hcs_api/models.py +++ b/apps/api/src/hcs_api/models.py @@ -695,6 +695,76 @@ class GeneratedVideoAssetRecord(BaseModel): registered_at: str = Field(default_factory=utc_now_iso) +class TeachingImageProvenance(BaseModel): + """Pinned identities and controlled inputs for one local teaching image.""" + + model_config = ConfigDict( + extra="forbid", populate_by_name=True, serialize_by_alias=True + ) + + schema_: Literal["hanclassstudio.teaching_image_provenance.v1"] = Field( + default="hanclassstudio.teaching_image_provenance.v1", alias="schema" + ) + runtime_id: Literal["comfyui"] = "comfyui" + runtime_version: str + runtime_source_commit: str = Field(pattern=r"^[0-9a-f]{40}$") + runtime_installation_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_process_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_port: int = Field(ge=1024, le=65535) + model_package_id: str + model_version: str + model_manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + model_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + model_installation_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + workflow_pack_id: str + workflow_version: str + workflow_pack_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + request_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + execution_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + prompt_profile_id: str + positive_prompt: str + negative_prompt: str + seed: int = Field(ge=0, le=2**63 - 1) + steps: int = Field(gt=0, le=100) + cfg: float = Field(gt=0, le=30) + sampler_name: str + scheduler: str + denoise: float = Field(gt=0, le=1) + output_prefix: str = Field(pattern=r"^hcs_[0-9a-f]{20}_[0-9a-f]{12}$") + prompt_id: str = Field( + pattern=( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" + r"[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) + ) + source_trace: list[str] = Field(default_factory=list) + generated_at: str = Field(default_factory=utc_now_iso) + + +class VerifiedImageArtifact(BaseModel): + """Verified local PNG registered as a project-owned teaching asset.""" + + model_config = ConfigDict( + extra="forbid", populate_by_name=True, serialize_by_alias=True + ) + + schema_: Literal["hanclassstudio.verified_image_artifact.v1"] = Field( + default="hanclassstudio.verified_image_artifact.v1", alias="schema" + ) + artifact_id: str = Field(pattern=r"^img-[0-9a-f]{24}$") + asset_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$") + path: str + mime_type: Literal["image/png"] = "image/png" + width: int = Field(gt=0, le=4096) + height: int = Field(gt=0, le=4096) + size_bytes: int = Field(gt=0, le=32 * 1024**2) + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + provenance_ref: str + provenance_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + provenance: TeachingImageProvenance + registered_at: str = Field(default_factory=utc_now_iso) + + VideoGenerationFailureCode = Literal[ "approval_required", "approval_stale", @@ -746,6 +816,7 @@ class AssetFile(BaseModel): request_fingerprint: str | None = None presentation_theme_id: str | None = None presentation_theme_version: str | None = None + verified_image_artifact: VerifiedImageArtifact | None = None video_artifact: VideoArtifactRecord | None = None video_generation: GeneratedVideoAssetRecord | None = None diff --git a/apps/api/src/hcs_api/provider_hub.py b/apps/api/src/hcs_api/provider_hub.py index 493d627..a1214d7 100644 --- a/apps/api/src/hcs_api/provider_hub.py +++ b/apps/api/src/hcs_api/provider_hub.py @@ -49,6 +49,23 @@ start_runtime, stop_runtime, ) +from .comfyui_model import ( + ComfyUIModelError, + ModelOperationConfirmation, + ModelOperationSummary, + ModelPackageSnapshot, + consume_model_operation_confirmation, + model_generation_guard, + model_snapshot, + prepare_model_operation, + recover_model_installations, + run_model_install, + run_model_uninstall, +) +from .comfyui_teaching_image import ( + GenerationCapabilitySnapshot, + generation_capability_snapshot, +) from .models import ImageProviderSettings from .provider_registry import ( ProviderRegistryError, @@ -74,6 +91,7 @@ "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", ] TrustLevel = Literal[ "official_verified", "community_verified", "discovered_unverified", @@ -88,6 +106,7 @@ "verifying_download", "inspecting_archive", "verifying_extracted_tree", "creating_python_environment", "installing_dependencies", "validating_runtime", "publishing_runtime", "uninstalling_runtime", + "uninstalling_model", ] ErrorCode = Literal[ "network_error", "authentication_error", "rate_limited", "invalid_manifest", @@ -205,6 +224,10 @@ class ModelPackageSpec(BaseModel): version: str format: str safe_format: bool + source_revision: str | None = None + size_bytes: int | None = None + sha256: str | None = None + license_name: str | None = None class WorkflowPackSpec(BaseModel): @@ -281,6 +304,8 @@ class ProviderHubItem(BaseModel): runtime_ready: bool = False generation_ready: bool = False runtime_details: RuntimeSnapshot | None = None + model_details: ModelPackageSnapshot | None = None + generation_details: GenerationCapabilitySnapshot | None = None class ProviderHubCatalog(BaseModel): @@ -324,6 +349,7 @@ class ProviderInstallTask(BaseModel): task_id: str package_id: str operation: Literal["install", "repair", "uninstall"] = "install" + mutation_target: Literal["fixture", "runtime", "model"] = "fixture" state: TaskState = "queued" phase: InstallPhase = "preflight" progress: int = Field(default=0, ge=0, le=100) @@ -369,6 +395,16 @@ class RuntimeMutationConfirmationRequest(BaseModel): preserve_models: Literal[True] = True +class ModelMutationConfirmationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + confirmation_token: str = Field(pattern=r"^[0-9a-f]{64}$") + expected_model_identity: str = Field(pattern=r"^[0-9a-f]{64}$") + preserve_runtime: Literal[True] = True + preserve_projects: Literal[True] = True + preserve_other_models: Literal[True] = True + + def _normalize_online_model(value: str) -> str: normalized = value.strip() or "gpt-image-2" if normalized == "placeholder-svg": @@ -649,33 +685,61 @@ def _local_package_item(hardware: HardwareCapability) -> ProviderHubItem: def _comfyui_package_item(hardware: HardwareCapability) -> ProviderHubItem: try: snapshot = runtime_snapshot() + model = model_snapshot() + generation = generation_capability_snapshot() latest = latest_install_task(_COMFYUI_PACKAGE_ID, recover_interrupted=True) mutating = bool(latest and latest.state in {"queued", "running"}) - status: HubStatus = "installing" if mutating else snapshot.status + status: HubStatus + if mutating: + status = "installing" + elif generation.generation_ready: + status = "ready" + else: + status = snapshot.status actions: list[HubAction] if mutating: actions = ["cancel_install", "view_runtime_logs"] else: actions = list(snapshot.available_actions) + runtime_stopped = snapshot.actual_port is None and snapshot.status not in { + "starting", + "runtime_ready", + "stopping", + } + if runtime_stopped and snapshot.installed and not model.installed: + actions.append("install_model") + elif runtime_stopped and model.installed: + if snapshot.installed: + actions.append("repair_model") + 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" return ProviderHubItem( id=_COMFYUI_PACKAGE_ID, provider_id="comfyui_runtime", - name="ComfyUI 本地运行环境", - description="为本地 AI 图片模型提供运行基础;安装后仍需另行安装图片模型。", + name="ComfyUI 本地教学图片", + description="固定 Runtime、Stable Diffusion v1.5 FP16 模型与官方核心节点教学插图工作流。", provider_type="offline", - capabilities=["local_image_runtime"], + capabilities=[ + "local_image_runtime", + "teaching_illustration", + "vocabulary_image", + "classroom_scene", + ], trust_level="official_verified", registry_source="builtin", status=status, installed=snapshot.installed, - configured=snapshot.installed, - ready=False, + configured=snapshot.installed and model.installed, + ready=generation.generation_ready, runtime_ready=snapshot.runtime_ready, - generation_ready=False, + generation_ready=generation.generation_ready, runtime_details=snapshot, + model_details=model, + generation_details=generation, compatible=compatible, available_actions=actions, recommended=True, @@ -690,37 +754,79 @@ 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( - name="GPL-3.0-only", - url="https://github.com/Comfy-Org/ComfyUI/blob/700821e1364eaab0e8f21c538a2131719fec57bf/LICENSE", + name="GPL-3.0-only(Runtime)+ CreativeML Open RAIL-M(模型)", + url=( + "https://huggingface.co/spaces/CompVis/stable-diffusion-license/" + "blob/14d42d09bffd871b1666a084fc954a50cff72ac0/license.txt" + ), redistribution_allowed=True, clear=True, ), capability_package=CapabilityPackageSpec( id=_COMFYUI_PACKAGE_ID, - name="ComfyUI 本地运行环境", - description="固定官方源码、隔离依赖、loopback 进程和真实 API 健康检查。", + name="ComfyUI 本地教学图片", + description="一个固定 Runtime、一个固定模型包和一个固定官方核心节点工作流。", runtime=RuntimeSpec( id="comfyui", name="ComfyUI", version=snapshot.version, execution="managed_loopback_process", ), - model_packages=[], - workflow_packs=[], - healthcheck="managed process ownership + /system_stats + /object_info + pristine custom_nodes", + model_packages=[ + ModelPackageSpec( + id=model.package_id, + name=model.name, + version=model.version, + format="safetensors", + safe_format=True, + source_revision=model.model_source_revision, + size_bytes=model.model_size, + sha256=model.model_sha256, + license_name=model.model_license, + ) + ], + workflow_packs=[ + WorkflowPackSpec( + id=model.workflow_pack_id, + name="Stable Diffusion v1.5 教学插图核心工作流", + version=model.workflow_version, + capabilities=[ + "teaching_illustration", + "vocabulary_image", + "classroom_scene", + ], + ) + ], + healthcheck=( + "Runtime ownership + exact model SHA/SafeTensors + fixed workflow digest " + "+ seven core nodes + checkpoint inventory" + ), + ), + technical_error=generation.technical_error or model.technical_error or snapshot.technical_error, + 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) ), - technical_error=snapshot.technical_error, - last_health_check_at=(snapshot.last_health.checked_at if snapshot.last_health else None), ) - except (ComfyUIRuntimeError, ComfyUIArchiveError) as exc: + except ( + ComfyUIRuntimeError, + ComfyUIArchiveError, + ComfyUIModelError, + ) as exc: return ProviderHubItem( id=_COMFYUI_PACKAGE_ID, provider_id="comfyui_runtime", - name="ComfyUI 本地运行环境", - description="为本地 AI 图片模型提供运行基础;当前受控 Runtime manifest 无法验证。", + name="ComfyUI 本地教学图片", + description="固定 Runtime、模型或工作流合同当前无法验证。", provider_type="offline", capabilities=["local_image_runtime"], trust_level="official_verified", @@ -1019,26 +1125,55 @@ def latest_install_task(package_id: str, *, recover_interrupted: bool = False) - task = max(tasks, key=lambda item: item.started_at) if recover_interrupted and task.state in {"queued", "running"} and task.task_id not in _install_threads: if package_id == _COMFYUI_PACKAGE_ID: - try: - recover_comfyui_installations() - snapshot = runtime_snapshot(recover=False) - transaction_phase = runtime_transaction_phase(task.task_id) - completed = transaction_phase == "completed" or ( - task.operation == "uninstall" - and transaction_phase is None - and not snapshot.installed - ) - except (ComfyUIRuntimeError, ComfyUIArchiveError): - completed = False + if task.mutation_target == "model": + try: + recovery = recover_model_installations() + snapshot_model = model_snapshot(recover=False) + completed = ( + ("completed" in recovery or "published_state_committed" in recovery) + and task.operation != "uninstall" + and snapshot_model.model_ready + ) or ( + ("uninstall_completed" in recovery or "completed" in recovery) + and task.operation == "uninstall" + and not snapshot_model.installed + ) + except ComfyUIModelError: + completed = False + else: + try: + recover_comfyui_installations() + snapshot = runtime_snapshot(recover=False) + transaction_phase = runtime_transaction_phase(task.task_id) + completed = transaction_phase == "completed" or ( + task.operation == "uninstall" + and transaction_phase is None + and not snapshot.installed + ) + except (ComfyUIRuntimeError, ComfyUIArchiveError): + completed = False if completed: task.state, task.phase, task.progress = "completed", "completed", 100 - task.message = "中断的 Runtime 事务已恢复完成" + task.message = ( + "中断的模型事务已恢复完成" + if task.mutation_target == "model" + else "中断的 Runtime 事务已恢复完成" + ) task.error = None task.recoverable_actions = [] else: task.state, task.phase = "failed", "failed" - task.error = {"code": "installation_failed", "message": "Runtime mutation was interrupted and safely recovered."} - task.recoverable_actions = ["repair_runtime"] + task.error = { + "code": "installation_failed", + "message": ( + "Model mutation was interrupted and safely recovered." + if task.mutation_target == "model" + else "Runtime mutation was interrupted and safely recovered." + ), + } + task.recoverable_actions = [ + "repair_model" if task.mutation_target == "model" else "repair_runtime" + ] else: task.state, task.phase = "failed", "failed" task.error = {"code": "installation_failed", "message": "Installation was interrupted; start again."} @@ -1071,7 +1206,17 @@ def _update_install_task(task_id: str, *, state: TaskState | None = None, phase: if task.state in {"completed", "failed", "cancelled"}: task.finished_at = task.updated_at task.cancellable = False - task.recoverable_actions = ["repair"] if task.state == "failed" else [] + task.recoverable_actions = ( + [ + "repair_model" + if task.mutation_target == "model" + else "repair_runtime" + if task.mutation_target == "runtime" + else "repair" + ] + if task.state == "failed" + else [] + ) _save_install_task(task) return task @@ -1338,6 +1483,7 @@ def start_comfyui_mutation( task_id=uuid.uuid4().hex, package_id=_COMFYUI_PACKAGE_ID, operation=operation, + mutation_target="runtime", state="queued", phase="preflight", message=messages[operation], @@ -1358,6 +1504,161 @@ def start_comfyui_mutation( return ProviderInstallStartResponse(task=task, provider=provider) +def _run_comfyui_model_mutation( + task_id: str, + confirmation: ModelOperationSummary | None = None, +) -> None: + task = get_install_task(task_id) + + def cancel() -> None: + if task_id in _cancelled_tasks: + raise ComfyUIModelError("cancelled", "Model installation was cancelled") + + def progress( + phase: str, + percent: int, + message: str, + current: int | None, + total: int | None, + ) -> None: + kwargs: dict[str, Any] = { + "state": "running", + "phase": phase, + "progress": percent, + "message": message, + } + if current is not None: + kwargs["downloaded_bytes"] = current + if total is not None: + kwargs["total_bytes"] = total + kwargs["current_file_progress"] = ( + int(current * 100 / total) if current is not None and total else 0 + ) + _update_install_task(task_id, **kwargs) + cancel() + + try: + if task.operation == "uninstall": + run_model_uninstall( + task_id, + progress=progress, + cancel=cancel, + confirmation=confirmation, + ) + message = "固定教学图片模型已卸载;Runtime 与项目数据均已保留" + else: + run_model_install( + task_id, + operation=task.operation, + progress=progress, + cancel=cancel, + confirmation=confirmation, + ) + message = "固定教学图片模型与工作流已验证;启动 Runtime 后可生成" + _update_install_task( + task_id, + state="completed", + phase="completed", + progress=100, + current_file_progress=100, + message=message, + ) + except ComfyUIModelError as exc: + state: TaskState = "cancelled" if exc.code == "cancelled" else "failed" + phase: InstallPhase = "cancelled" if exc.code == "cancelled" else "failed" + _update_install_task( + task_id, + state=state, + phase=phase, + message=exc.message, + error={"code": exc.code, "message": exc.message}, + ) + except Exception: + _update_install_task( + task_id, + state="failed", + phase="failed", + message="Model mutation failed unexpectedly", + error={"code": "internal_error", "message": "Model mutation failed unexpectedly"}, + ) + finally: + with _install_lock: + _install_threads.pop(task_id, None) + _cancelled_tasks.discard(task_id) + + +def prepare_comfyui_model_mutation( + operation: Literal["repair", "uninstall"], +) -> ModelOperationConfirmation: + try: + return prepare_model_operation(operation) + except ComfyUIModelError as exc: + raise ProviderHubError(exc.code, exc.message) from exc + + +def start_comfyui_model_mutation( + operation: Literal["install", "repair", "uninstall"], + *, + confirmation_token: str | None = None, + expected_model_identity: str | None = None, +) -> ProviderInstallStartResponse: + with _install_lock: + active = latest_install_task(_COMFYUI_PACKAGE_ID) + if active and active.state in {"queued", "running"} and active.task_id in _install_threads: + raise ProviderHubError("task_conflict", "A ComfyUI package mutation is already running") + runtime = runtime_snapshot() + model = model_snapshot() + if operation == "install": + if not runtime.installed: + raise ProviderHubError("runtime_not_installed", "Install the fixed Runtime first") + if model.installed: + 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") + confirmation: ModelOperationSummary | None = None + if operation in {"repair", "uninstall"}: + if not confirmation_token or not expected_model_identity: + raise ProviderHubError( + "confirmation_invalid", "A backend model confirmation is required" + ) + try: + confirmation = consume_model_operation_confirmation( + operation, + confirmation_token, + expected_model_identity, + ) + except ComfyUIModelError as exc: + raise ProviderHubError(exc.code, exc.message) from exc + now = _iso() + task = ProviderInstallTask( + task_id=uuid.uuid4().hex, + package_id=_COMFYUI_PACKAGE_ID, + operation=operation, + mutation_target="model", + state="queued", + phase="preflight", + message={ + "install": "固定教学图片模型安装任务已排队", + "repair": "固定教学图片模型修复任务已排队", + "uninstall": "固定教学图片模型卸载任务已排队", + }[operation], + started_at=now, + updated_at=now, + log_ref="provider-hub-model:comfyui-sd15", + ) + _save_install_task(task) + thread = threading.Thread( + target=_run_comfyui_model_mutation, + args=(task.task_id, confirmation), + daemon=True, + name=f"hcs-comfyui-model-{operation}-{task.task_id[:8]}", + ) + _install_threads[task.task_id] = thread + provider = _comfyui_package_item(detect_hardware()) + thread.start() + return ProviderInstallStartResponse(task=task, provider=provider) + + def start_fixture_install(package_id: str) -> ProviderInstallStartResponse: if package_id == _COMFYUI_PACKAGE_ID: return start_comfyui_mutation("install") @@ -1421,11 +1722,27 @@ def check_local_health(package_id: str) -> ProviderHubItem: return _local_package_item(detect_hardware()) +def check_comfyui_generation_package(package_id: str) -> ProviderHubItem: + if package_id != _COMFYUI_PACKAGE_ID: + raise ProviderHubError("runtime_not_found", "Runtime package was not found") + capability = generation_capability_snapshot(deep=True) + if not capability.generation_ready: + error = capability.technical_error or { + "code": "generation_not_ready", + "message": "Runtime, model, and workflow are not jointly ready", + } + raise ProviderHubError(error["code"], error["message"]) + return _comfyui_package_item(detect_hardware()) + + 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") try: - start_runtime() + with model_generation_guard(): + start_runtime() + if model_snapshot().model_ready: + generation_capability_snapshot(deep=True) except ComfyUIRuntimeError as exc: raise ProviderHubError(exc.code, exc.message) from exc return _comfyui_package_item(detect_hardware()) @@ -1435,7 +1752,8 @@ def stop_comfyui_runtime_package(package_id: str, *, force: bool = False) -> Pro if package_id != _COMFYUI_PACKAGE_ID: raise ProviderHubError("runtime_not_found", "Runtime package was not found") try: - stop_runtime(force=force) + with model_generation_guard(): + stop_runtime(force=force) except ComfyUIRuntimeError as exc: raise ProviderHubError(exc.code, exc.message) from exc return _comfyui_package_item(detect_hardware()) diff --git a/apps/api/tests/test_comfyui_image_real_opt_in.py b/apps/api/tests/test_comfyui_image_real_opt_in.py new file mode 100644 index 0000000..76dcadd --- /dev/null +++ b/apps/api/tests/test_comfyui_image_real_opt_in.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import hashlib +import json +import os +import platform +import shutil +import time +from pathlib import Path + +import hcs_api.comfyui_model as model +import hcs_api.comfyui_runtime as runtime +import pytest +from hcs_api import storage +from hcs_api.comfyui_archive import load_runtime_manifest +from hcs_api.comfyui_teaching_image import ( + TeachingImageRequest, + generate_teaching_image, + generation_capability_snapshot, +) + +pytestmark = pytest.mark.skipif( + os.environ.get("HCS_RUN_REAL_COMFYUI_IMAGE") != "1", + reason="set HCS_RUN_REAL_COMFYUI_IMAGE=1 for the large local image lifecycle", +) + + +def _sha(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(8 * 1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def test_real_fixed_model_workflow_generation_and_manifest( + tmp_path: Path, monkeypatch +) -> None: + if platform.system() != "Darwin" or platform.machine().lower() not in { + "arm64", + "aarch64", + }: + pytest.skip("real Phase 2C adapter is enabled only for macOS Apple Silicon") + if shutil.disk_usage(tmp_path).free < 10 * 1024**3: + pytest.skip("real Phase 2C lifecycle requires at least 10 GB free disk") + + root = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", root) + monkeypatch.setattr(storage, "PROJECTS_DIR", root / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", root / "config") + monkeypatch.setattr( + storage, + "PROVIDER_SETTINGS_PATH", + root / "config/provider_settings.json", + ) + controlled_temp = root / "tmp" + controlled_temp.mkdir(parents=True) + monkeypatch.setenv("TMPDIR", str(controlled_temp)) + runtime_manifest = load_runtime_manifest() + model_manifest = model.load_model_manifest() + toolchain_cache_value = os.environ.get("HCS_COMFYUI_REAL_TOOLCHAIN_CACHE") + if toolchain_cache_value: + toolchain_cache = Path(toolchain_cache_value).resolve() + controlled_cache_root = (storage.ROOT_DIR / ".workbuddy").resolve() + try: + toolchain_cache.relative_to(controlled_cache_root) + except ValueError: + pytest.fail("real toolchain cache must remain inside .workbuddy") + runtime.verify_python_toolchain(runtime_manifest, toolchain_cache) + + def prepare_cached_toolchain(destination: Path, current_manifest, cancel): + cancel() + shutil.copytree(toolchain_cache, destination) + return runtime.verify_python_toolchain(current_manifest, destination) + + monkeypatch.setattr(runtime, "TOOLCHAIN_PREPARER", prepare_cached_toolchain) + + report: dict[str, object] = { + "schema": "hanclassstudio.comfyui_image_real_validation.v1", + "validated_at": runtime._iso(), + "platform": "macos", + "architecture": "arm64", + "runtime_version": runtime_manifest.version, + "runtime_commit": runtime_manifest.source_commit, + "model_package_id": model_manifest.package_id, + "model_source_revision": model_manifest.source.revision, + "model_size": model_manifest.source.size, + "model_sha256": model_manifest.source.sha256, + "model_license": model_manifest.license.name, + "workflow_pack_id": model.WORKFLOW_PACK_ID, + "workflow_pack_sha256": model.WORKFLOW_PACK_SHA256, + "custom_nodes_allowed": False, + } + model_cache_value = os.environ.get("HCS_COMFYUI_REAL_MODEL_CACHE") + if model_cache_value: + model_cache = Path(model_cache_value).resolve() + controlled_cache_root = (storage.ROOT_DIR / ".workbuddy").resolve() + try: + model_cache.relative_to(controlled_cache_root) + except ValueError: + pytest.fail("real model cache must remain inside .workbuddy") + cached_weight = model_cache / model_manifest.source.file_name + cached_license = model_cache / model_manifest.license.installed_file_name + assert cached_weight.stat().st_size == model_manifest.source.size + assert _sha(cached_weight) == model_manifest.source.sha256 + assert cached_license.stat().st_size == model_manifest.license.size + assert _sha(cached_license) == model_manifest.license.sha256 + model.inspect_safetensors(cached_weight, model_manifest) + + def copy_verified_model_cache( + destination_model: Path, + destination_license: Path, + _manifest, + progress, + cancel, + ) -> None: + cancel() + shutil.copyfile(cached_weight, destination_model) + progress( + model_manifest.source.size, + model_manifest.source.size + model_manifest.license.size, + ) + shutil.copyfile(cached_license, destination_license) + progress( + model_manifest.source.size + model_manifest.license.size, + model_manifest.source.size + model_manifest.license.size, + ) + + monkeypatch.setattr(model, "MODEL_DOWNLOADER", copy_verified_model_cache) + report["initial_install_used_verified_local_cache"] = True + runtime_installed = False + model_installed = False + running = False + artifact_path: Path | None = None + try: + started = time.monotonic() + runtime.run_runtime_install("phase2c-real-runtime") + runtime_installed = True + report["runtime_install_seconds"] = round(time.monotonic() - started, 3) + + started = time.monotonic() + installed_model = model.run_model_install("phase2c-real-model") + model_installed = True + report["model_install_seconds"] = round(time.monotonic() - started, 3) + report["model_installation_identity"] = model.model_installation_identity( + installed_model + ) + model.validate_model_installation(deep=True) + + repair_cache = root / "repair-cache" + repair_cache.mkdir() + cached_model = repair_cache / model_manifest.source.file_name + cached_license = repair_cache / model_manifest.license.installed_file_name + shutil.copyfile( + root + / "provider-models/comfyui/checkpoints" + / model_manifest.source.installed_file_name, + cached_model, + ) + shutil.copyfile( + root + / "provider-models/comfyui/licenses" + / model_manifest.license.installed_file_name, + cached_license, + ) + assert cached_model.stat().st_size == model_manifest.source.size + assert _sha(cached_model) == model_manifest.source.sha256 + assert _sha(cached_license) == model_manifest.license.sha256 + + def copy_verified_repair( + destination_model: Path, + destination_license: Path, + _manifest, + progress, + cancel, + ) -> None: + cancel() + shutil.copyfile(cached_model, destination_model) + progress(model_manifest.source.size, model_manifest.source.size + model_manifest.license.size) + shutil.copyfile(cached_license, destination_license) + progress( + model_manifest.source.size + model_manifest.license.size, + model_manifest.source.size + model_manifest.license.size, + ) + + started = time.monotonic() + health = runtime.start_runtime() + running = True + report["runtime_start_seconds"] = round(time.monotonic() - started, 3) + assert health.healthy is True + capability = generation_capability_snapshot(deep=True) + assert capability.runtime_ready is True + assert capability.model_ready is True + assert capability.workflow_ready is True + assert capability.generation_ready is True + report["generation_capability"] = capability.model_dump( + mode="json", by_alias=True + ) + + project = storage.ensure_project("phase2c-real") + request = TeachingImageRequest( + asset_id="classroom-greeting", + purpose="classroom_scene", + subject="two students and one teacher", + action="the students smile, wave, and greet the teacher", + environment="a bright uncluttered classroom", + aspect_ratio="4:3", + seed=20260726, + source_trace=["manual:phase-2c-real-validation"], + ) + started = time.monotonic() + artifact = generate_teaching_image(project, request) + report["generation_seconds"] = round(time.monotonic() - started, 3) + artifact_path = project / artifact.path + assert artifact_path.is_file() + assert artifact_path.stat().st_size == artifact.size_bytes + assert _sha(artifact_path) == artifact.sha256 + assert artifact.width == 512 and artifact.height == 384 + saved_manifest = json.loads( + (project / "assets/data/asset_manifest.json").read_text() + ) + assert ( + saved_manifest["images"][0]["verified_image_artifact"]["sha256"] + == artifact.sha256 + ) + report["verified_image_artifact"] = artifact.model_dump( + mode="json", by_alias=True + ) + + output_value = os.environ.get("HCS_COMFYUI_IMAGE_REAL_OUTPUT_DIR") + if output_value: + output = Path(output_value).resolve() + output.mkdir(parents=True, exist_ok=True) + shutil.copyfile(artifact_path, output / "phase2c-classroom-greeting.png") + report["retained_image"] = str( + output / "phase2c-classroom-greeting.png" + ) + + runtime.stop_runtime() + running = False + assert generation_capability_snapshot().generation_ready is False + + confirmation = model.prepare_model_operation("repair") + summary = model.consume_model_operation_confirmation( + "repair", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + original_downloader = model.MODEL_DOWNLOADER + monkeypatch.setattr(model, "MODEL_DOWNLOADER", copy_verified_repair) + started = time.monotonic() + repaired = model.run_model_install( + "phase2c-real-model-repair", + operation="repair", + confirmation=summary, + ) + report["model_repair_seconds"] = round(time.monotonic() - started, 3) + report["repair_used_verified_local_cache"] = True + report["repaired_model_installation_identity"] = ( + model.model_installation_identity(repaired) + ) + model.validate_model_installation(deep=True) + monkeypatch.setattr(model, "MODEL_DOWNLOADER", original_downloader) + + started = time.monotonic() + repaired_health = runtime.start_runtime() + running = True + report["post_repair_runtime_start_seconds"] = round( + time.monotonic() - started, 3 + ) + assert repaired_health.healthy is True + post_repair_capability = generation_capability_snapshot(deep=True) + assert post_repair_capability.generation_ready is True + report["post_repair_generation_capability"] = ( + post_repair_capability.model_dump(mode="json", by_alias=True) + ) + runtime.stop_runtime() + running = False + assert generation_capability_snapshot().generation_ready is False + + confirmation = model.prepare_model_operation("uninstall") + summary = model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + model.run_model_uninstall( + "phase2c-real-model-uninstall", confirmation=summary + ) + model_installed = False + assert model.model_snapshot().installed is False + assert artifact_path.is_file() + + confirmation_runtime = runtime.prepare_runtime_operation("uninstall") + runtime_summary = runtime.consume_runtime_operation_confirmation( + "uninstall", + confirmation_runtime.confirmation_token, + confirmation_runtime.summary.installation_identity, + ) + runtime.run_runtime_uninstall( + "phase2c-real-runtime-uninstall", + confirmation=runtime_summary, + ) + runtime_installed = False + assert runtime.runtime_snapshot().installed is False + assert artifact_path.is_file() + report["lifecycle_cleanup"] = { + "model_removed": True, + "runtime_removed": True, + "project_image_preserved": True, + } + except (model.ComfyUIModelError, runtime.ComfyUIRuntimeError) as exc: + report["error"] = {"code": exc.code, "message": exc.message} + report["runtime_log_tail"] = runtime.log_summary("runtime", max_lines=60) + report["install_log_tail"] = runtime.log_summary("install", max_lines=30) + raise + finally: + if running: + try: + runtime.stop_runtime(force=True) + except runtime.ComfyUIRuntimeError: + pass + if model_installed: + try: + confirmation = model.prepare_model_operation("uninstall") + summary = model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + model.run_model_uninstall( + "phase2c-real-model-cleanup", confirmation=summary + ) + except model.ComfyUIModelError: + pass + if runtime_installed: + try: + confirmation = runtime.prepare_runtime_operation("uninstall") + summary = runtime.consume_runtime_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + runtime.run_runtime_uninstall( + "phase2c-real-runtime-cleanup", confirmation=summary + ) + except runtime.ComfyUIRuntimeError: + pass + report_value = os.environ.get("HCS_COMFYUI_IMAGE_REAL_REPORT") + if report_value: + report_path = Path(report_value) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(report, indent=2), encoding="utf-8") diff --git a/apps/api/tests/test_comfyui_model.py b/apps/api/tests/test_comfyui_model.py new file mode 100644 index 0000000..20f2d79 --- /dev/null +++ b/apps/api/tests/test_comfyui_model.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import hcs_api.comfyui_model as model +import hcs_api.storage as storage + + +def _sha(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _safetensors() -> bytes: + header = json.dumps( + { + "__metadata__": { + "modelspec.architecture": "stable-diffusion-v1", + "modelspec.resolution": "512x512", + "modelspec.license": "CreativeML Open RAIL-M", + "format": "pt", + }, + "fixture": { + "dtype": "F16", + "shape": [], + "data_offsets": [0, 2], + }, + }, + separators=(",", ":"), + ).encode() + return len(header).to_bytes(8, "little") + header + b"\0\0" + + +def _isolate(tmp_path: Path, monkeypatch) -> tuple[model.ComfyUIModelManifest, bytes, bytes]: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + model._CONFIRMATIONS.clear() + manifest = model.load_model_manifest().model_copy(deep=True) + weight = _safetensors() + license_text = b"CreativeML Open RAIL-M fixture\n" + header_size = int.from_bytes(weight[:8], "little") + manifest.source.size = len(weight) + manifest.source.sha256 = _sha(weight) + manifest.license.size = len(license_text) + manifest.license.sha256 = _sha(license_text) + 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, + "RUNTIME_SNAPSHOT", + lambda: SimpleNamespace( + installed=True, + version="0.28.0", + source_commit="700821e1364eaab0e8f21c538a2131719fec57bf", + actual_port=None, + status="stopped", + ), + ) + monkeypatch.setattr( + model, + "DISK_USAGE", + lambda _path: SimpleNamespace(free=10 * 1024**3), + ) + + def download( + model_path: Path, + license_path: Path, + _manifest, + progress, + cancel, + ) -> None: + cancel() + model_path.write_bytes(weight) + license_path.write_bytes(license_text) + progress(len(weight) + len(license_text), len(weight) + len(license_text)) + + monkeypatch.setattr(model, "MODEL_DOWNLOADER", download) + return manifest, weight, license_text + + +def test_fixed_model_and_workflow_contracts_are_digest_pinned() -> None: + manifest = model.load_model_manifest() + workflow = model.load_workflow_pack() + assert manifest.source.size == 2_132_696_762 + assert manifest.source.sha256 == ( + "e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916" + ) + assert manifest.license.name == "CreativeML Open RAIL-M" + assert workflow.model_package_id == manifest.package_id + assert [node.class_type for node in workflow.nodes] == [ + "CheckpointLoaderSimple", + "CLIPTextEncode", + "CLIPTextEncode", + "EmptyLatentImage", + "KSampler", + "VAEDecode", + "SaveImage", + ] + + +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") + assert model.validate_model_installation(deep=True).model_sha256 == manifest.source.sha256 + assert model.model_snapshot().model_ready is True + assert (storage.RUNTIME_DIR / "provider-models/comfyui/checkpoints" / manifest.source.installed_file_name).read_bytes() == weight + + confirmation = model.prepare_model_operation("repair") + summary = model.consume_model_operation_confirmation( + "repair", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + repaired = model.run_model_install( + "repair-fixture", + operation="repair", + confirmation=summary, + ) + assert repaired.model_identity.inode != installed.model_identity.inode + + target = storage.RUNTIME_DIR / "provider-models/comfyui/checkpoints" / manifest.source.installed_file_name + target.write_bytes(weight[:-1] + b"x") + assert model.model_snapshot(deep=True).status == "repair_required" + confirmation = model.prepare_model_operation("repair") + summary = model.consume_model_operation_confirmation( + "repair", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + repaired = model.run_model_install( + "repair-corrupt-fixture", + operation="repair", + confirmation=summary, + ) + model.validate_model_installation(deep=True) + + confirmation = model.prepare_model_operation("uninstall") + summary = model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + model.run_model_uninstall("uninstall-fixture", confirmation=summary) + assert not target.exists() + assert model.model_snapshot().installed is False + assert repaired.package_id == model.MODEL_PACKAGE_ID + + +def test_failed_repair_rolls_back_owned_files_and_cleans_partial_download( + tmp_path, monkeypatch +) -> None: + manifest, weight, _license = _isolate(tmp_path, monkeypatch) + original = model.run_model_install("install-fixture") + confirmation = model.prepare_model_operation("repair") + summary = model.consume_model_operation_confirmation( + "repair", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + + def fail_download(model_path, _license_path, _manifest, _progress, _cancel): + model_path.write_bytes(b"partial") + raise model.ComfyUIModelError("download_failed", "fixture failure") + + monkeypatch.setattr(model, "MODEL_DOWNLOADER", fail_download) + with pytest.raises(model.ComfyUIModelError, match="fixture failure"): + model.run_model_install( + "repair-failure", + operation="repair", + confirmation=summary, + ) + restored = model.validate_model_installation(deep=True) + assert restored.model_identity == original.model_identity + root = storage.RUNTIME_DIR / "provider-models/comfyui" + assert not list(root.glob(".hcs-*.download")) + assert ( + root / "checkpoints" / manifest.source.installed_file_name + ).read_bytes() == weight + + +def test_confirmation_is_single_use_and_identity_bound(tmp_path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + model.run_model_install("install-fixture") + confirmation = model.prepare_model_operation("uninstall") + with pytest.raises(model.ComfyUIModelError, match="does not match"): + model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + "0" * 64, + ) + with pytest.raises(model.ComfyUIModelError, match="already used"): + model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + + +def test_model_install_rejects_replaced_physical_parent(tmp_path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + storage.RUNTIME_DIR.mkdir(parents=True) + outside = tmp_path / "outside-models" + outside.mkdir() + (storage.RUNTIME_DIR / "provider-models").symlink_to(outside, target_is_directory=True) + with pytest.raises(model.ComfyUIModelError, match="identity changed"): + model.run_model_install("unsafe-parent") + assert not list(outside.rglob("*")) + + +def test_cancelled_model_install_rolls_back_before_publish(tmp_path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + + def cancel() -> None: + raise model.ComfyUIModelError("cancelled", "fixture cancellation") + + with pytest.raises(model.ComfyUIModelError, match="fixture cancellation"): + model.run_model_install("cancelled-install", cancel=cancel) + assert model.model_snapshot().installed is False + root = storage.RUNTIME_DIR / "provider-models/comfyui" + assert not list(root.glob(".hcs-*.download")) + + +def test_recovery_commits_verified_publish_after_process_loss(tmp_path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + original_write = model._write_installation + crashed = False + + class SimulatedProcessLoss(BaseException): + pass + + def crash_before_state_commit(record) -> None: + nonlocal crashed + if not crashed: + crashed = True + raise SimulatedProcessLoss + original_write(record) + + monkeypatch.setattr(model, "_write_installation", crash_before_state_commit) + with pytest.raises(SimulatedProcessLoss): + model.run_model_install("publish-crash") + assert model._read_journal().phase == "model_published" + monkeypatch.setattr(model, "_write_installation", original_write) + assert model.recover_model_installations() == ["published_state_committed"] + assert model.validate_model_installation(deep=True).package_id == model.MODEL_PACKAGE_ID + + +def test_recovery_finishes_interrupted_owned_uninstall(tmp_path, monkeypatch) -> None: + _isolate(tmp_path, monkeypatch) + model.run_model_install("install-fixture") + confirmation = model.prepare_model_operation("uninstall") + summary = model.consume_model_operation_confirmation( + "uninstall", + confirmation.confirmation_token, + confirmation.summary.installation_identity, + ) + original_unlink = model._unlink_if_owned + crashed = False + + class SimulatedProcessLoss(BaseException): + pass + + def crash_after_first_unlink(path, identity) -> None: + nonlocal crashed + original_unlink(path, identity) + if ( + not crashed + and path.name == model.MODEL_MANIFEST_LOADER().source.installed_file_name + ): + crashed = True + raise SimulatedProcessLoss + + monkeypatch.setattr(model, "_unlink_if_owned", crash_after_first_unlink) + with pytest.raises(SimulatedProcessLoss): + model.run_model_uninstall("uninstall-crash", confirmation=summary) + monkeypatch.setattr(model, "_unlink_if_owned", original_unlink) + assert model.recover_model_installations() == ["uninstall_completed"] + assert model.model_snapshot().installed is False diff --git a/apps/api/tests/test_comfyui_teaching_image.py b/apps/api/tests/test_comfyui_teaching_image.py new file mode 100644 index 0000000..00c195a --- /dev/null +++ b/apps/api/tests/test_comfyui_teaching_image.py @@ -0,0 +1,819 @@ +from __future__ import annotations + +import hashlib +import json +import struct +import threading +import time +import zlib +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import SimpleNamespace + +import hcs_api.comfyui_teaching_image as images +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from hcs_api import main, storage +from hcs_api.comfyui_model import ( + MODEL_MANIFEST_SHA256, + WORKFLOW_PACK_SHA256, + ModelFileIdentity, + ModelInstallationRecord, + load_workflow_pack, +) +from hcs_api.comfyui_runtime import RuntimeDirectoryIdentity, RuntimeHealthSnapshot +from hcs_api.main import app +from pydantic import ValidationError + + +def _chunk(name: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + name + + payload + + struct.pack(">I", zlib.crc32(name + payload) & 0xFFFFFFFF) + ) + + +def _png( + width: int = 512, + height: int = 384, + *, + text: bool = False, + bit_depth: int = 8, + color_type: int = 2, +) -> bytes: + scanline = b"\0" + b"\xff\x80\x20" * width + raw = scanline * height + parts = [ + images._PNG_SIGNATURE, + _chunk( + b"IHDR", + struct.pack( + ">IIBBBBB", width, height, bit_depth, color_type, 0, 0, 0 + ), + ), + ] + if text: + parts.append(_chunk(b"tEXt", b"prompt\0forbidden")) + parts.extend([_chunk(b"IDAT", zlib.compress(raw)), _chunk(b"IEND", b"")]) + return b"".join(parts) + + +def _record() -> ModelInstallationRecord: + file_identity = ModelFileIdentity( + device=1, inode=2, size=10, mtime_ns=3, ctime_ns=4 + ) + return ModelInstallationRecord( + manifest_sha256=MODEL_MANIFEST_SHA256, + model_relative_path="checkpoints/hcs-sd-v1-5-pruned-emaonly-fp16.safetensors", + model_sha256="e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + model_size=2_132_696_762, + license_relative_path="licenses/CreativeML-OpenRAIL-M.txt", + license_sha256="be351ebe7ac01bcdbb018639aadcfd38f136b7dc3f2a3d4d3a24db51d1b210ef", + workflow_pack_sha256=WORKFLOW_PACK_SHA256, + parent_directory_identity=RuntimeDirectoryIdentity(device=1, inode=4), + root_identity=RuntimeDirectoryIdentity(device=1, inode=1), + model_identity=file_identity, + license_identity=ModelFileIdentity( + device=1, inode=3, size=10, mtime_ns=3, ctime_ns=4 + ), + installed_at="2026-07-26T00:00:00+00:00", + ) + + +def _request() -> images.TeachingImageRequest: + return images.TeachingImageRequest( + asset_id="classroom-greeting", + purpose="classroom_scene", + subject="two students and one teacher", + action="the students wave and greet the teacher", + environment="a bright uncluttered classroom", + aspect_ratio="4:3", + seed=20260726, + source_trace=["manual:phase-2c-real-validation"], + ) + + +def _ready_runtime(monkeypatch) -> None: + monkeypatch.setattr( + images, + "runtime_snapshot", + lambda **_kwargs: SimpleNamespace( + runtime_ready=True, + installed=True, + version="0.28.0", + source_commit="700821e1364eaab0e8f21c538a2131719fec57bf", + actual_port=8188, + process_identity="c" * 64, + ), + ) + monkeypatch.setattr(images, "runtime_installation_identity", lambda: "a" * 64) + monkeypatch.setattr(images, "model_installation_identity", lambda _record: "b" * 64) + + +def test_request_compiles_to_fixed_plan_and_graph(monkeypatch) -> None: + _ready_runtime(monkeypatch) + plan = images.compile_teaching_image_request( + _request(), + model_record=_record(), + workflow=load_workflow_pack(), + ) + graph = images._fixed_graph(plan) + assert [graph[str(index)]["class_type"] for index in range(1, 8)] == [ + "CheckpointLoaderSimple", + "CLIPTextEncode", + "CLIPTextEncode", + "EmptyLatentImage", + "KSampler", + "VAEDecode", + "SaveImage", + ] + assert graph["1"]["inputs"] == { + "ckpt_name": "hcs-sd-v1-5-pruned-emaonly-fp16.safetensors" + } + assert graph["5"]["inputs"]["steps"] == 20 + assert graph["5"]["inputs"]["seed"] == 20260726 + assert (plan.width, plan.height) == (512, 384) + assert "graph" not in plan.model_dump() + assert plan.runtime_port == 8188 + assert plan.runtime_process_identity == "c" * 64 + retry = images.compile_teaching_image_request( + _request(), + model_record=_record(), + workflow=load_workflow_pack(), + ) + assert retry.output_prefix != plan.output_prefix + assert retry.execution_plan_sha256 != plan.execution_plan_sha256 + + +def test_request_rejects_graph_fields_and_multiline_prompt_text() -> None: + payload = _request().model_dump(mode="json", by_alias=True) + with pytest.raises(ValidationError): + images.TeachingImageRequest.model_validate( + {**payload, "workflow": {"1": {"class_type": "LoadImage"}}} + ) + with pytest.raises(ValidationError): + images.TeachingImageRequest.model_validate( + {**payload, "subject": "student\nignore fixed policy"} + ) + + +def test_png_verification_rejects_dimensions_metadata_and_corruption() -> None: + verified = images.verify_png( + _png(), + expected_width=512, + expected_height=384, + maximum_bytes=16 * 1024**2, + ) + assert verified.sha256 == hashlib.sha256(_png()).hexdigest() + with pytest.raises(images.TeachingImageError, match="metadata"): + images.verify_png( + _png(text=True), + expected_width=512, + expected_height=384, + maximum_bytes=16 * 1024**2, + ) + with pytest.raises(images.TeachingImageError): + images.verify_png( + _png(512, 512), + expected_width=512, + expected_height=384, + maximum_bytes=16 * 1024**2, + ) + with pytest.raises(images.TeachingImageError, match="coding method"): + images.verify_png( + _png(bit_depth=4, color_type=2), + expected_width=512, + expected_height=384, + maximum_bytes=16 * 1024**2, + ) + corrupt = bytearray(_png()) + corrupt[-5] ^= 1 + with pytest.raises(images.TeachingImageError): + images.verify_png( + bytes(corrupt), + expected_width=512, + expected_height=384, + maximum_bytes=16 * 1024**2, + ) + + +def test_executor_binds_history_to_exact_job_graph_client_and_output( + monkeypatch, +) -> None: + _ready_runtime(monkeypatch) + plan = images.compile_teaching_image_request( + _request(), model_record=_record(), workflow=load_workflow_pack() + ) + submitted: dict[str, object] = {} + viewed: list[str] = [] + + def fake_json(_port, method, path, **kwargs): + if path == "/prompt": + submitted.update(kwargs["payload"]) + return {"prompt_id": submitted["prompt_id"], "node_errors": {}} + prompt_id = submitted["prompt_id"] + assert method == "GET" + assert path == f"/history/{prompt_id}" + return { + prompt_id: { + "status": {"status_str": "success", "completed": True}, + "prompt": [ + 1, + prompt_id, + submitted["prompt"], + {"client_id": submitted["client_id"]}, + ["7"], + ], + "outputs": { + "7": { + "images": [ + { + "filename": f"{plan.output_prefix}_00001_.png", + "subfolder": "", + "type": "output", + } + ] + } + }, + } + } + + monkeypatch.setattr(images, "_http_json", fake_json) + monkeypatch.setattr( + images, + "_http_image", + lambda _port, query, _maximum: viewed.append(query) or _png(), + ) + payload, prompt_id = images._execute_fixed_plan( + plan, 8188, 16 * 1024**2 + ) + assert payload == _png() + assert prompt_id == submitted["prompt_id"] + assert submitted["prompt"] == images._fixed_graph(plan) + assert len(viewed) == 1 + + +@pytest.mark.parametrize( + "mismatch", + ["client", "graph", "prior_output", "path_traversal"], +) +def test_executor_rejects_mismatched_history_and_cancels_only_its_job( + monkeypatch, mismatch: str +) -> None: + _ready_runtime(monkeypatch) + plan = images.compile_teaching_image_request( + _request(), model_record=_record(), workflow=load_workflow_pack() + ) + submitted: dict[str, object] = {} + cancelled: list[str] = [] + fetched: list[str] = [] + + def fake_json(_port, method, path, **kwargs): + if path == "/prompt": + submitted.update(kwargs["payload"]) + return {"prompt_id": submitted["prompt_id"], "node_errors": {}} + prompt_id = submitted["prompt_id"] + if method == "POST": + cancelled.append(path) + return {"cancelled": False} + filename = f"{plan.output_prefix}_00001_.png" + if mismatch == "prior_output": + filename = "hcs_00000000000000000000_000000000000_00001_.png" + elif mismatch == "path_traversal": + filename = f"../{filename}" + return { + prompt_id: { + "status": {"status_str": "success", "completed": True}, + "prompt": [ + 1, + prompt_id, + ( + {"1": {"class_type": "prior-graph", "inputs": {}}} + if mismatch == "graph" + else submitted["prompt"] + ), + { + "client_id": ( + "prior-client" + if mismatch == "client" + else submitted["client_id"] + ) + }, + ["7"], + ], + "outputs": { + "7": { + "images": [ + { + "filename": filename, + "subfolder": "", + "type": "output", + } + ] + } + }, + } + } + + monkeypatch.setattr(images, "_http_json", fake_json) + monkeypatch.setattr( + images, + "_http_image", + lambda *_args: fetched.append("called") or _png(), + ) + with pytest.raises(images.TeachingImageError) as error: + images._execute_fixed_plan(plan, 8188, 16 * 1024**2) + assert error.value.code == "generation_failed" + assert cancelled == [f"/api/jobs/{submitted['prompt_id']}/cancel"] + assert fetched == [] + + +def test_timeout_cancels_target_and_never_publishes_late_result( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr(images, "_GENERATION_TIMEOUT_SECONDS", 0) + submitted: dict[str, object] = {} + cancelled: list[str] = [] + + def fake_json(_port, method, path, **kwargs): + if path == "/prompt": + submitted.update(kwargs["payload"]) + return {"prompt_id": submitted["prompt_id"], "node_errors": {}} + if method == "POST": + cancelled.append(path) + return {"cancelled": True} + return {} + + monkeypatch.setattr(images, "_http_json", fake_json) + with pytest.raises(images.TeachingImageError) as error: + images.generate_teaching_image(project, _request()) + assert error.value.code == "generation_timeout" + assert cancelled == [f"/api/jobs/{submitted['prompt_id']}/cancel"] + assert not (project / "assets/data/asset_manifest.json").exists() + assert not (project / "assets/images").exists() + + +def test_runtime_process_change_rejects_valid_png_before_publication( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + process = {"identity": "c" * 64} + monkeypatch.setattr( + images, + "runtime_snapshot", + lambda **_kwargs: SimpleNamespace( + runtime_ready=True, + installed=True, + version="0.28.0", + source_commit="700821e1364eaab0e8f21c538a2131719fec57bf", + actual_port=8188, + process_identity=process["identity"], + ), + ) + monkeypatch.setattr(images, "runtime_installation_identity", lambda: "a" * 64) + monkeypatch.setattr(images, "model_installation_identity", lambda _record: "b" * 64) + monkeypatch.setattr( + images, "validate_model_installation", lambda **_kwargs: record + ) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + + def replace_runtime(_plan, _port, _maximum): + process["identity"] = "d" * 64 + return _png(), "current-job" + + monkeypatch.setattr(images, "IMAGE_EXECUTOR", replace_runtime) + with pytest.raises(images.TeachingImageError) as error: + images.generate_teaching_image(project, _request()) + assert error.value.code == "generation_identity_mismatch" + 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: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda _plan, _port, _maximum: (b"not a png", "bad-image-job"), + ) + with pytest.raises(images.TeachingImageError) as invalid: + images.generate_teaching_image(project, _request()) + assert invalid.value.code == "image_format_invalid" + assert not (project / "assets/data/asset_manifest.json").exists() + assert not (project / "assets/images").exists() + + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda _plan, _port, _maximum: ( + _png(), + "22222222-2222-4222-8222-222222222222", + ), + ) + original_write_manifest = images._write_manifest + + def fail_manifest(_path, _manifest): + raise images.TeachingImageError( + "manifest_registration_failed", "simulated manifest failure" + ) + + monkeypatch.setattr(images, "_write_manifest", fail_manifest) + with pytest.raises(images.TeachingImageError) as failed: + images.generate_teaching_image(project, _request()) + assert failed.value.code == "manifest_registration_failed" + assert list(project.rglob("*.png")) == [] + assert list(project.rglob("*.provenance.json")) == [] + assert not (project / "assets/data/asset_manifest.json").exists() + + monkeypatch.setattr(images, "_write_manifest", original_write_manifest) + artifact = images.generate_teaching_image(project, _request()) + assert (project / artifact.path).is_file() + assert (project / artifact.provenance_ref).is_file() + + +def test_generation_rejects_malformed_or_linked_manifest_before_execution( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda *_args: pytest.fail("executor must not run for an unsafe Manifest"), + ) + malformed = tmp_path / "malformed" + (malformed / "assets/data").mkdir(parents=True) + (malformed / "assets/data/asset_manifest.json").write_text("{") + with pytest.raises(images.TeachingImageError) as invalid: + images.generate_teaching_image(malformed, _request()) + assert invalid.value.code == "manifest_invalid" + + linked = tmp_path / "linked" + (linked / "assets/data").mkdir(parents=True) + outside = tmp_path / "outside-manifest.json" + outside.write_text("{}") + (linked / "assets/data/asset_manifest.json").symlink_to(outside) + with pytest.raises(images.TeachingImageError) as unsafe: + images.generate_teaching_image(linked, _request()) + assert unsafe.value.code == "project_identity_mismatch" + assert outside.read_text() == "{}" + + +def test_concurrent_generations_preserve_both_manifest_entries_and_block_mutation( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + first_running = threading.Event() + release_first = threading.Event() + mutation_entered = threading.Event() + call_lock = threading.Lock() + calls = 0 + + def controlled_executor(plan, _port, _maximum): + nonlocal calls + with call_lock: + calls += 1 + call_number = calls + if call_number == 1: + first_running.set() + assert release_first.wait(timeout=2) + return ( + _png(), + ( + "33333333-3333-4333-8333-333333333333" + if call_number == 1 + else "44444444-4444-4444-8444-444444444444" + ), + ) + + monkeypatch.setattr(images, "IMAGE_EXECUTOR", controlled_executor) + second_request = _request().model_copy( + update={"asset_id": "classroom-farewell", "action": "the students wave goodbye"} + ) + + def mutation() -> None: + with images.model_generation_guard(): + mutation_entered.set() + + with ThreadPoolExecutor(max_workers=3) as pool: + first = pool.submit(images.generate_teaching_image, project, _request()) + assert first_running.wait(timeout=2) + second = pool.submit( + images.generate_teaching_image, project, second_request + ) + mutation_future = pool.submit(mutation) + time.sleep(0.05) + assert calls == 1 + assert not mutation_entered.is_set() + release_first.set() + first.result(timeout=3) + second.result(timeout=3) + mutation_future.result(timeout=3) + + saved = json.loads((project / "assets/data/asset_manifest.json").read_text()) + assert {asset["id"] for asset in saved["images"]} == { + "classroom-greeting", + "classroom-farewell", + } + assert mutation_entered.is_set() + + +def test_verified_image_is_registered_with_complete_provenance( + tmp_path: Path, monkeypatch +) -> None: + project = tmp_path / "project" + project.mkdir() + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, + "_require_generation_context", + lambda: (8188, record, workflow), + ) + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda _plan, _port, _maximum: ( + _png(), + "11111111-1111-4111-8111-111111111111", + ), + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + artifact = images.generate_teaching_image(project, _request()) + assert artifact.width == 512 + assert artifact.height == 384 + assert artifact.sha256 == hashlib.sha256(_png()).hexdigest() + assert artifact.provenance.model_manifest_sha256 == MODEL_MANIFEST_SHA256 + assert artifact.provenance.workflow_pack_sha256 == WORKFLOW_PACK_SHA256 + assert (project / artifact.path).read_bytes() == _png() + provenance = project / artifact.provenance_ref + assert hashlib.sha256(provenance.read_bytes()).hexdigest() == artifact.provenance_sha256 + saved = json.loads((project / "assets/data/asset_manifest.json").read_text()) + registered = saved["images"][0] + assert registered["placeholder"] is False + assert registered["review_state"] == "pending_review" + assert registered["verified_image_artifact"]["sha256"] == artifact.sha256 + assert registered["verified_image_artifact"]["schema"] == ( + "hanclassstudio.verified_image_artifact.v1" + ) + assert registered["verified_image_artifact"]["provenance"]["schema"] == ( + "hanclassstudio.teaching_image_provenance.v1" + ) + assert registered["generation"]["provider"] == "hcs.local.comfyui" + with pytest.raises(images.TeachingImageError, match="already contains"): + images.generate_teaching_image(project, _request()) + + +def test_generation_readiness_requires_all_three_layers(monkeypatch) -> None: + monkeypatch.setattr( + images, + "runtime_snapshot", + lambda: SimpleNamespace(installed=True, runtime_ready=True), + ) + monkeypatch.setattr( + images, + "model_snapshot", + lambda **_kwargs: SimpleNamespace( + installed=False, + model_ready=False, + workflow_ready=True, + technical_error=None, + ), + ) + snapshot = images.generation_capability_snapshot() + assert snapshot.runtime_ready is True + assert snapshot.model_ready is False + assert snapshot.workflow_ready is True + assert snapshot.generation_ready is False + + +def test_generation_ready_is_fail_closed_until_live_joint_check(monkeypatch) -> None: + images._CAPABILITY_CACHE.clear() + runtime_snapshot = SimpleNamespace( + installed=True, + runtime_ready=True, + version="0.28.0", + source_commit="700821e1364eaab0e8f21c538a2131719fec57bf", + actual_port=8188, + process_identity="c" * 64, + ) + model_snapshot = SimpleNamespace( + package_id="hcs.sd15-teaching-illustration-fp16", + version="1.5-fp16-emaonly", + installed=True, + model_ready=True, + workflow_ready=True, + workflow_pack_id="hcs.teaching-illustration-sd15-core", + workflow_version="1.0.0", + model_sha256="e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + checked_at="2026-07-26T00:00:00+00:00", + technical_error=None, + ) + monkeypatch.setattr(images, "runtime_snapshot", lambda: runtime_snapshot) + monkeypatch.setattr( + images, "model_snapshot", lambda **_kwargs: model_snapshot + ) + assert images.generation_capability_snapshot().generation_ready is False + monkeypatch.setattr( + images, + "check_runtime_health", + lambda: RuntimeHealthSnapshot( + healthy=True, + checked_at="2026-07-26T00:00:00+00:00", + status="runtime_ready", + port=8188, + core_api_available=True, + custom_nodes_pristine=True, + identity_verified=True, + ), + ) + monkeypatch.setattr( + images, "validate_model_installation", lambda **_kwargs: _record() + ) + object_info = {name: {} for name in images._REQUIRED_CORE_NODES} + object_info["CheckpointLoaderSimple"] = { + "input": { + "required": { + "ckpt_name": [ + ["hcs-sd-v1-5-pruned-emaonly-fp16.safetensors"] + ] + } + } + } + monkeypatch.setattr(images, "_http_json", lambda *_args, **_kwargs: object_info) + assert images.generation_capability_snapshot(deep=True).generation_ready is True + assert images.generation_capability_snapshot().generation_ready is True + + +def test_teaching_image_api_registers_artifact_and_bumps_revision( + tmp_path: Path, monkeypatch +) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + project = storage.ensure_project("phase2c-api") + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, + "_require_generation_context", + lambda: (8188, record, workflow), + ) + monkeypatch.setattr( + images, + "IMAGE_EXECUTOR", + lambda _plan, _port, _maximum: ( + _png(), + "55555555-5555-4555-8555-555555555555", + ), + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + response = TestClient(app).post( + "/api/projects/phase2c-api/media/teaching-image?expected_revision=0", + json=_request().model_dump(mode="json", by_alias=True), + ) + assert response.status_code == 200 + assert response.json()["schema"] == "hanclassstudio.verified_image_artifact.v1" + manifest = json.loads((project / "assets/data/asset_manifest.json").read_text()) + assert manifest["images"][0]["verified_image_artifact"]["artifact_id"] == response.json()["artifact_id"] + assert storage.project_revision("phase2c-api") == 1 + + +def test_teaching_image_api_serializes_expected_revision_with_generation( + tmp_path: Path, monkeypatch +) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + storage.ensure_project("phase2c-revision") + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + first_running = threading.Event() + release_first = threading.Event() + calls = 0 + call_lock = threading.Lock() + + def controlled_executor(_plan, _port, _maximum): + nonlocal calls + with call_lock: + calls += 1 + call_number = calls + if call_number == 1: + first_running.set() + assert release_first.wait(timeout=2) + return _png(), "66666666-6666-4666-8666-666666666666" + + monkeypatch.setattr(images, "IMAGE_EXECUTOR", controlled_executor) + second_request = _request().model_copy( + update={"asset_id": "stale-second-request"} + ) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit( + main.create_teaching_image, + "phase2c-revision", + _request(), + expected_revision=0, + ) + assert first_running.wait(timeout=2) + second = pool.submit( + main.create_teaching_image, + "phase2c-revision", + second_request, + expected_revision=0, + ) + release_first.set() + first.result(timeout=3) + with pytest.raises(HTTPException) as stale: + second.result(timeout=3) + assert stale.value.status_code == 409 + assert stale.value.detail["code"] == "project_revision_conflict" + assert calls == 1 + assert storage.project_revision("phase2c-revision") == 1 + + +def test_teaching_image_api_rechecks_revision_after_long_execution( + tmp_path: Path, monkeypatch +) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + project = storage.ensure_project("phase2c-late-revision") + workflow = load_workflow_pack() + record = _record() + _ready_runtime(monkeypatch) + monkeypatch.setattr( + images, "_require_generation_context", lambda: (8188, record, workflow) + ) + monkeypatch.setattr( + images, "_revalidate_generation_context", lambda _plan: None + ) + + def concurrent_project_mutation(_plan, _port, _maximum): + storage.bump_project_revision("phase2c-late-revision") + return _png(), "77777777-7777-4777-8777-777777777777" + + monkeypatch.setattr(images, "IMAGE_EXECUTOR", concurrent_project_mutation) + response = TestClient(app).post( + "/api/projects/phase2c-late-revision/media/teaching-image?expected_revision=0", + json=_request().model_dump(mode="json", by_alias=True), + ) + assert response.status_code == 409 + assert response.json()["detail"]["code"] == "project_revision_conflict" + assert storage.project_revision("phase2c-late-revision") == 1 + assert not (project / "assets/data/asset_manifest.json").exists() + assert list((project / "assets/images").glob("*.png")) == [] diff --git a/apps/api/tests/test_provider_hub.py b/apps/api/tests/test_provider_hub.py index 8ed55c6..54f2470 100644 --- a/apps/api/tests/test_provider_hub.py +++ b/apps/api/tests/test_provider_hub.py @@ -26,6 +26,8 @@ RuntimeOperationSummary, RuntimeSnapshot, ) +from hcs_api.comfyui_model import ModelPackageSnapshot +from hcs_api.comfyui_teaching_image import GenerationCapabilitySnapshot from hcs_api.main import app from hcs_api.models import ImageProviderSettings, ProviderSettings, SafeValidationErrorEnvelope @@ -89,6 +91,40 @@ def _runtime_snapshot(status: str, *, installed: bool) -> RuntimeSnapshot: ) +def _model_snapshot(*, installed: bool, ready: bool) -> ModelPackageSnapshot: + return ModelPackageSnapshot( + name="Stable Diffusion v1.5 FP16 教学插图模型", + version="1.5-fp16-emaonly", + status="model_ready" if ready else "not_installed", + installed=installed, + model_ready=ready, + workflow_ready=True, + model_size=2_132_696_762, + model_sha256="e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + model_source_revision="4fddeb7f9096623f1b77f4708feb96126a08a0cf", + model_license="CreativeML Open RAIL-M", + estimated_download_bytes=2_132_711_147, + ) + + +def _generation_snapshot( + *, + runtime_ready: bool, + model_installed: bool, + model_ready: bool, + ready: bool, +) -> GenerationCapabilitySnapshot: + return GenerationCapabilitySnapshot( + runtime_installed=True, + runtime_ready=runtime_ready, + model_installed=model_installed, + model_ready=model_ready, + workflow_ready=True, + generation_ready=ready, + checked_at=datetime.now(timezone.utc).isoformat(), + ) + + def test_hub_catalog_separates_domain_layers_and_actions(tmp_path, monkeypatch) -> None: client = _isolate(tmp_path, monkeypatch) monkeypatch.setattr(hub, "runtime_snapshot", lambda **_kwargs: _runtime_snapshot("not_installed", installed=False)) @@ -118,12 +154,97 @@ def test_hub_catalog_separates_domain_layers_and_actions(tmp_path, monkeypatch) assert comfyui["ready"] is False assert comfyui["runtime_ready"] is False assert comfyui["generation_ready"] is False - assert comfyui["capabilities"] == ["local_image_runtime"] - assert comfyui["capability_package"]["model_packages"] == [] - assert comfyui["capability_package"]["workflow_packs"] == [] + assert comfyui["capabilities"] == [ + "local_image_runtime", + "teaching_illustration", + "vocabulary_image", + "classroom_scene", + ] + assert comfyui["capability_package"]["model_packages"][0]["sha256"] == ( + "e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916" + ) + assert comfyui["capability_package"]["workflow_packs"][0]["id"] == ( + "hcs.teaching-illustration-sd15-core" + ) assert "install_runtime" in comfyui["available_actions"] +def test_comfyui_generation_ready_requires_runtime_model_and_workflow( + tmp_path, monkeypatch +) -> None: + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr( + hub, + "runtime_snapshot", + lambda **_kwargs: _runtime_snapshot("runtime_ready", installed=True), + ) + monkeypatch.setattr( + hub, + "model_snapshot", + lambda **_kwargs: _model_snapshot(installed=False, ready=False), + ) + monkeypatch.setattr( + hub, + "generation_capability_snapshot", + lambda **_kwargs: _generation_snapshot( + runtime_ready=True, + model_installed=False, + model_ready=False, + ready=False, + ), + ) + runtime_only = hub._comfyui_package_item(hub.detect_hardware()) + assert runtime_only.runtime_ready is True + assert runtime_only.generation_ready is False + assert runtime_only.status == "runtime_ready" + assert "install_model" not in runtime_only.available_actions + + monkeypatch.setattr( + hub, + "runtime_snapshot", + lambda **_kwargs: _runtime_snapshot("stopped", 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=False, + model_installed=True, + model_ready=True, + ready=False, + ), + ) + stopped = hub._comfyui_package_item(hub.detect_hardware()) + assert stopped.generation_ready is False + assert {"repair_model", "uninstall_model"}.issubset(stopped.available_actions) + + monkeypatch.setattr( + hub, + "runtime_snapshot", + lambda **_kwargs: _runtime_snapshot("runtime_ready", installed=True), + ) + monkeypatch.setattr( + hub, + "generation_capability_snapshot", + lambda **_kwargs: _generation_snapshot( + runtime_ready=True, + model_installed=True, + model_ready=True, + ready=True, + ), + ) + ready = hub._comfyui_package_item(hub.detect_hardware()) + assert ready.status == "ready" + assert ready.ready is True + assert ready.generation_ready is True + assert "check_generation" in ready.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} @@ -170,6 +291,16 @@ def unsafe(_task_id, **_kwargs): def test_comfyui_runtime_lifecycle_repair_uninstall_logs_and_directory_contract(tmp_path, monkeypatch) -> None: client = _isolate(tmp_path, monkeypatch) state = {"status": "stopped", "installed": True} + guard_events: list[str] = [] + + class GenerationGuard: + def __enter__(self): + guard_events.append("enter") + + def __exit__(self, *_args): + guard_events.append("exit") + + monkeypatch.setattr(hub, "model_generation_guard", GenerationGuard) monkeypatch.setattr(hub, "runtime_snapshot", lambda **_kwargs: _runtime_snapshot(state["status"], installed=state["installed"])) monkeypatch.setattr(hub, "start_runtime", lambda: state.update(status="runtime_ready")) monkeypatch.setattr(hub, "stop_runtime", lambda **_kwargs: state.update(status="stopped")) @@ -216,6 +347,7 @@ def prepare(operation): stopped = client.post("/api/providers/hub/packages/hcs.comfyui-runtime/stop") assert stopped.status_code == 200 assert stopped.json()["status"] == "stopped" + assert guard_events == ["enter", "exit", "enter", "exit"] def repair(_task_id, *, operation, progress, cancel, confirmation): assert operation == "repair" @@ -334,6 +466,12 @@ def test_comfyui_runtime_openapi_matches_lifecycle_responses(tmp_path, monkeypat ("/api/providers/hub/packages/{package_id}/install-task", "get", "ProviderInstallTask"), ("/api/providers/hub/packages/{package_id}/logs", "get", "RuntimeLogsResponse"), ("/api/providers/hub/packages/{package_id}/directory", "get", "RuntimeDirectoryAction"), + ("/api/providers/hub/packages/{package_id}/model/install", "post", "ProviderInstallStartResponse"), + ("/api/providers/hub/packages/{package_id}/model/repair", "post", "ProviderInstallStartResponse"), + ("/api/providers/hub/packages/{package_id}/model/uninstall", "post", "ProviderInstallStartResponse"), + ("/api/providers/hub/packages/{package_id}/model/prepare-repair", "post", "ModelOperationConfirmation"), + ("/api/providers/hub/packages/{package_id}/model/prepare-uninstall", "post", "ModelOperationConfirmation"), + ("/api/providers/hub/packages/{package_id}/generation/health", "post", "ProviderHubItem"), ): response_schema = paths[path][method]["responses"]["200"]["content"]["application/json"]["schema"] assert response_schema["$ref"] == f"#/components/schemas/{schema}" diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 3008916..35ce873 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -17,6 +17,7 @@ import type { ProviderHubInstallStartResponse, ProviderHubInstallTask, ProviderHubItem, + ModelOperationConfirmation, RuntimeOperationConfirmation, ProviderRefreshTask, PublicOnlineProviderConfig, @@ -407,6 +408,51 @@ export async function uninstallProviderRuntime( return mutateProviderRuntime(packageId, "uninstall", confirmation); } +export async function installProviderModel(packageId: string): Promise { + return request( + `/api/providers/hub/packages/${encodeURIComponent(packageId)}/model/install`, + { method: "POST" } + ); +} + +export async function prepareProviderModelMutation( + packageId: string, + operation: "repair" | "uninstall" +): Promise { + return request( + `/api/providers/hub/packages/${encodeURIComponent(packageId)}/model/prepare-${operation}`, + { method: "POST" } + ); +} + +export async function mutateProviderModel( + packageId: string, + operation: "repair" | "uninstall", + confirmation: ModelOperationConfirmation +): Promise { + return request( + `/api/providers/hub/packages/${encodeURIComponent(packageId)}/model/${operation}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmation_token: confirmation.confirmation_token, + expected_model_identity: confirmation.summary.installation_identity, + preserve_runtime: true, + preserve_projects: true, + preserve_other_models: true + }) + } + ); +} + +export async function checkProviderGeneration(packageId: string): Promise { + return request( + `/api/providers/hub/packages/${encodeURIComponent(packageId)}/generation/health`, + { 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 82049fa..c5507c4 100644 --- a/apps/web/src/components/ProviderHubDialog.tsx +++ b/apps/web/src/components/ProviderHubDialog.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle2, Cloud, Download, ExternalLink, HardDrive, import { cancelProviderHubInstall, + checkProviderGeneration, checkProviderHubHealth, deleteOnlineProviderConfig, fetchOnlineProviderConfig, @@ -12,6 +13,9 @@ import { fetchProviderHubRefresh, fetchProviderRuntimeDirectoryAction, fetchProviderRuntimeLogs, + installProviderModel, + mutateProviderModel, + prepareProviderModelMutation, prepareProviderRuntimeMutation, repairProviderRuntime, saveOnlineProviderConfig, @@ -228,6 +232,64 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => return mutateRuntime(item, "install"); } + async function mutateModel(item: ProviderHubItem, operation: "install" | "repair" | "uninstall"): Promise { + if (!beginMutation(item.id)) return; + setError(""); + try { + const confirmation = operation === "install" + ? null + : await prepareProviderModelMutation(item.id, operation); + if ( + operation === "repair" + && !window.confirm(t("provider.hub.modelRepairConfirm")) + ) { + endMutation(item.id); + return; + } + if ( + operation === "uninstall" + && !window.confirm(t("provider.hub.modelUninstallConfirm")) + ) { + endMutation(item.id); + return; + } + const started = operation === "install" + ? await installProviderModel(item.id) + : await mutateProviderModel(item.id, operation, confirmation!); + let task = started.task; + setHubState((current) => applyProviderHubInstallStart(current.catalog, current.installTasks, started)); + endMutation(item.id); + const deadline = Date.now() + 60 * 60_000; + while (!TERMINAL_TASKS.has(task.state)) { + if (Date.now() >= deadline) throw new Error(t("provider.hub.installTimeout")); + await wait(250); + task = await fetchProviderHubInstall(task.task_id); + if (!mountedRef.current) return; + setHubState((current) => ({ ...current, installTasks: { ...current.installTasks, [item.id]: task } })); + } + await reload(); + } catch (nextError) { + setError(errorText(nextError, t, t("provider.hub.installFailed"))); + await reload().catch(() => undefined); + } finally { + endMutation(item.id); + } + } + + async function checkGeneration(item: ProviderHubItem): Promise { + if (!beginMutation(item.id)) return; + setError(""); + try { + await checkProviderGeneration(item.id); + await reload(); + } catch (nextError) { + setError(errorText(nextError, t, t("provider.hub.generationCheckFailed"))); + 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(""); @@ -376,6 +438,9 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => const displayDescription = copy ? t(`provider.hub.advancedProvider.${copy.description}`) : item.description; const logs = runtimeLogs[item.id]; const runtimeNotice = runtimeNotices[item.id]; + const generation = item.generation_details; + const modelInstalled = generation?.model_installed ?? item.model_details?.installed ?? false; + const workflowReady = generation?.workflow_ready ?? item.model_details?.workflow_ready ?? false; return (
@@ -404,11 +469,17 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => )} {item.runtime_details && (
-

{t("provider.hub.runtimeBoundary")} {item.runtime_details.no_model_message}

+
+

{t("provider.hub.runtimeUsable")}{item.runtime_ready ? 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")}

+

{t("provider.hub.runtimeDownload", { size: formatBytes(item.runtime_details.estimated_download_bytes) })}

+ {item.model_details &&

{t("provider.hub.modelDownload", { size: formatBytes(item.model_details.estimated_download_bytes) })}

}

{t("provider.hub.runtimePlatform", { support: item.runtime_details.platform_support })}

{item.runtime_details.modified &&

} -

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

+ {!item.generation_ready &&

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

}
)} {runtimeNotice &&

{runtimeNotice}

} @@ -428,6 +499,10 @@ export function ProviderHubDialog({ onClose, onOpenSettings }: { onClose: () => {hasProviderHubAction(item, "force_stop_runtime") && } {hasProviderHubAction(item, "repair_runtime") && } {hasProviderHubAction(item, "uninstall_runtime") && } + {hasProviderHubAction(item, "install_model") && } + {hasProviderHubAction(item, "repair_model") && } + {hasProviderHubAction(item, "uninstall_model") && } + {hasProviderHubAction(item, "check_generation") && } {hasProviderHubAction(item, "cancel_install") && task && !TERMINAL_TASKS.has(task.state) && } {hasProviderHubAction(item, "configure") && } {hasProviderHubAction(item, "test_connection") && item.id === "hcs.online-image-high-quality" && } diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index 68d6938..8795596 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -503,6 +503,7 @@ const zh: Dict = { "provider.hub.runtimeStopFailed": "运行环境未能安全停止,请检查进程状态。", "provider.hub.runtimeLogsFailed": "无法读取受控 Runtime 日志。", "provider.hub.runtimeDirectoryFailed": "无法获取受控 Runtime 目录动作。", + "provider.hub.generationCheckFailed": "教学图片能力未通过联合检查,请查看模型、工作流和 Runtime 状态。", "provider.hub.configFailed": "Provider 配置失败。", "provider.hub.testFailed": "连接测试失败。", "provider.hub.deleteFailed": "无法删除 Provider 配置。", @@ -547,16 +548,28 @@ const zh: Dict = { "provider.hub.runtimeCheck": "检查运行环境", "provider.hub.runtimeRepair": "修复", "provider.hub.runtimeUninstall": "卸载", + "provider.hub.modelInstall": "安装教学图片模型", + "provider.hub.modelRepair": "修复图片模型", + "provider.hub.modelUninstall": "卸载图片模型", + "provider.hub.generationCheck": "检查生图能力", "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.runtimeBoundary": "能力边界:", + "provider.hub.runtimeUsable": "本地运行环境", + "provider.hub.modelInstalled": "教学图片模型", + "provider.hub.workflowReady": "教学插图工作流", + "provider.hub.generationReady": "当前可以生成图片", + "provider.hub.readyYes": "可用", + "provider.hub.readyNo": "未就绪", "provider.hub.runtimeDownload": "首次安装固定产物约 {size},通常需要 10–30 分钟;实际时间取决于网络与磁盘。", + "provider.hub.modelDownload": "固定教学图片模型约 {size};下载后会校验大小、SHA-256 与 SafeTensors 结构。", "provider.hub.runtimePlatform": "平台支持级别:{support}", "provider.hub.runtimeModified": "检测到外部修改。HanClassStudio 不会执行未批准的 custom nodes,请先修复。", - "provider.hub.runtimeNextStep": "Phase 2C 才会支持固定图片模型;本阶段没有图片生成功能。", + "provider.hub.generationBoundary": "只有 Runtime、固定模型与固定工作流同时就绪时,才会显示可以生成图片。", "provider.hub.runtimeDirectoryNotice": "后端已确认受控目录动作;当前 Web 入口不会暴露本机绝对路径。", "provider.hub.runtimeLogs": "受控 Runtime 日志摘要", "provider.hub.runtimeLogsEmpty": "暂无日志。", @@ -659,6 +672,7 @@ const zh: Dict = { "provider.hub.phase.validating_runtime": "正在验证运行环境", "provider.hub.phase.publishing_runtime": "正在发布运行环境", "provider.hub.phase.uninstalling_runtime": "正在卸载运行环境", + "provider.hub.phase.uninstalling_model": "正在卸载图片模型", "provider.hub.errorCode.checksum_mismatch": "文件校验失败,未保留安装结果。", "provider.hub.errorCode.cancelled": "安装已安全取消。", "provider.hub.errorCode.internal_error": "安装任务发生内部错误。", diff --git a/apps/web/src/state.test.ts b/apps/web/src/state.test.ts index cd2eb10..c433a15 100644 --- a/apps/web/src/state.test.ts +++ b/apps/web/src/state.test.ts @@ -262,7 +262,7 @@ const startedInstall = applyProviderHubInstallStart({ isolated_errors: [], }, {}, { task: { - task_id: "task-1", package_id: hubItem.id, operation: "install", state: "queued", phase: "preflight", progress: 0, + task_id: "task-1", package_id: hubItem.id, operation: "install", mutation_target: "fixture", state: "queued", phase: "preflight", progress: 0, current_file_progress: 0, downloaded_bytes: 0, total_bytes: 1, message: "queued", started_at: "2026-07-20T00:00:00Z", updated_at: "2026-07-20T00:00:00Z", cancellable: true, cancel_requested: false, recoverable_actions: [], log_ref: "test", diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7e7fca4..de6b0b8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -93,6 +93,11 @@ .provider-hub-runtime-summary p { margin: 0; line-height: 1.48; } .provider-hub-runtime-summary p:not(:first-child) { color: var(--muted); font-size: .82rem; } .provider-hub-runtime-summary .provider-hub-phase2c { color: var(--primary); font-weight: 650; } +.provider-hub-readiness-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-bottom: 4px; } +.provider-hub-readiness-grid p { display: flex; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface, #fff); font-size: .82rem; } +.provider-hub-readiness-grid p[data-ready="true"] span { color: var(--success, #18794e); font-weight: 700; } +.provider-hub-readiness-grid p[data-ready="false"] span { color: var(--muted); font-weight: 650; } +@media (max-width: 520px) { .provider-hub-readiness-grid { grid-template-columns: 1fr; } } .provider-hub-runtime-notice { margin: 10px 0; color: var(--muted); font-size: .82rem; } .provider-hub-runtime-logs { margin: 10px 0; } .provider-hub-runtime-logs summary { cursor: pointer; color: var(--primary); } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 983e738..df327cf 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"; +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 ProviderTrustLevel = "official_verified" | "community_verified" | "discovered_unverified" | "user_added" | "deprecated" | "blocked"; export type ProviderCompatibility = "compatible" | "compatible_but_slow" | "unsupported" | "unknown"; @@ -214,7 +214,7 @@ export interface ProviderCapabilityPackage { name: string; description: string; runtime?: { id: string; name: string; version: string; execution: string } | null; - model_packages: Array<{ id: string; name: string; version: string; format: string; safe_format: boolean }>; + model_packages: Array<{ id: string; name: string; version: string; format: string; safe_format: boolean; source_revision?: string | null; size_bytes?: number | null; sha256?: string | null; license_name?: string | null }>; workflow_packs: Array<{ id: string; name: string; version: string; capabilities: string[] }>; healthcheck: string; } @@ -270,6 +270,39 @@ export interface ProviderHubItem { runtime_ready: boolean; generation_ready: boolean; runtime_details?: ProviderRuntimeSnapshot | null; + model_details?: ProviderModelSnapshot | null; + generation_details?: ProviderGenerationSnapshot | null; +} + +export interface ProviderModelSnapshot { + package_id: "hcs.sd15-teaching-illustration-fp16"; + name: string; + version: string; + status: "not_installed" | "installing" | "model_ready" | "repair_required" | "failed"; + installed: boolean; + model_ready: boolean; + workflow_pack_id: "hcs.teaching-illustration-sd15-core"; + workflow_version: string; + workflow_ready: boolean; + model_size: number; + model_sha256: string; + model_source_revision: string; + model_license: string; + estimated_download_bytes: number; + checked_at?: string | null; + technical_error?: { code: string; message: string } | null; +} + +export interface ProviderGenerationSnapshot { + schema: "hanclassstudio.local_image_generation_capability.v1"; + runtime_installed: boolean; + runtime_ready: boolean; + model_installed: boolean; + model_ready: boolean; + workflow_ready: boolean; + generation_ready: boolean; + checked_at: string; + technical_error?: { code: string; message: string } | null; } export interface ProviderRuntimeSnapshot { @@ -287,6 +320,7 @@ export interface ProviderRuntimeSnapshot { compatible: boolean; available_actions: ProviderHubAction[]; actual_port?: number | null; + process_identity?: string | null; estimated_download_bytes: number; no_model_message: string; modified: boolean; @@ -332,6 +366,7 @@ export interface ProviderHubInstallTask { task_id: string; package_id: string; operation: "install" | "repair" | "uninstall"; + mutation_target: "fixture" | "runtime" | "model"; state: "queued" | "running" | "completed" | "failed" | "cancelled" | "partial"; phase: string; progress: number; @@ -371,6 +406,23 @@ export interface RuntimeOperationConfirmation { expires_at: string; } +export interface ModelOperationConfirmation { + summary: { + operation: "repair" | "uninstall"; + package_id: "hcs.sd15-teaching-illustration-fp16"; + version: string; + installation_identity: string; + model_sha256: string; + tree_identity: string; + replaces_model_files: boolean; + preserves_runtime: true; + preserves_projects: true; + preserves_other_models: true; + }; + confirmation_token: string; + expires_at: string; +} + export interface PublicOnlineProviderConfig { provider_id: string; endpoint: string; diff --git a/docs/comfyui-runtime-phase-2b.md b/docs/comfyui-runtime-phase-2b.md index 3ca8dab..a7c53b1 100644 --- a/docs/comfyui-runtime-phase-2b.md +++ b/docs/comfyui-runtime-phase-2b.md @@ -1,8 +1,10 @@ # Controlled ComfyUI Runtime — Phase 2B Phase 2B defines a controlled local ComfyUI **Runtime** for HanClassStudio. It is -infrastructure for a future fixed image Model Package; it is not an image -generator by itself. The current manifest enables one reviewed experimental +infrastructure and is not an image generator by itself. Phase 2C now layers one +fixed Model Package and Workflow Pack on top without changing this Runtime +contract; see [Controlled ComfyUI Teaching Image — Phase 2C](comfyui-teaching-image-phase-2c.md). +The current manifest enables one reviewed experimental adapter: macOS Apple Silicon on macOS 14 or newer. Other platforms remain non-installable. @@ -25,8 +27,9 @@ The invariant exposed in both API and UI is: runtime_ready ≠ generation_ready ``` -`generation_ready` is always `false` in Phase 2B. The card says -“运行环境可用,但尚未安装图片模型。” and exposes no image-generation action. +`RuntimeSnapshot.generation_ready` remains always `false`. The Phase 2C +Provider Hub projection computes its separate capability readiness only after +the Runtime, exact model, and exact workflow agree. ## Official source and immutable identity @@ -342,7 +345,9 @@ idempotently. 390 px, including the absence of a generation button. - `test_comfyui_real_opt_in.py` remains skipped in normal CI and must be explicitly enabled on a supported macOS host. It exercises the same manifest - and code-owned installer, with no model or workflow. + and code-owned Runtime installer. The separate Phase 2C opt-in test layers the + fixed model/workflow and real generation lifecycle described in the Phase 2C + document. ## Real validation diff --git a/docs/comfyui-teaching-image-phase-2c.md b/docs/comfyui-teaching-image-phase-2c.md new file mode 100644 index 0000000..3799410 --- /dev/null +++ b/docs/comfyui-teaching-image-phase-2c.md @@ -0,0 +1,275 @@ +# Controlled ComfyUI Teaching Image — Phase 2C + +Phase 2C adds one deliberately narrow local image-generation capability to the +Phase 2B managed ComfyUI Runtime: + +```text +Runtime ready +→ fixed Model Package +→ fixed Workflow Pack +→ generation_ready +→ TeachingImageRequest +→ controlled internal graph +→ verified PNG +→ VerifiedImageArtifact +→ Asset Manifest +``` + +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. + +## Model decision and immutable identity + +The first package uses Comfy Org's archival Stable Diffusion v1.5 +FP16 EMA-only SafeTensors checkpoint. ComfyUI's official first-generation guide +uses this exact checkpoint with the built-in default nodes. Compared with SDXL +or FLUX.1-schnell, SD 1.5 is materially smaller and is a more practical first +real lifecycle test on a 16 GB Apple Silicon machine. It also avoids custom +nodes, separate VAE files, LoRA, ControlNet, and gated or floating model +selection. + +| Fact | Fixed value | +| --- | --- | +| Package | `hcs.sd15-teaching-illustration-fp16` | +| Upstream repository | [Comfy-Org/stable-diffusion-v1-5-archive](https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive) | +| Repository revision | `4fddeb7f9096623f1b77f4708feb96126a08a0cf` | +| File | `v1-5-pruned-emaonly-fp16.safetensors` | +| Installed name | `hcs-sd-v1-5-pruned-emaonly-fp16.safetensors` | +| Size | 2,132,696,762 bytes | +| SHA-256 | `e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916` | +| Xet identity | `908c39bfdfec888e295ba04e974b6342f3c15776760edd46838240a8d455525d` | +| Format | SafeTensors, 1,145 F16 tensors | +| Architecture metadata | `stable-diffusion-v1`, `512x512`, `pt` | +| License | CreativeML Open RAIL-M | +| Pinned license revision | `14d42d09bffd871b1666a084fc954a50cff72ac0` | +| License file size / SHA-256 | 14,385 bytes / `be351ebe7ac01bcdbb018639aadcfd38f136b7dc3f2a3d4d3a24db51d1b210ef` | +| Supported adapter | macOS 14+, Apple Silicon, 16 GB memory minimum | + +The code-owned manifest is +`providers/comfyui/model-package-sd15-fp16.v1.json`; its own SHA-256 is +`b86be7b3fc04afc839913e1d7a20aba19d4a0de401beeb08e970c829ef40c658`. +The URL contains the full upstream revision. Redirects are limited to reviewed +Hugging Face delivery hosts; exact final size and SHA-256 are mandatory. + +CreativeML Open RAIL-M includes use restrictions and notice/redistribution +conditions. The pinned license text is installed beside the model and is part +of the package identity. This engineering review is not legal advice; product +distribution still needs release counsel and retained notices. + +## Install, repair, recovery, and physical separation + +```text +runtime/providers/hcs.comfyui-runtime/ managed executable Runtime +runtime/provider-models/comfyui/ fixed model + pinned license +runtime/provider-data/comfyui/ ComfyUI input/output/temp/user data +runtime/projects// user project assets and manifests +``` + +Model install is enabled only when the exact Runtime is installed and stopped. +It downloads to a private staging file, checks byte count and SHA-256, parses +the SafeTensors header without loading tensors, validates metadata, tensor +inventory, dtype, shapes, byte ranges, and contiguous data length, validates +the pinned license, then atomically publishes both files. + +Repair and uninstall require a short-lived, single-use backend confirmation +bound to operation, current real file identities, fixed package identity, and +managed-root identity. Repair remains available after checksum or mtime damage, +but symlinks, special files, a replaced managed root, unknown contracts, or +escaped paths fail closed. Repair keeps the previous owned pair until the new +pair and state commit succeed. Uninstall removes only the two recorded +HanClassStudio model-package files; it preserves Runtime, other model files, +provider data, projects, and generated assets. + +A durable journal distinguishes download, verification, publish, state-commit, +uninstall, rollback, and completed phases. Restart recovery either completes a +fully verified publish/uninstall or restores the previous owned pair. Partial +downloads and retained backups are removed only after their current file +identity matches the journal. + +## Fixed official-core workflow + +The workflow contract is +`providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json`, SHA-256 +`e25c17976054ad0122c943a22631640afd50fa52af960e839ce29cd168c1751e`. +It permits exactly seven built-in ComfyUI nodes: + +```text +CheckpointLoaderSimple +→ CLIPTextEncode (fixed-profile positive prompt) +→ CLIPTextEncode (fixed negative prompt) +→ EmptyLatentImage +→ KSampler +→ VAEDecode +→ SaveImage +``` + +Sampling is fixed at one image, 20 steps, CFG 7, Euler, normal scheduler, +denoise 1.0. Aspect ratio selects only `512×512`, `512×384`, or `512×288`. +The style prefix/suffix and negative prompt are fixed. Runtime launch continues +to disable all custom nodes, API nodes, metadata, auto-launch, and non-loopback +binding. + +`TeachingImageRequest` accepts only a safe asset ID, one of three teaching +purposes, bounded subject/action/environment text, one fixed aspect ratio, a +seed, and source-trace references. HanClassStudio compiles these fields into the +sole graph in backend code. The public API never exposes or accepts that graph. + +## Readiness contract + +`RuntimeSnapshot.generation_ready` remains permanently false because a Runtime +cannot claim a model capability. Provider Hub computes the separate capability: + +```text +generation_ready = + runtime_ready + AND exact model installation is ready + AND exact workflow digest/Runtime/model identities agree + AND the current identity set has passed a live joint check +``` + +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 +`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 +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: + +1. local Runtime usable; +2. teaching image model installed; +3. teaching illustration workflow ready; +4. image generation currently available. + +Installing Runtime alone therefore never renders “currently available for +generation.” + +## Execution, PNG verification, and Asset Manifest + +The endpoint is: + +```text +POST /api/projects/{project_id}/media/teaching-image +``` + +It assigns one canonical UUID and client ID, queues only the internally built +graph on the verified managed loopback port, and then accepts history only when +the UUID, client ID, complete graph, sole output node, and successful completed +status all match that submission. Each compiled plan contains a fresh, +request-bound `SaveImage` prefix so ComfyUI cannot satisfy a retry with a prior +prompt's cached output. It accepts exactly one basename-only PNG under that +prefix and retrieves it only through ComfyUI's loopback `/view` output route. + +History polling and responses are bounded. On timeout or any rejected result, +HanClassStudio makes a best-effort call to ComfyUI's targeted, idempotent +single-job cancellation endpoint; it does so only while the verified Runtime +port and process identity still match, so it cannot interrupt work on a +replacement listener. A late completion has no publication path and cannot +enter a project manifest. + +Before project persistence, HanClassStudio validates PNG signature, complete +chunk framing, every CRC, a single IHDR, exact requested dimensions, at least +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. + +The result is a `VerifiedImageArtifact` containing: + +- project-relative PNG path, MIME, dimensions, size, and SHA-256; +- a separately hashed project-relative provenance record; +- Runtime version, source commit, installation identity, process identity, and + loopback port; +- model package/version/manifest/checkpoint/installation identities; +- workflow package/version/digest; +- request and compiled-plan hashes; +- fixed prompt profile, compiled prompts, sampling, seed, unique output prefix, + prompt ID, source trace, and timestamps. + +The same artifact is nested in an `AssetFile`, with a generated candidate, +`pending_review`, and local-generation compatibility metadata, then atomically +registered in `assets/data/asset_manifest.json`. Image and provenance files are +removed if registration fails. Generation, fresh bounded Manifest read, +registration, and optimistic revision check share the model-mutation lock, so +concurrent requests cannot overwrite each other's entries and repair/uninstall +cannot overlap an executing generation. The Manifest read and optimistic +revision check are repeated after the long-running provider execution and again +immediately before publication, so a completed concurrent project mutation is +rejected instead of being overwritten. This endpoint does not update lesson +content, accept the image for the teacher, render, export, or bypass a quality +gate. + +The fixed seed is captured for controlled retry and audit, but it is not a +promise of byte-identical output across PyTorch, operating-system, or hardware +changes. + +## Real opt-in validation + +Run the macOS Apple Silicon lifecycle with: + +```bash +HCS_RUN_REAL_COMFYUI_IMAGE=1 \ +HCS_COMFYUI_IMAGE_REAL_REPORT="$PWD/runtime/phase2c-validation/report.json" \ +HCS_COMFYUI_IMAGE_REAL_OUTPUT_DIR="$PWD/runtime/phase2c-validation" \ +PYTHONPATH=apps/api/src uv run --project apps/api \ +python -m pytest apps/api/tests/test_comfyui_image_real_opt_in.py -v +``` + +The opt-in test performs real Runtime installation, one exact model download, +deep model/workflow validation, Runtime start, live joint health, one 512×384 +classroom greeting generation, PNG/provenance/manifest checks, Runtime stop, +repair from a locally copied and re-hashed verified model pair, a second +Runtime start and live joint revalidation, another stop, model uninstall, +Runtime uninstall, and project-asset preservation. Reports, model bytes, +caches, Runtime trees, and generated images live under ignored Runtime or +pytest directories and are never committed. + +### Validation evidence — 2026-07-27 Asia/Bangkok (2026-07-26 UTC) + +The lifecycle passed on a 16 GB Apple Silicon machine running macOS 26.5.2: + +| Check | Result | +| --- | --- | +| Strict upstream model download | 2,132,696,762 bytes and SHA-256 `e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916` verified | +| SafeTensors inspection | 201,982-byte header, 1,145 F16 tensors including valid rank-0 tensors, metadata and contiguous offsets verified | +| Runtime install | 187.888 s | +| Model install / post-generation repair | 1.863 s / 2.842 s from the separately re-hashed ignored validation cache | +| Runtime start | 56.910 s initially; 7.453 s after repair, managed loopback port `8188` | +| Joint capability | Runtime, model, workflow, core nodes, and checkpoint inventory all ready; `generation_ready=true` | +| Post-repair joint capability | Second live check passed with all four readiness facts true before final stop | +| Real generation | 24.223 s, one 512×384 RGB PNG, 341,808 bytes | +| Image SHA-256 | `834306448582d195bb96f96fa2513b30f560bcf995108b886c687fcc843c6de0` | +| Provenance SHA-256 | `16b13b8924c764850ca62387b922b5cdc94503dc60d166383ccb3438915425f6` | +| Registration | `VerifiedImageArtifact` `img-52f57fda24abf00f3e08b9bb` nested in the project Asset Manifest as `pending_review` | +| Cleanup | Model removed, Runtime removed, project image preserved | +| Total opt-in gate | `1 passed in 301.86s` | + +The first live inspection also found that the upstream file contains legal +SafeTensors rank-0 tensors (`shape: []`). The validator initially rejected them +and rolled back without publishing the model. The implementation was corrected +to apply SafeTensors scalar semantics (one element for an empty dimension +list), covered by a regression fixture, and the complete lifecycle then passed. +This is concrete fail-closed and recovery evidence rather than a relaxed model +identity. + +The retained test image is an original, text-free illustration of four stylized +figures against a plain background. It is technically valid and carries no +requested copyrighted character. It does not reliably express the requested +wave/greeting, exact person count, or classroom environment, which confirms +that SD 1.5 prompt adherence and classroom-semantic quality remain +teacher-review risks; the artifact therefore stays `pending_review`. + +## Explicit non-goals + +This slice does not add multiple models, a marketplace, arbitrary URLs, +arbitrary workflow JSON, custom nodes, ComfyUI Manager, LoRA, ControlNet, +external VAE files, image editing, inpainting, character consistency, batches, +automatic lesson image generation, cloud fallback, Windows/Linux installation, +video generation, performance claims, classroom validation, or automatic +teacher acceptance. diff --git a/docs/provider-hub.md b/docs/provider-hub.md index 1426e89..f6afe2b 100644 --- a/docs/provider-hub.md +++ b/docs/provider-hub.md @@ -65,7 +65,7 @@ The first featured entries are: | ID | Type | Current behavior | | --- | --- | --- | -| `hcs.comfyui-runtime` | local Runtime | Defines and supervises a fixed official ComfyUI Runtime contract. macOS Apple Silicon on macOS 14+ is install-enabled as experimental with a reviewed uv/Python/wheel-only bundle. It contains no model or workflow. | +| `hcs.comfyui-runtime` | local Runtime + one fixed teaching-image capability | Defines and supervises the fixed official ComfyUI Runtime, one commit-pinned SD 1.5 FP16 Model Package, and one official-core-node Workflow Pack. macOS Apple Silicon on macOS 14+ is experimental. | | `hcs.teaching-video-basic` | local | Probes system FFmpeg/ffprobe, required encoders/decoders, subtitle filter, and a usable CJK font. It does not install FFmpeg. | | `hcs.local-image-basic` | local | Installs a bundled, checksum-pinned JSON fixture through the real asynchronous task pipeline. It is a safe lifecycle proof, not a generative model. | | `hcs.online-image-high-quality` | online | Configures and tests the user's OpenAI image API credentials. The default is `gpt-image-2`; generation/editing still uses the existing media pipeline adapter. | @@ -160,13 +160,13 @@ size budgets, private exclusive extraction, post-walk verification, atomic publish, and a durable recovery journal. These protections do not broaden the fixture installer or authorize registry-provided archives. -## Phase 2B controlled ComfyUI Runtime +## Phase 2B Runtime and Phase 2C fixed teaching image -The ComfyUI card is a Runtime package, not a local-image Provider. Its backend -projection adds `runtime_ready`, `generation_ready`, and `runtime_details` while -keeping the legacy `ready` field false. In Phase 2B, `generation_ready` is -always false, Model Package and Workflow Pack arrays are empty, and the card -exposes no image-generation action. +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. Backend actions are specific to the lifecycle: @@ -175,18 +175,24 @@ install_runtime / cancel_install 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 ``` -Install, repair, and uninstall return the common asynchronous `{task, -provider}` shape. Repair and uninstall first require a backend prepare call and -a short-lived one-time confirmation token bound to the current installation, -tree identity, modified state, operation, and expiry; execution revalidates the -identity. Start, stop, and health return a current Provider snapshot. +Runtime and model install, repair, and uninstall return the common asynchronous +`{task, provider}` shape and identify the mutation target. Both repair and +uninstall families require their own backend prepare call and a short-lived +single-use confirmation bound to the current owned identity, operation, and +expiry. Runtime operations preserve models; model operations preserve Runtime, +other models, and projects. The directory endpoint returns an opaque desktop action rather than a machine path. A normal catalog read uses persisted Runtime/process state and the manifest-bound source-tree identity; it does not start the Runtime, make a health HTTP call, or run the full dependency probe. Start and explicit health -perform the deep source/Python/lock/custom-node and ComfyUI API checks. +perform the deep source/Python/lock/custom-node and ComfyUI API checks. Explicit +generation health additionally verifies the exact model/SafeTensors identity, +fixed workflow digest, seven required core node classes, and the checkpoint +inventory. macOS Apple Silicon on macOS 14+ is install-enabled as `experimental`. Its adapter is bound to exact reviewed uv and CPython artifacts plus an 83-entry @@ -196,7 +202,8 @@ allowlist and perform no dependency resolution. Windows/Linux adapters are dependencies, archive policy, artifact/license identities, process and listener 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). +[Controlled ComfyUI Runtime — Phase 2B](comfyui-runtime-phase-2b.md) and +[Controlled ComfyUI Teaching Image — Phase 2C](comfyui-teaching-image-phase-2c.md). ## Online configuration and secrets @@ -285,8 +292,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 ComfyUI Runtime lifecycle, unsafe archive - never ready, no image-generation action, mobile overflow, Escape/focus + 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 restoration, and explicit configuration without secret rendering or placeholder-model inheritance. - Repository gate: full `npm test` plus full Playwright E2E. @@ -296,15 +304,23 @@ estimate is shown because phase 1 has no representative benchmark. - arbitrary GitHub/Hugging Face/ComfyUI discovery or installation; - remote shell, Python, npm, Docker, Homebrew, or system-package commands; - installing FFmpeg, GPU drivers, CUDA, or DirectML; -- real local model downloads or model execution; +- arbitrary local model downloads or model execution outside the one fixed + Phase 2C package; - detached registry signatures, transparency logs, or multi-process locks; - encrypted/keychain secret storage or reliable performance estimates; - uninstall/update/log-view actions for the phase-1 JSON capability fixture; - automatic refresh, implicit credential writes, or quality-gate bypasses. -## Phase 2C follow-up - -The next ComfyUI slice is a separately pinned Model Package, not another -Runtime installer. It needs its own source/license/hash/size/hardware policy and -must preserve the Phase 2B Runtime, custom-node, process, and loopback boundary. -No model, workflow execution, or generation action is implemented here. +## Phase 2C controlled slice + +The implemented Phase 2C exception is one separately pinned Model Package and +one fixed Workflow Pack. It preserves every Phase 2B custom-node, process, +loopback, dependency, and physical-directory boundary. The sole project API +accepts a `TeachingImageRequest`, builds the graph internally, verifies one PNG +and provenance record, and registers a `VerifiedImageArtifact` in the Asset +Manifest. Execution history must match the caller-assigned job UUID, client, +and complete fixed graph; Runtime/model/workflow identities are checked again +before publication. Generation and model mutation share one lock, and every +registration starts from a fresh bounded Manifest read plus a post-execution +optimistic revision recheck. It does not expose a generic ComfyUI execution API +or automatically generate lesson images. diff --git a/docs/roadmap.md b/docs/roadmap.md index 0e5ca73..708e6ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -95,10 +95,24 @@ Provider Hub local Runtime status: reviewed fixed uv/Python artifacts and an 83-entry wheel-only bundle; real install/start/check/stop/repair/uninstall validation passed on 2026-07-23; Windows/Linux declarations remain contract-only; -- Provider Hub keeps Runtime readiness separate from generation readiness: - no model, workflow, image generation, custom node, ComfyUI Manager, LAN - binding, arbitrary repository, or system-driver installation is enabled; +- Provider Hub keeps Runtime readiness separate from generation readiness; + 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 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 + project ownership survived cleanup, while prompt adherence remains explicitly + teacher-review quality rather than a classroom-readiness claim; +- no marketplace, arbitrary repository/workflow, custom node, ComfyUI Manager, + LoRA, ControlNet, image editing, lesson auto-generation, cloud fallback, LAN + 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). ## Artifact Ownership diff --git a/e2e/provider-hub.spec.mjs b/e2e/provider-hub.spec.mjs index e47ebeb..9528a3f 100644 --- a/e2e/provider-hub.spec.mjs +++ b/e2e/provider-hub.spec.mjs @@ -186,26 +186,43 @@ test("Provider Hub refresh summary, source details, real fixture install, and na }); -test("ComfyUI Runtime installs, starts, stays model-incomplete, stops, and uninstalls", async ({ page }) => { +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"); let status = "not_installed"; let installed = false; let activeTask = null; + let modelInstalled = false; 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", "view_runtime_logs", "open_runtime_directory"]; - return ["start_runtime", "check_runtime", "repair_runtime", "uninstall_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"]; }; const provider = () => ({ ...initial, - status, + status: status === "runtime_ready" && modelInstalled ? "ready" : status, installed, - configured: installed, - ready: false, + configured: installed && modelInstalled, + ready: status === "runtime_ready" && modelInstalled, runtime_ready: status === "runtime_ready", - generation_ready: false, + generation_ready: status === "runtime_ready" && modelInstalled, available_actions: actions(), + model_details: { + ...initial.model_details, + status: modelInstalled ? "model_ready" : "not_installed", + installed: modelInstalled, + model_ready: modelInstalled, + workflow_ready: true, + }, + generation_details: { + ...initial.generation_details, + runtime_installed: installed, + runtime_ready: status === "runtime_ready", + model_installed: modelInstalled, + model_ready: modelInstalled, + workflow_ready: true, + generation_ready: status === "runtime_ready" && modelInstalled, + }, runtime_details: { ...initial.runtime_details, status, @@ -216,8 +233,8 @@ test("ComfyUI Runtime installs, starts, stays model-incomplete, stops, and unins actual_port: status === "runtime_ready" ? 8188 : null, }, }); - const task = (id, operation, state, phase, progress) => ({ - task_id: id, package_id: "hcs.comfyui-runtime", operation, state, phase, progress, + const task = (id, operation, state, phase, progress, mutationTarget = "runtime") => ({ + task_id: id, package_id: "hcs.comfyui-runtime", operation, mutation_target: mutationTarget, state, phase, progress, current_file_progress: progress, downloaded_bytes: 11611291, total_bytes: 11611291, message: phase, started_at: new Date().toISOString(), updated_at: new Date().toISOString(), finished_at: state === "completed" ? new Date().toISOString() : null, @@ -241,6 +258,15 @@ test("ComfyUI Runtime installs, starts, stays model-incomplete, stops, and unins activeTask = task("comfy-install-e2e", "install", "completed", "completed", 100); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(activeTask) }); }); + await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/model/install", async (route) => { + activeTask = task("model-install-e2e", "install", "queued", "preflight", 0, "model"); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ task: activeTask, provider: { ...provider(), status: "installing", available_actions: ["cancel_install", "view_runtime_logs"] } }) }); + }); + await page.route("**/api/providers/hub/install-tasks/model-install-e2e", async (route) => { + modelInstalled = true; + activeTask = task("model-install-e2e", "install", "completed", "completed", 100, "model"); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(activeTask) }); + }); await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/start", async (route) => { status = "runtime_ready"; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(provider()) }); @@ -249,6 +275,46 @@ test("ComfyUI Runtime installs, starts, stays model-incomplete, stops, and unins status = "stopped"; 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) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + summary: { + operation: "uninstall", + package_id: "hcs.sd15-teaching-illustration-fp16", + version: "1.5-fp16-emaonly", + installation_identity: modelUninstallIdentity, + model_sha256: "e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + tree_identity: "f".repeat(64), + replaces_model_files: false, + preserves_runtime: true, + preserves_projects: true, + preserves_other_models: true, + }, + confirmation_token: modelUninstallToken, + expires_at: new Date(Date.now() + 60_000).toISOString(), + }), + }); + }); + await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/model/uninstall", async (route) => { + expect(route.request().postDataJSON()).toEqual({ + confirmation_token: modelUninstallToken, + expected_model_identity: modelUninstallIdentity, + preserve_runtime: true, + preserve_projects: true, + preserve_other_models: true, + }); + activeTask = task("model-uninstall-e2e", "uninstall", "queued", "preflight", 0, "model"); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ task: activeTask, provider: { ...provider(), status: "installing", available_actions: ["cancel_install", "view_runtime_logs"] } }) }); + }); + await page.route("**/api/providers/hub/install-tasks/model-uninstall-e2e", async (route) => { + modelInstalled = false; + activeTask = task("model-uninstall-e2e", "uninstall", "completed", "completed", 100, "model"); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(activeTask) }); + }); const uninstallIdentity = "a".repeat(64); const uninstallToken = "b".repeat(64); await page.route("**/api/providers/hub/packages/hcs.comfyui-runtime/prepare-uninstall", async (route) => { @@ -293,17 +359,27 @@ test("ComfyUI Runtime installs, starts, stays model-incomplete, stops, and unins 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(); - await expect(card).toContainText("本阶段没有图片生成功能"); + const card = hub.locator(".provider-hub-card").filter({ hasText: "ComfyUI 本地教学图片" }).first(); + await expect(card).toContainText("当前可以生成图片"); + await expect(card).toContainText("未就绪"); await expect(card.getByRole("button", { name: /生成图片/ })).toHaveCount(0); await card.getByRole("button", { name: "安装运行环境", exact: true }).click(); await expect(card.getByText("已安装,当前停止", { exact: true })).toBeVisible(); - await expect(card).toContainText("运行环境可用,但尚未安装图片模型"); + await expect(card.getByRole("button", { name: "安装教学图片模型", exact: true })).toBeVisible(); + await card.getByRole("button", { name: "安装教学图片模型", exact: true }).click(); + await expect(card.getByRole("button", { name: "修复图片模型", exact: true })).toBeVisible(); + await expect(card).toContainText("教学图片模型"); + await expect(card).toContainText("教学插图工作流"); + await expect(card).toContainText("当前可以生成图片"); await card.getByRole("button", { name: "启动", exact: true }).click(); - await expect(card.getByText("运行环境可用", { exact: true })).toBeVisible(); + await expect(card.getByText("当前可用", { exact: true })).toBeVisible(); + await expect(card.locator('.provider-hub-readiness-grid p[data-ready="true"]')).toHaveCount(4); await expect(card.getByRole("button", { name: /生成图片/ })).toHaveCount(0); 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"); + await card.getByRole("button", { name: "卸载图片模型", exact: true }).click(); + await expect(card.getByRole("button", { name: "安装教学图片模型", exact: true })).toBeVisible(); await card.getByRole("button", { name: "卸载", exact: true }).click(); await expect(card.getByText("未安装", { exact: true })).toBeVisible(); await expect.poll(() => hub.evaluate((element) => ({ scroll: element.scrollWidth, client: element.clientWidth }))).toEqual({ scroll: 390, client: 390 }); @@ -363,7 +439,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: "ComfyUI 本地教学图片" }).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); diff --git a/providers/README.md b/providers/README.md index 655855f..5237e65 100644 --- a/providers/README.md +++ b/providers/README.md @@ -28,3 +28,12 @@ nodes, or remote sources. `comfyui/locks/comfyui-macos-arm64-py311.lock` is the generated dependency inventory for the single experimental macOS Apple Silicon adapter. See `docs/comfyui-runtime-phase-2b.md` for source attribution, GPL obligations, security, lifecycle, testing, and explicit non-goals. + +`comfyui/model-package-sd15-fp16.v1.json` is the separate Phase 2C contract for +one commit-pinned Comfy Org Stable Diffusion v1.5 FP16 SafeTensors checkpoint +and its pinned CreativeML Open RAIL-M license text. The code-owned installer +accepts no other URL or model identity. The matching +`comfyui/workflows/teaching-illustration-sd15-core.v1.json` authorizes only the +seven listed official core nodes and fixed sampling/output policy. See +`docs/comfyui-teaching-image-phase-2c.md`; neither file grants registry data +generic model-download or ComfyUI-graph execution authority. diff --git a/providers/comfyui/model-package-sd15-fp16.v1.json b/providers/comfyui/model-package-sd15-fp16.v1.json new file mode 100644 index 0000000..1fa5077 --- /dev/null +++ b/providers/comfyui/model-package-sd15-fp16.v1.json @@ -0,0 +1,68 @@ +{ + "schema": "hanclassstudio.comfyui_model_package.v1", + "package_id": "hcs.sd15-teaching-illustration-fp16", + "model_id": "stable-diffusion-v1-5", + "name": "Stable Diffusion v1.5 FP16 教学插图模型", + "version": "1.5-fp16-emaonly", + "publisher": "Stability AI, RunwayML and CompVis; archived by Comfy Org", + "source": { + "repository_url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive", + "revision": "4fddeb7f9096623f1b77f4708feb96126a08a0cf", + "file_url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/4fddeb7f9096623f1b77f4708feb96126a08a0cf/v1-5-pruned-emaonly-fp16.safetensors", + "file_name": "v1-5-pruned-emaonly-fp16.safetensors", + "installed_file_name": "hcs-sd-v1-5-pruned-emaonly-fp16.safetensors", + "size": 2132696762, + "sha256": "e9476a13728cd75d8279f6ec8bad753a66a1957ca375a1464dc63b37db6e3916", + "xet_hash": "908c39bfdfec888e295ba04e974b6342f3c15776760edd46838240a8d455525d", + "uploaded_at": "2024-08-29", + "allowed_redirect_hosts": [ + "us.aws.cdn.hf.co", + "cdn-lfs.huggingface.co", + "cdn-lfs-us-1.huggingface.co", + "cas-bridge.xethub.hf.co" + ] + }, + "license": { + "spdx": "LicenseRef-CreativeML-OpenRAIL-M", + "name": "CreativeML Open RAIL-M", + "source_revision": "14d42d09bffd871b1666a084fc954a50cff72ac0", + "url": "https://huggingface.co/spaces/CompVis/stable-diffusion-license/blob/14d42d09bffd871b1666a084fc954a50cff72ac0/license.txt", + "text_url": "https://huggingface.co/spaces/CompVis/stable-diffusion-license/raw/14d42d09bffd871b1666a084fc954a50cff72ac0/license.txt", + "installed_file_name": "CreativeML-OpenRAIL-M.txt", + "size": 14385, + "sha256": "be351ebe7ac01bcdbb018639aadcfd38f136b7dc3f2a3d4d3a24db51d1b210ef", + "redistribution_review": "approved_with_attribution_notice_and_use_restrictions" + }, + "safetensors": { + "header_size": 201982, + "tensor_count": 1145, + "allowed_dtypes": [ + "F16" + ], + "architecture": "stable-diffusion-v1", + "resolution": "512x512", + "format": "pt" + }, + "runtime": { + "runtime_id": "comfyui", + "version": "0.28.0", + "source_commit": "700821e1364eaab0e8f21c538a2131719fec57bf", + "checkpoint_directory": "checkpoints" + }, + "platforms": [ + { + "operating_system": "macos", + "architecture": "arm64", + "minimum_os_version": "14.0", + "support": "experimental", + "install_enabled": true, + "minimum_memory_mb": 16384, + "minimum_free_disk_bytes": 5368709120 + } + ], + "capabilities": [ + "teaching_illustration", + "vocabulary_image", + "classroom_scene" + ] +} diff --git a/providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json b/providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json new file mode 100644 index 0000000..b9d0415 --- /dev/null +++ b/providers/comfyui/workflows/teaching-illustration-sd15-core.v1.json @@ -0,0 +1,80 @@ +{ + "schema": "hanclassstudio.comfyui_workflow_pack.v1", + "pack_id": "hcs.teaching-illustration-sd15-core", + "name": "Stable Diffusion v1.5 教学插图核心工作流", + "version": "1.0.0", + "runtime": { + "runtime_id": "comfyui", + "version": "0.28.0", + "source_commit": "700821e1364eaab0e8f21c538a2131719fec57bf" + }, + "model_package_id": "hcs.sd15-teaching-illustration-fp16", + "capabilities": [ + "teaching_illustration", + "vocabulary_image", + "classroom_scene" + ], + "nodes": [ + { + "id": "checkpoint", + "class_type": "CheckpointLoaderSimple" + }, + { + "id": "positive", + "class_type": "CLIPTextEncode" + }, + { + "id": "negative", + "class_type": "CLIPTextEncode" + }, + { + "id": "latent", + "class_type": "EmptyLatentImage" + }, + { + "id": "sampler", + "class_type": "KSampler" + }, + { + "id": "decode", + "class_type": "VAEDecode" + }, + { + "id": "save", + "class_type": "SaveImage" + } + ], + "sampling": { + "steps": 20, + "cfg": 7.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0, + "batch_size": 1 + }, + "dimensions": { + "1:1": [ + 512, + 512 + ], + "4:3": [ + 512, + 384 + ], + "16:9": [ + 512, + 288 + ] + }, + "prompt_profile": { + "id": "soft-flat-educational-v1", + "positive_prefix": "soft flat educational illustration for an international Chinese language classroom", + "positive_suffix": "warm calm mood, culturally respectful, simple uncluttered background, one unmistakable central action, clear high contrast shapes, age-appropriate, no written text", + "negative": "text, letters, words, captions, subtitles, logo, watermark, photorealistic, cluttered background, low quality, blurry, distorted hands, extra fingers, violence, weapons, nudity" + }, + "output": { + "mime_type": "image/png", + "maximum_bytes": 16777216, + "reject_text_metadata": true + } +}