mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
204 lines
6.1 KiB
Python
204 lines
6.1 KiB
Python
"""
|
|
Checkpoints Service (R47).
|
|
Manages local workflow snapshots for safe iteration.
|
|
Implements limits (count/size) and oldest-eviction policy.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import time
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
try:
|
|
from config import DATA_DIR
|
|
except ImportError:
|
|
# Fallback for tests or decoupled run
|
|
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
|
|
|
from .integrity import IntegrityError, load_verified, save_verified
|
|
from .safe_io import PathTraversalError, resolve_under_root
|
|
|
|
logger = logging.getLogger("ComfyUI-OpenClaw.services.checkpoints")
|
|
|
|
CHECKPOINTS_DIR = os.path.join(DATA_DIR, "checkpoints")
|
|
MAX_CHECKPOINTS = 50
|
|
MAX_PAYLOAD_SIZE = 1 * 1024 * 1024 # 1MB
|
|
|
|
|
|
def _ensure_dir():
|
|
os.makedirs(CHECKPOINTS_DIR, exist_ok=True)
|
|
|
|
|
|
def _normalize_checkpoint_id(checkpoint_id: str) -> str:
|
|
text = str(checkpoint_id or "").strip()
|
|
try:
|
|
return str(uuid.UUID(text))
|
|
except (ValueError, AttributeError, TypeError) as exc:
|
|
raise ValueError("invalid checkpoint id") from exc
|
|
|
|
|
|
def _get_paths(checkpoint_id: str) -> Tuple[str, str]:
|
|
"""Return (meta_path, payload_path) for a given ID."""
|
|
cid = _normalize_checkpoint_id(checkpoint_id)
|
|
return (
|
|
resolve_under_root(CHECKPOINTS_DIR, f"{cid}.meta.json"),
|
|
resolve_under_root(CHECKPOINTS_DIR, f"{cid}.workflow.json"),
|
|
)
|
|
|
|
|
|
def list_checkpoints() -> List[Dict[str, Any]]:
|
|
"""List all checkpoints (metadata only), sorted by updated_at desc."""
|
|
_ensure_dir()
|
|
checkpoints = []
|
|
|
|
try:
|
|
with os.scandir(CHECKPOINTS_DIR) as it:
|
|
for entry in it:
|
|
if entry.name.endswith(".meta.json") and entry.is_file():
|
|
try:
|
|
|
|
meta = load_verified(entry.path, migrate=True)
|
|
checkpoints.append(meta)
|
|
except IntegrityError as e:
|
|
logger.critical(
|
|
f"R77: Integrity violation in checkpoint {entry.name}: {e}"
|
|
)
|
|
# Skip this file (fail-closed for this item)
|
|
except Exception:
|
|
logger.warning(f"Failed to read checkpoint meta: {entry.name}")
|
|
except OSError:
|
|
return []
|
|
|
|
# Sort by timestamp desc (newest first)
|
|
checkpoints.sort(key=lambda x: x.get("timestamp", 0), reverse=True)
|
|
return checkpoints
|
|
|
|
|
|
def get_checkpoint(checkpoint_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get full checkpoint data (meta + workflow)."""
|
|
try:
|
|
meta_path, payload_path = _get_paths(checkpoint_id)
|
|
except (ValueError, PathTraversalError):
|
|
return None
|
|
|
|
if not os.path.exists(meta_path) or not os.path.exists(payload_path):
|
|
return None
|
|
|
|
try:
|
|
|
|
meta = load_verified(meta_path, migrate=True)
|
|
workflow = load_verified(payload_path, migrate=True)
|
|
|
|
return {"id": checkpoint_id, "meta": meta, "workflow": workflow}
|
|
except IntegrityError as e:
|
|
logger.critical(f"R77: Integrity violation in checkpoint {checkpoint_id}: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error reading checkpoint {checkpoint_id}: {e}")
|
|
return None
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
def _atomic_write(filepath: str, content: str | bytes):
|
|
"""
|
|
Write to a temp file then rename to ensure atomicity.
|
|
Handles string (utf-8) or bytes.
|
|
"""
|
|
mode = "wb" if isinstance(content, bytes) else "w"
|
|
folder = os.path.dirname(filepath)
|
|
prefix = os.path.basename(filepath) + ".tmp"
|
|
|
|
# Create temp file in the same directory to ensure atomic rename works (same filesystem)
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
prefix=prefix, dir=folder, text=not isinstance(content, bytes)
|
|
)
|
|
try:
|
|
with os.fdopen(fd, mode, encoding="utf-8" if mode == "w" else None) as f:
|
|
f.write(content)
|
|
# Atomic rename
|
|
os.replace(tmp_path, filepath)
|
|
except Exception:
|
|
# Cleanup if something failed/crashed before replace
|
|
if os.path.exists(tmp_path):
|
|
os.remove(tmp_path)
|
|
raise
|
|
|
|
|
|
def create_checkpoint(
|
|
name: str, workflow: Dict[str, Any], description: str = ""
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new checkpoint.
|
|
Enforces size limits and eviction policy.
|
|
"""
|
|
_ensure_dir()
|
|
|
|
# 0. Validate Inputs
|
|
if len(name) > 100:
|
|
raise ValueError("Name exceeds 100 characters")
|
|
if len(description) > 500:
|
|
raise ValueError("Description exceeds 500 characters")
|
|
|
|
# 1. Validate Size
|
|
workflow_json = json.dumps(workflow)
|
|
if len(workflow_json.encode("utf-8")) > MAX_PAYLOAD_SIZE:
|
|
raise ValueError(f"Workflow exceeds max size of {MAX_PAYLOAD_SIZE} bytes")
|
|
|
|
# 2. Eviction (if needed)
|
|
current_list = list_checkpoints()
|
|
if len(current_list) >= MAX_CHECKPOINTS:
|
|
# Evict oldest (last in list)
|
|
to_remove = current_list[-1]
|
|
delete_checkpoint(to_remove["id"])
|
|
|
|
# 3. Save
|
|
cid = str(uuid.uuid4())
|
|
timestamp = time.time()
|
|
|
|
meta = {
|
|
"id": cid,
|
|
"name": name,
|
|
"description": description,
|
|
"timestamp": timestamp,
|
|
"size_bytes": len(workflow_json.encode("utf-8")),
|
|
"node_count": len(workflow) if isinstance(workflow, dict) else 0,
|
|
}
|
|
|
|
meta_path, payload_path = _get_paths(cid)
|
|
|
|
try:
|
|
save_verified(meta_path, meta)
|
|
save_verified(payload_path, workflow)
|
|
except Exception as e:
|
|
# Cleanup on fail (although atomic write minimizes this risk for individual files)
|
|
if os.path.exists(meta_path):
|
|
os.remove(meta_path)
|
|
if os.path.exists(payload_path):
|
|
os.remove(payload_path)
|
|
raise IOError(f"Failed to save checkpoint: {e}")
|
|
|
|
return meta
|
|
|
|
|
|
def delete_checkpoint(checkpoint_id: str) -> bool:
|
|
"""Delete a checkpoint."""
|
|
try:
|
|
meta_path, payload_path = _get_paths(checkpoint_id)
|
|
except (ValueError, PathTraversalError):
|
|
return False
|
|
|
|
deleted = False
|
|
if os.path.exists(meta_path):
|
|
os.remove(meta_path)
|
|
deleted = True
|
|
if os.path.exists(payload_path):
|
|
os.remove(payload_path)
|
|
deleted = True
|
|
|
|
return deleted
|