ComfyUI on a rented cloud server (AutoDL, RunPod) is only ever opened
from another device, so since v1.58.1 the plugin was view only there.
An empty allow_remote_changes file in the plugin folder, made on the
server and read once at start, now makes local_guard.may_change true for
every request; the middleware and the routes that check for themselves
(import, placement, scan choice, recipe focus) use it. is_local stays for
import's search of this computer's Downloads and for MCP. The page cannot
make or remove the file, and the file is git-ignored.
GET /anomalous/access answers {local, changes}. The header chip is now a
button: "view only" says what is refused and the two ways for cloud users
(SSH tunnel, the file); with the file it reads "remote changes on", in
yellow, and says that anyone with the address may change things.
docs/guides/cloud-server.md explains both ways and the risk; README,
AGENTS.md rule 4, backend.md and the CHANGELOG (v1.61.0) say so too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
510 lines
24 KiB
Python
510 lines
24 KiB
Python
"""HTTP routes for Workflow Recipes."""
|
|
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from aiohttp import web
|
|
import folder_paths
|
|
|
|
from . import recipe_schema as _recipe_schema_module
|
|
from .recipe_constants import *
|
|
from .recipe_images import (
|
|
_attach_preview_snapshots, _bounded_gallery_value, _decode_embedded_json,
|
|
_embedded_parameter_signature, _embedded_workflow_node_signature,
|
|
_embedded_workflow_payload, _gallery_parameter_diff, _output_source_path,
|
|
_parameter_gallery_images, _recipe_assets_dir, _recipe_gallery_images,
|
|
_recipe_cover_webp_bytes, _recent_output_pngs, _store_recipe_gallery_cover,
|
|
_thumbnail_webp_bytes,
|
|
)
|
|
from .recipe_schema import (
|
|
_build_model_references,
|
|
_computed_identity_for_reference as _schema_computed_identity_for_reference,
|
|
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
|
|
_identity_for_reference as _schema_identity_for_reference,
|
|
_model_reference_key, _model_reference_specs,
|
|
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
|
|
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
|
|
)
|
|
from .recipe_store import (
|
|
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
|
|
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
|
|
)
|
|
from .local_guard import may_change
|
|
from .utils import require_filename, resolve_within
|
|
from .workflow_schema import (
|
|
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
|
|
_parameter_signature, _recipe_receipt, _validate_workflow,
|
|
_volatile_widget_indexes, _widget_values, _workflow_fingerprint,
|
|
_workflow_link_records, _workflow_node_key, _workflow_node_signature,
|
|
_workflow_node_types,
|
|
)
|
|
|
|
|
|
def _computed_identity_for_reference(saved_value):
|
|
"""Compatibility facade for callers that patched the former route module."""
|
|
previous = _recipe_schema_module._resolve_exact_model_reference
|
|
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
|
|
try:
|
|
return _schema_computed_identity_for_reference(saved_value)
|
|
finally:
|
|
_recipe_schema_module._resolve_exact_model_reference = previous
|
|
|
|
|
|
def _identity_for_reference(saved_value):
|
|
"""Compatibility facade for the former monolithic recipes module."""
|
|
previous = _recipe_schema_module._resolve_exact_model_reference
|
|
_recipe_schema_module._resolve_exact_model_reference = _resolve_exact_model_reference
|
|
try:
|
|
return _schema_identity_for_reference(saved_value)
|
|
finally:
|
|
_recipe_schema_module._resolve_exact_model_reference = previous
|
|
|
|
|
|
def _enrich_recipe(*args, **kwargs):
|
|
"""Compatibility facade while domain callers migrate to recipe_schema."""
|
|
previous = _recipe_schema_module._identity_for_reference
|
|
_recipe_schema_module._identity_for_reference = _identity_for_reference
|
|
try:
|
|
return _schema_enrich_recipe(*args, **kwargs)
|
|
finally:
|
|
_recipe_schema_module._identity_for_reference = previous
|
|
|
|
async def api_get_recipes(request):
|
|
recipes_dir = get_recipes_dir()
|
|
recipes = await asyncio.to_thread(_list_recipes, recipes_dir)
|
|
return web.json_response({"recipes": recipes})
|
|
|
|
|
|
async def api_save_recipe(request):
|
|
try:
|
|
payload = await request.json()
|
|
verify_identities = payload.get("verify_model_identities", False)
|
|
if not isinstance(verify_identities, bool):
|
|
raise ValueError("Invalid verification preference")
|
|
recipe = _normalise_recipe(payload)
|
|
except (ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
|
|
|
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
|
|
try:
|
|
recipes_dir = get_recipes_dir()
|
|
recipe["created_timestamp"] = recipe["timestamp"]
|
|
recipe["updated_timestamp"] = recipe["timestamp"]
|
|
recipe = await asyncio.to_thread(
|
|
_enrich_recipe,
|
|
recipe,
|
|
recipes_dir,
|
|
filename,
|
|
True,
|
|
False,
|
|
verify_identities,
|
|
)
|
|
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
|
except (OSError, ValueError):
|
|
return web.json_response({"status": "error", "message": "Could not save recipe"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"filename": filename,
|
|
"receipt": _recipe_receipt(recipe, filename),
|
|
})
|
|
|
|
|
|
async def api_delete_recipe(request):
|
|
try:
|
|
payload = await request.json()
|
|
filename = require_filename(payload.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
recipes_dir = get_recipes_dir()
|
|
path = resolve_within(recipes_dir, filename)
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid filename"}, status=400)
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
|
|
|
try:
|
|
await asyncio.to_thread(_delete_recipe_with_history, recipes_dir, filename)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not delete recipe"}, status=500)
|
|
return web.json_response({"status": "success"})
|
|
|
|
|
|
async def api_get_recipe_full(request):
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
recipes_dir = get_recipes_dir()
|
|
path = resolve_within(recipes_dir, filename)
|
|
data = await asyncio.to_thread(_read_recipe, path)
|
|
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
except (ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
|
|
return web.json_response({"status": "success", "data": data})
|
|
|
|
|
|
async def api_get_recipe_asset(request):
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
asset_id = require_filename(request.query.get("asset", ""))
|
|
if not filename.endswith(".json") or not asset_id.endswith(".webp"):
|
|
raise ValueError("Invalid recipe asset")
|
|
recipes_dir = get_recipes_dir()
|
|
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
asset_path = resolve_within(_recipe_assets_dir(recipes_dir, filename), asset_id)
|
|
if not os.path.isfile(asset_path):
|
|
raise FileNotFoundError
|
|
except (ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe asset"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe asset"}, status=500)
|
|
return web.FileResponse(asset_path)
|
|
|
|
|
|
async def api_get_recipe_gallery(request):
|
|
"""Find recent output PNGs whose embedded node composition matches one recipe."""
|
|
try:
|
|
filename = request.query.get("filename")
|
|
fingerprint = request.query.get("fingerprint")
|
|
if filename:
|
|
filename = require_filename(filename)
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid recipe")
|
|
recipes_dir = get_recipes_dir()
|
|
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
fingerprint = _workflow_node_signature(recipe["workflow"])["value"]
|
|
elif not isinstance(fingerprint, str) or not SHA256_PATTERN.fullmatch(fingerprint):
|
|
raise ValueError("Invalid fingerprint")
|
|
images, scanned = await asyncio.to_thread(_recipe_gallery_images, fingerprint.lower())
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe gallery request"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe gallery"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"fingerprint": fingerprint.lower(),
|
|
"match_mode": "node-types",
|
|
"images": images,
|
|
"scanned": scanned,
|
|
})
|
|
|
|
|
|
async def api_get_recipe_parameter_gallery(request):
|
|
"""Find output PNGs with the same saved node parameters as one recipe."""
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid recipe")
|
|
recipes_dir = get_recipes_dir()
|
|
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
signature = _parameter_signature(recipe["workflow"])["value"]
|
|
images, scanned = await asyncio.to_thread(_parameter_gallery_images, signature.lower())
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe parameter gallery request"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe parameter gallery"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"fingerprint": signature.lower(),
|
|
"match_mode": "parameters",
|
|
"images": images,
|
|
"scanned": scanned,
|
|
})
|
|
|
|
|
|
async def api_get_recipe_gallery_compare(request):
|
|
"""Return bounded parameters and differences for one matched output image."""
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
source_image = _normalise_source_image({
|
|
"type": "output",
|
|
"filename": request.query.get("image_filename", ""),
|
|
"subfolder": request.query.get("image_subfolder", ""),
|
|
})
|
|
recipes_dir = get_recipes_dir()
|
|
recipe = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
image_path = await asyncio.to_thread(_output_source_path, source_image)
|
|
payload = await asyncio.to_thread(_embedded_workflow_payload, image_path)
|
|
if not payload or not (payload.get("workflow") or payload.get("prompt")):
|
|
raise ValueError("Image has no workflow metadata")
|
|
comparison = _gallery_parameter_diff(recipe.get("workflow"), payload)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"image": source_image,
|
|
"match_mode": "node-types",
|
|
"comparison": comparison,
|
|
})
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid gallery comparison request"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "Image or recipe not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read gallery comparison"}, status=500)
|
|
|
|
|
|
async def api_set_recipe_gallery_cover(request):
|
|
"""Promote one verified output image to a portable recipe cover asset."""
|
|
try:
|
|
payload = await request.json()
|
|
filename = require_filename(payload.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
source_image = _normalise_source_image(payload.get("source_image"))
|
|
recipes_dir = get_recipes_dir()
|
|
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
cover = await asyncio.to_thread(_store_recipe_gallery_cover, recipes_dir, filename, source_image)
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe cover"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "Output image not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not create recipe cover"}, status=500)
|
|
|
|
recipe = copy.deepcopy(existing)
|
|
recipe["source_image"] = source_image
|
|
recipe["thumbnail"] = None
|
|
presentation = dict(recipe.get("presentation") or {})
|
|
presentation["cover_asset_id"] = cover["asset_id"]
|
|
recipe["presentation"] = presentation
|
|
recipe["timestamp"] = int(time.time() * 1000)
|
|
recipe["updated_timestamp"] = recipe["timestamp"]
|
|
recipe["workflow_fingerprint"] = _workflow_fingerprint(recipe["workflow"])
|
|
if recipe.get("workflow_scope") not in {"partial", "complete"}:
|
|
recipe["workflow_scope"] = "complete"
|
|
recipe["schema_version"] = max(7, int(recipe.get("schema_version") or 1))
|
|
|
|
try:
|
|
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
|
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not update recipe cover"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"filename": filename,
|
|
"cover": cover,
|
|
"source_image": source_image,
|
|
"workflow_fingerprint": recipe["workflow_fingerprint"],
|
|
})
|
|
|
|
|
|
async def api_update_recipe(request):
|
|
"""Replace one recipe while preserving its prior state in local history."""
|
|
try:
|
|
payload = await request.json()
|
|
filename = require_filename(payload.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
recipes_dir = get_recipes_dir()
|
|
path = resolve_within(recipes_dir, filename)
|
|
existing = await asyncio.to_thread(_read_recipe, path)
|
|
if not isinstance(existing, dict) or not isinstance(existing.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
refresh_identities = bool(payload.get("refreshIdentities"))
|
|
verify_identities = payload.get("verify_model_identities", False)
|
|
if not isinstance(verify_identities, bool):
|
|
raise ValueError("Invalid verification preference")
|
|
refresh_only = refresh_identities and set(payload).issubset({"filename", "refreshIdentities"})
|
|
recipe = copy.deepcopy(existing) if refresh_only else _updated_recipe(payload, existing)
|
|
recipe = await asyncio.to_thread(
|
|
_enrich_recipe,
|
|
recipe,
|
|
recipes_dir,
|
|
filename,
|
|
True,
|
|
refresh_identities,
|
|
verify_identities,
|
|
)
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe"}, status=500)
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
|
|
|
try:
|
|
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
|
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"filename": filename,
|
|
"receipt": _recipe_receipt(recipe, filename),
|
|
})
|
|
|
|
|
|
def _set_recipe_focus(recipes_dir, filename, entries):
|
|
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
|
|
focus = _normalise_focus_params(entries)
|
|
path = resolve_within(recipes_dir, filename)
|
|
if os.path.getsize(path) > MAX_RECIPE_BYTES:
|
|
raise ValueError("Recipe is too large")
|
|
recipe = _read_recipe(path)
|
|
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe")
|
|
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
|
|
recipe["params"] = {**params, "focusParams": focus}
|
|
_write_recipe(recipes_dir, filename, recipe)
|
|
return focus
|
|
|
|
|
|
async def api_set_recipe_focus(request):
|
|
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer (local_guard.may_change)."""
|
|
if not may_change(request):
|
|
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
|
|
try:
|
|
payload = await request.json()
|
|
filename = require_filename(payload.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
|
|
return web.json_response({"status": "success", "focusParams": focus})
|
|
|
|
|
|
async def api_get_recipe_history(request):
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
if not filename.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
recipes_dir = get_recipes_dir()
|
|
# Verify the root recipe exists before exposing its history directory.
|
|
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
versions = await asyncio.to_thread(_list_recipe_history, recipes_dir, filename)
|
|
except (ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe history"}, status=500)
|
|
return web.json_response({"status": "success", "versions": versions})
|
|
|
|
|
|
async def api_get_recipe_version(request):
|
|
"""Return one bounded historical recipe for semantic comparison only."""
|
|
try:
|
|
filename = require_filename(request.query.get("filename", ""))
|
|
version = require_filename(request.query.get("version", ""))
|
|
if not filename.endswith(".json") or not version.endswith(".json"):
|
|
raise ValueError("Invalid recipe version")
|
|
recipes_dir = get_recipes_dir()
|
|
await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
data = await asyncio.to_thread(
|
|
_read_recipe,
|
|
resolve_within(_history_dir(recipes_dir, filename), version),
|
|
)
|
|
if not isinstance(data, dict) or not isinstance(data.get("workflow"), dict):
|
|
raise ValueError("Invalid recipe version")
|
|
except (ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe version"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "Recipe version not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not read recipe version"}, status=500)
|
|
return web.json_response({"status": "success", "data": data})
|
|
|
|
|
|
async def api_restore_recipe_version(request):
|
|
try:
|
|
payload = await request.json()
|
|
filename = require_filename(payload.get("filename", ""))
|
|
version = require_filename(payload.get("version", ""))
|
|
if not filename.endswith(".json") or not version.endswith(".json"):
|
|
raise ValueError("Invalid filename")
|
|
recipes_dir = get_recipes_dir()
|
|
existing = await asyncio.to_thread(_read_recipe, resolve_within(recipes_dir, filename))
|
|
historical = await asyncio.to_thread(
|
|
_read_recipe,
|
|
resolve_within(_history_dir(recipes_dir, filename), version),
|
|
)
|
|
if not isinstance(existing, dict) or not isinstance(historical, dict):
|
|
raise ValueError("Invalid recipe")
|
|
recipe = _updated_recipe(historical, existing)
|
|
recipe = await asyncio.to_thread(_enrich_recipe, recipe, recipes_dir, filename, False)
|
|
except (AttributeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid recipe"}, status=400)
|
|
except FileNotFoundError:
|
|
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
|
|
except Exception:
|
|
return web.json_response({"status": "error", "message": "Invalid request"}, status=400)
|
|
|
|
try:
|
|
await asyncio.to_thread(_archive_recipe, recipes_dir, filename, existing)
|
|
await asyncio.to_thread(_write_recipe, recipes_dir, filename, recipe)
|
|
except OSError:
|
|
return web.json_response({"status": "error", "message": "Could not restore recipe history"}, status=500)
|
|
return web.json_response({
|
|
"status": "success",
|
|
"filename": filename,
|
|
"receipt": _recipe_receipt(recipe, filename),
|
|
})
|
|
|
|
|
|
async def api_refresh_recipe_identity(request):
|
|
"""Check exact saved model references using cached metadata only.
|
|
|
|
This deliberately does not walk model folders and never computes a full-file
|
|
hash. The response is transient current-machine availability, separate from
|
|
the historical identity stored in the recipe.
|
|
"""
|
|
try:
|
|
payload = await request.json()
|
|
references = payload.get("references", [])
|
|
if not isinstance(references, list):
|
|
raise ValueError("Invalid references")
|
|
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
|
|
return web.json_response({"status": "error", "message": "Invalid references"}, status=400)
|
|
|
|
results = []
|
|
for reference in references[:128]:
|
|
if not isinstance(reference, dict):
|
|
continue
|
|
saved_value = reference.get("saved_value")
|
|
resolved = _resolve_exact_model_reference(saved_value)
|
|
result = {
|
|
"node_id": reference.get("node_id"),
|
|
"widget_index": reference.get("widget_index"),
|
|
"saved_value": saved_value,
|
|
"availability": "available" if resolved else "missing",
|
|
}
|
|
if resolved:
|
|
result["local_path"] = resolved["path"]
|
|
identity_result = _identity_for_reference(saved_value)
|
|
result["identity"] = identity_result[0] if isinstance(identity_result, tuple) else identity_result
|
|
results.append(result)
|
|
return web.json_response({"status": "success", "results": results})
|