feat(engine): snapshot freezing + payload hash + legacy coercion
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
"""Module dataclasses, snapshot helpers, dispatcher, handlers."""
|
||||
from engine.modules.snapshot import (
|
||||
coerce_legacy_module,
|
||||
freeze_snapshot,
|
||||
payload_hash,
|
||||
)
|
||||
from engine.modules.types import (
|
||||
FixedValueEntry,
|
||||
FixedValueModule,
|
||||
@@ -16,3 +21,9 @@ __all__ = [
|
||||
"module_from_dict",
|
||||
"module_to_dict",
|
||||
]
|
||||
|
||||
__all__ += [
|
||||
"coerce_legacy_module",
|
||||
"freeze_snapshot",
|
||||
"payload_hash",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Snapshot freezing, content hashing, and legacy-shape coercion.
|
||||
|
||||
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
|
||||
node when a user picks a library row. The snapshot is decoupled from the
|
||||
library: graph execution reads only the snapshot, never the live DB.
|
||||
|
||||
Backward-compat: pre-SPA fixed_values modules in saved workflows lack the
|
||||
``library_*`` and ``instance`` keys. ``coerce_legacy_module`` upgrades them
|
||||
in-place to the canonical shape (still detached, ``library_id is None``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""UTC ISO timestamp with millisecond precision and ``Z`` suffix."""
|
||||
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def _fresh_instance() -> dict[str, Any]:
|
||||
"""Default per-snapshot instance overrides. Fresh dict per call."""
|
||||
return {
|
||||
"variable_binding": "",
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
}
|
||||
|
||||
|
||||
def payload_hash(payload: dict[str, Any]) -> str:
|
||||
"""SHA-256 of canonical JSON. Stable across key order + whitespace."""
|
||||
canonical = json.dumps(
|
||||
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def freeze_snapshot(library_row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the JSON shape that gets embedded in a node's modules array."""
|
||||
return {
|
||||
"library_id": library_row["id"],
|
||||
"library_snapshot_at": _now_iso(),
|
||||
"library_version_at_snapshot": library_row["version"],
|
||||
"type": library_row["type"],
|
||||
"name": library_row["name"],
|
||||
"category_id": library_row.get("category_id"),
|
||||
"payload": library_row["payload"],
|
||||
"instance": _fresh_instance(),
|
||||
}
|
||||
|
||||
|
||||
def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Upgrade a legacy module dict to canonical snapshot shape.
|
||||
|
||||
Idempotent: returns ``raw`` unchanged if it already has both
|
||||
``library_id`` and ``instance`` keys.
|
||||
"""
|
||||
if "instance" in raw and "library_id" in raw:
|
||||
return raw
|
||||
|
||||
payload: dict[str, Any]
|
||||
if raw.get("type") == "fixed_values" and "entries" in raw:
|
||||
legacy_entries = raw.get("entries", [])
|
||||
payload = {
|
||||
"values": [
|
||||
{
|
||||
"id": f"val_{i:04x}",
|
||||
"name": e.get("variable_name", ""),
|
||||
"value": e.get("value", ""),
|
||||
}
|
||||
for i, e in enumerate(legacy_entries)
|
||||
],
|
||||
}
|
||||
else:
|
||||
payload = raw.get("payload", {})
|
||||
|
||||
meta = raw.get("meta") or {}
|
||||
return {
|
||||
"library_id": raw.get("library_id"),
|
||||
"library_snapshot_at": raw.get("library_snapshot_at"),
|
||||
"library_version_at_snapshot": raw.get("library_version_at_snapshot"),
|
||||
"type": raw.get("type", "fixed_values"),
|
||||
"name": raw.get("name") or meta.get("name", ""),
|
||||
"category_id": raw.get("category_id"),
|
||||
"payload": payload,
|
||||
"instance": _fresh_instance(),
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for snapshot.py — payload hash, freeze, legacy coercion."""
|
||||
from engine.modules.snapshot import (
|
||||
coerce_legacy_module,
|
||||
freeze_snapshot,
|
||||
payload_hash,
|
||||
)
|
||||
|
||||
|
||||
def test_payload_hash_is_stable_across_key_order():
|
||||
a = {"x": 1, "y": [1, 2]}
|
||||
b = {"y": [1, 2], "x": 1}
|
||||
assert payload_hash(a) == payload_hash(b)
|
||||
|
||||
|
||||
def test_payload_hash_changes_with_value():
|
||||
a = {"x": 1}
|
||||
b = {"x": 2}
|
||||
assert payload_hash(a) != payload_hash(b)
|
||||
|
||||
|
||||
def test_payload_hash_returns_64_char_hex_string():
|
||||
h = payload_hash({"x": 1})
|
||||
assert isinstance(h, str)
|
||||
assert len(h) == 64
|
||||
int(h, 16) # raises ValueError if not hex
|
||||
|
||||
|
||||
def test_payload_hash_handles_non_ascii():
|
||||
a = {"name": "café"}
|
||||
h = payload_hash(a)
|
||||
assert len(h) == 64
|
||||
|
||||
|
||||
def test_freeze_snapshot_includes_required_fields():
|
||||
library_row = {
|
||||
"id": "wc_colors_a3f2",
|
||||
"type": "wildcard",
|
||||
"name": "colors",
|
||||
"category_id": "style",
|
||||
"version": 7,
|
||||
"payload": {"options": []},
|
||||
}
|
||||
snap = freeze_snapshot(library_row)
|
||||
assert snap["library_id"] == "wc_colors_a3f2"
|
||||
assert snap["library_version_at_snapshot"] == 7
|
||||
assert snap["library_snapshot_at"]
|
||||
assert snap["library_snapshot_at"].endswith("Z")
|
||||
assert "." in snap["library_snapshot_at"] # ms-precision
|
||||
assert snap["type"] == "wildcard"
|
||||
assert snap["name"] == "colors"
|
||||
assert snap["category_id"] == "style"
|
||||
assert snap["payload"] == {"options": []}
|
||||
assert snap["instance"] == {
|
||||
"variable_binding": "",
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
}
|
||||
|
||||
|
||||
def test_freeze_snapshot_each_call_yields_fresh_instance_dict():
|
||||
library_row = {
|
||||
"id": "x", "type": "wildcard", "name": "n",
|
||||
"category_id": None, "version": 1, "payload": {},
|
||||
}
|
||||
a = freeze_snapshot(library_row)
|
||||
b = freeze_snapshot(library_row)
|
||||
a["instance"]["variable_binding"] = "$mutated"
|
||||
assert b["instance"]["variable_binding"] == ""
|
||||
|
||||
|
||||
def test_freeze_snapshot_handles_missing_category_id():
|
||||
library_row = {
|
||||
"id": "x", "type": "wildcard", "name": "n",
|
||||
"version": 1, "payload": {},
|
||||
}
|
||||
snap = freeze_snapshot(library_row)
|
||||
assert snap["category_id"] is None
|
||||
|
||||
|
||||
def test_coerce_legacy_module_fills_missing_fields_for_fixed_values():
|
||||
legacy = {
|
||||
"id": "fv_x",
|
||||
"type": "fixed_values",
|
||||
"entries": [
|
||||
{"variable_name": "lens", "value": "85mm"},
|
||||
{"variable_name": "angle", "value": "wide"},
|
||||
],
|
||||
}
|
||||
coerced = coerce_legacy_module(legacy)
|
||||
assert coerced["library_id"] is None
|
||||
assert coerced["library_snapshot_at"] is None
|
||||
assert coerced["library_version_at_snapshot"] is None
|
||||
assert coerced["type"] == "fixed_values"
|
||||
assert coerced["payload"]["values"][0] == {
|
||||
"id": "val_0000", "name": "lens", "value": "85mm",
|
||||
}
|
||||
assert coerced["payload"]["values"][1] == {
|
||||
"id": "val_0001", "name": "angle", "value": "wide",
|
||||
}
|
||||
assert coerced["instance"]["variable_binding"] == ""
|
||||
|
||||
|
||||
def test_coerce_legacy_module_passes_through_already_coerced():
|
||||
already = {
|
||||
"library_id": "fv_x",
|
||||
"library_snapshot_at": "2026-04-26T00:00:00Z",
|
||||
"library_version_at_snapshot": 1,
|
||||
"type": "fixed_values",
|
||||
"name": "lens",
|
||||
"category_id": None,
|
||||
"payload": {"values": []},
|
||||
"instance": {
|
||||
"variable_binding": "$lens",
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
},
|
||||
}
|
||||
coerced = coerce_legacy_module(already)
|
||||
assert coerced == already
|
||||
|
||||
|
||||
def test_coerce_legacy_module_uses_meta_name_when_present():
|
||||
legacy = {
|
||||
"id": "fv_x",
|
||||
"type": "fixed_values",
|
||||
"meta": {"name": "from-meta"},
|
||||
"entries": [],
|
||||
}
|
||||
coerced = coerce_legacy_module(legacy)
|
||||
assert coerced["name"] == "from-meta"
|
||||
|
||||
|
||||
def test_coerce_legacy_module_prefers_top_level_name_over_meta():
|
||||
legacy = {
|
||||
"id": "x",
|
||||
"type": "fixed_values",
|
||||
"name": "top-level",
|
||||
"meta": {"name": "from-meta"},
|
||||
"entries": [],
|
||||
}
|
||||
coerced = coerce_legacy_module(legacy)
|
||||
assert coerced["name"] == "top-level"
|
||||
|
||||
|
||||
def test_coerce_legacy_module_non_fixed_values_uses_payload():
|
||||
raw = {
|
||||
"id": "wc_x",
|
||||
"type": "wildcard",
|
||||
"name": "x",
|
||||
"payload": {"options": [{"id": "o1", "value": "red", "weight": 1}]},
|
||||
}
|
||||
coerced = coerce_legacy_module(raw)
|
||||
assert coerced["type"] == "wildcard"
|
||||
assert coerced["payload"] == {"options": [{"id": "o1", "value": "red", "weight": 1}]}
|
||||
Reference in New Issue
Block a user