Files
JasonHoku-ComfyUI-Ultimate-…/manifest_utils.py
T
Jason HokuandClaude Fable 5.1 968b0dfdfc feat(sweep): attach to a running identical sweep, job_prompt route, configs in sweep meta
Identity = session + ordered job fingerprints + start_at_job + overwrite_existing; anything
else gets a clear 409. GET /config_tester/sweep/job_prompt returns one job's API prompt.
Sweep manifests now save their workflow configs in meta.configs_json.
pause/resume emit a progress event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 00:52:56 -08:00

336 lines
12 KiB
Python

"""
Manifest File Management
Handles saving and merging manifest.json with user modifications
"""
import json
import os
import re
import threading
import time
# Thread-safe lock for manifest file writes.
# Required when multiple distributed workers submit results concurrently.
_manifest_lock = threading.Lock()
# ---------------------------------------------------------------------------
# Pairwise review votes (manifest top-level "comparisons" list)
# ---------------------------------------------------------------------------
_WINNERS = ("a", "b", "tie")
def comparison_key(c):
"""Dedupe key of a vote: "ts:a:b" (string)."""
return f"{c.get('ts')}:{c.get('a')}:{c.get('b')}"
def validate_comparison(c):
"""Return a cleaned vote dict, or None when the entry is not a valid vote."""
if not isinstance(c, dict):
return None
a, b = c.get("a"), c.get("b")
if type(a) is not int or type(b) is not int or a == b:
return None
if c.get("winner") not in _WINNERS:
return None
ts = c.get("ts")
if type(ts) not in (int, float) or ts != ts or ts < 0:
return None
mode = c.get("mode")
mode = mode[:32] if isinstance(mode, str) and mode else "matched"
out = {"a": a, "b": b, "winner": c["winner"], "ts": ts, "mode": mode}
if isinstance(c.get("blind"), bool):
out["blind"] = c["blind"]
return out
def merge_comparison_lists(existing, append, remove_keys):
"""
Merge votes: keep existing order, drop entries whose key is in remove_keys,
append valid new entries whose key is not present yet.
Invalid existing entries are kept untouched. Returns (merged, added, removed).
"""
remove = set(remove_keys or ())
merged = []
seen = set()
removed = 0
for c in (existing or []):
if isinstance(c, dict) and validate_comparison(c) is not None:
k = comparison_key(c)
if k in remove:
removed += 1
continue
seen.add(k)
merged.append(c)
added = 0
for c in (append or []):
v = validate_comparison(c)
if v is None:
continue
k = comparison_key(v)
if k in seen or k in remove:
continue
seen.add(k)
merged.append(v)
added += 1
return merged, added, removed
def preserve_disk_comparisons(disk_manifest, target_manifest):
"""
Copy the on-disk votes onto target_manifest (disk wins). Only the compare
routes create or remove votes, so a stale in-memory copy must never
resurrect undone/cleared votes or drop new ones.
"""
try:
if (isinstance(disk_manifest, dict) and "comparisons" in disk_manifest
and isinstance(target_manifest, dict)):
target_manifest["comparisons"] = disk_manifest["comparisons"]
except Exception:
pass
def _atomic_write_json(path, data):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
last = None
for _ in range(5):
try:
os.replace(tmp, path)
return
except PermissionError as e: # Windows: a reader has the file open
last = e
time.sleep(0.04)
# Fall back to a plain in-place write (same discipline as save_manifest)
with open(path, "w") as f:
json.dump(data, f, indent=4)
try:
os.remove(tmp)
except OSError:
pass
def update_meta(manifest_path, updates):
"""
Locked read-modify-write of ONLY keys of the manifest's "meta" dict (items written since the
caller last looked are preserved). Raises on a missing/unreadable manifest; nothing is written then.
"""
with _manifest_lock:
with open(manifest_path, "r") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("manifest is not a JSON object")
meta = data.get("meta")
if not isinstance(meta, dict):
meta = {}
meta.update(updates)
data["meta"] = meta
_atomic_write_json(manifest_path, data)
return meta
def update_manifest_comparisons(manifest_path, append=(), remove_keys=(), clear=False):
"""
Locked read-modify-write of ONLY the "comparisons" list.
Raises FileNotFoundError when the manifest is missing and ValueError/JSON
errors when it is unreadable (nothing is written in that case).
"""
with _manifest_lock:
with open(manifest_path, "r") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("manifest is not a JSON object")
if clear:
old = data.get("comparisons")
removed = len(old) if isinstance(old, list) else 0
data["comparisons"], added = [], 0
else:
existing = data.get("comparisons")
if not isinstance(existing, list):
existing = []
data["comparisons"], added, removed = merge_comparison_lists(existing, append, remove_keys)
_atomic_write_json(manifest_path, data)
return {"comparisons": data["comparisons"], "added": added,
"removed": removed, "total": len(data["comparisons"])}
def merge_manifest_user_changes(manifest_path, existing_data):
"""
Reload manifest and merge user changes (favorites, rejected, notes) to preserve them.
This prevents losing user modifications when the manifest is saved during generation.
Args:
manifest_path: Path to the manifest.json file
existing_data: The current manifest data dictionary to update
"""
try:
with open(manifest_path, "r") as f:
current_manifest = json.load(f)
# Votes are owned by the compare routes: the disk copy always wins
preserve_disk_comparisons(current_manifest, existing_data)
# Create lookup dict of current items by ID from DISK
current_items_dict = {
item.get("id"): item
for item in current_manifest.get("items", [])
if "id" in item
}
# Track merge statistics
merged_count = 0
favorites_preserved = 0
rejected_preserved = 0
notes_preserved = 0
# Update ALL items in existing_data (both new and old) with user modifications from disk
for item in existing_data["items"]:
item_id = item.get("id")
if item_id and item_id in current_items_dict:
current_item = current_items_dict[item_id]
merged_count += 1
# Preserve user-modified fields from the version on disk
# Field names: "favorited" (not "favorite"), "rejected", "note" (not "notes")
if current_item.get("favorited"):
item["favorited"] = True
favorites_preserved += 1
if current_item.get("rejected"):
item["rejected"] = True
rejected_preserved += 1
if current_item.get("note"):
item["note"] = current_item["note"]
notes_preserved += 1
# Per-image metrics computed for an existing session (metrics_runner) live only on
# disk; a generation run's in-memory copy must not wipe them on its next save.
if "metrics" in current_item and "metrics" not in item:
item["metrics"] = current_item["metrics"]
# else: This is a new item, no merge needed
# Log merge results if any user changes were preserved
if favorites_preserved > 0 or rejected_preserved > 0 or notes_preserved > 0:
print(f"[GridTester] 🔄 Merged user changes: {favorites_preserved} favorites, {rejected_preserved} rejected, {notes_preserved} notes (from {merged_count} items)")
except FileNotFoundError:
# First save, no existing manifest to merge
pass
except Exception as e:
print(f"[GridTester] ⚠️ Warning: Could not merge manifest changes: {e}")
import traceback
traceback.print_exc()
def save_manifest(manifest_path, manifest_data):
"""
Save manifest data to disk, merging user changes first.
Thread-safe: uses _manifest_lock for concurrent access from distributed workers.
Args:
manifest_path: Path to the manifest.json file
manifest_data: Manifest data dictionary to save
"""
with _manifest_lock:
# Merge user changes from disk first
merge_manifest_user_changes(manifest_path, manifest_data)
# Save merged data
with open(manifest_path, "w") as f:
json.dump(manifest_data, f, indent=4)
print(f"[GridTester] 💾 Manifest saved: {manifest_path}")
def load_existing_manifest(manifest_path):
"""
Load existing manifest data from disk.
Args:
manifest_path: Path to the manifest.json file
Returns:
dict: Manifest data or new empty manifest structure
"""
if os.path.exists(manifest_path):
try:
with open(manifest_path, "r") as f:
data = json.load(f)
print(f"[GridTester] 📂 Loaded existing manifest with {len(data.get('items', []))} items")
return data
except Exception as e:
print(f"[GridTester] ⚠️ Error loading manifest: {e}")
# Return new manifest structure
return {
"session_name": "default",
"items": []
}
def _item_file_path(manifest_path, item):
"""Local path of an item's image, derived from its /view URL, confined to <session>/images."""
try:
m = re.search(r"[?&]filename=([^&]+)", str(item.get("file", "")))
filename = os.path.basename(m.group(1)) if m else ""
if not filename:
return None
images_dir = os.path.join(os.path.dirname(os.path.abspath(manifest_path)), "images")
path = os.path.abspath(os.path.join(images_dir, filename))
if os.path.dirname(path) != os.path.abspath(images_dir):
return None
return path
except Exception:
return None
def append_items(manifest_path, items, remove_ids=(), session_name=None, default_meta=None):
"""
Add items to a manifest (newest first, like every other writer) with a fresh read of the file on disk.
Used by workflow sweeps, where a standard Generator run on the same session may write the manifest
between two jobs, so an in-memory copy held elsewhere would drop its items. Thread-safe.
`remove_ids` are deleted (item + image file) after the new items are inserted (overwrite_existing).
Returns the manifest dict that was written.
"""
remove_ids = set(remove_ids or ())
with _manifest_lock:
data = None
if os.path.exists(manifest_path):
try:
with open(manifest_path, "r") as f:
data = json.load(f)
except Exception as e:
print(f"[GridTester] ⚠️ Could not read manifest before append: {e}")
if not isinstance(data, dict):
data = {"session_name": session_name or "default", "items": []}
data.setdefault("items", [])
if session_name:
data.setdefault("session_name", session_name)
if default_meta and not data.get("meta"):
data["meta"] = default_meta
for item in items:
data["items"].insert(0, item)
if remove_ids:
kept = []
for it in data["items"]:
if it.get("id") in remove_ids and not any(it is n for n in items):
path = _item_file_path(manifest_path, it)
if path and os.path.isfile(path):
try:
os.remove(path)
except OSError:
pass
else:
kept.append(it)
data["items"] = kept
os.makedirs(os.path.dirname(os.path.abspath(manifest_path)), exist_ok=True)
tmp = manifest_path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
os.replace(tmp, manifest_path)
return data