A run with Gemini 3.8 Flash asked for picture to picture and built it node by node, 13 failed calls on the way, with an official example ready:
- list_workflows was 23,630 characters on 17 cards, 21,033 of them settings. Antigravity files an answer over about 4,000 away, unread. A list of more than 3 now goes with name, route, tags and short notes.
- Examples showed only when no saved workflow matched, and only by English words: 图生图 found nothing. They are always named now, and found by their Chinese titles, img2img, txt2img and inpaint.
- Every card's route line was empty, and cards_of found nothing, since c84b596: _list_recipes leaves the graph out, and the tests fed cards that had one. Both ask for it now, with a test on a file on disk.
A waiting task the user wrote to also takes its name from what they wrote, instead of the AI's "take the task".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.3 KiB
Python
140 lines
5.3 KiB
Python
"""User-owned Workflow Recipe records and bounded history persistence."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
import folder_paths
|
|
|
|
from .recipe_constants import MAX_HISTORY_VERSIONS
|
|
from .recipe_images import _recipe_assets_dir
|
|
from .trash import move_to_trash
|
|
from .utils import require_filename, resolve_within
|
|
|
|
def get_recipes_dir():
|
|
"""Return the user-owned recipe directory, creating it if needed."""
|
|
user_dir = (
|
|
folder_paths.get_user_directory()
|
|
if hasattr(folder_paths, "get_user_directory")
|
|
else os.path.join(folder_paths.base_path, "user", "default")
|
|
)
|
|
recipes_dir = os.path.join(user_dir, "workflows", "anomalous_recipes")
|
|
os.makedirs(recipes_dir, exist_ok=True)
|
|
return os.path.realpath(recipes_dir)
|
|
|
|
|
|
def _read_recipe(path):
|
|
with open(path, "r", encoding="utf-8") as recipe_file:
|
|
return json.load(recipe_file)
|
|
|
|
|
|
def _list_recipes(recipes_dir, with_workflow=False):
|
|
recipes = []
|
|
try:
|
|
with os.scandir(recipes_dir) as entries:
|
|
for entry in entries:
|
|
if not entry.is_file() or not entry.name.endswith(".json"):
|
|
continue
|
|
try:
|
|
filename = require_filename(entry.name)
|
|
data = _read_recipe(resolve_within(recipes_dir, filename))
|
|
if not isinstance(data, dict):
|
|
continue
|
|
# The graph can be much larger than all card data combined.
|
|
summary = data if with_workflow else {key: value for key, value in data.items() if key != "workflow"}
|
|
recipes.append({"filename": filename, "data": summary})
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
except OSError:
|
|
return []
|
|
recipes.sort(key=lambda item: item["data"].get("timestamp", 0), reverse=True)
|
|
return recipes
|
|
|
|
|
|
def _write_recipe(recipes_dir, filename, recipe):
|
|
path = resolve_within(recipes_dir, filename)
|
|
fd, temp_path = tempfile.mkstemp(prefix=".recipe-", suffix=".tmp", dir=recipes_dir)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as recipe_file:
|
|
json.dump(recipe, recipe_file, ensure_ascii=False, separators=(",", ":"))
|
|
recipe_file.flush()
|
|
os.fsync(recipe_file.fileno())
|
|
os.replace(temp_path, path)
|
|
finally:
|
|
if os.path.exists(temp_path):
|
|
try:
|
|
os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _history_dir(recipes_dir, filename, create=False):
|
|
"""Return the contained, user-data-only history directory for one recipe."""
|
|
stem = os.path.splitext(require_filename(filename))[0]
|
|
history_dir = resolve_within(recipes_dir, ".history", stem)
|
|
if create:
|
|
os.makedirs(history_dir, exist_ok=True)
|
|
return history_dir
|
|
|
|
|
|
def _archive_recipe(recipes_dir, filename, recipe):
|
|
"""Atomically retain a bounded pre-update snapshot before replacing a recipe."""
|
|
history_dir = _history_dir(recipes_dir, filename, create=True)
|
|
version_name = f"version_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.json"
|
|
_write_recipe(history_dir, version_name, recipe)
|
|
|
|
entries = []
|
|
with os.scandir(history_dir) as scan:
|
|
for entry in scan:
|
|
if entry.is_file() and entry.name.endswith(".json"):
|
|
entries.append(entry)
|
|
entries.sort(key=lambda entry: entry.stat().st_mtime_ns, reverse=True)
|
|
for entry in entries[MAX_HISTORY_VERSIONS:]:
|
|
try:
|
|
os.remove(resolve_within(history_dir, entry.name))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _list_recipe_history(recipes_dir, filename):
|
|
history_dir = _history_dir(recipes_dir, filename)
|
|
versions = []
|
|
try:
|
|
with os.scandir(history_dir) as entries:
|
|
for entry in entries:
|
|
if not entry.is_file() or not entry.name.endswith(".json"):
|
|
continue
|
|
try:
|
|
version = require_filename(entry.name)
|
|
data = _read_recipe(resolve_within(history_dir, version))
|
|
if not isinstance(data, dict):
|
|
continue
|
|
versions.append({
|
|
"version": version,
|
|
"timestamp": data.get("timestamp", 0),
|
|
"name": data.get("name", ""),
|
|
"workflow_fingerprint": data.get("workflow_fingerprint"),
|
|
"model_reference_count": len(
|
|
(data.get("params") or {}).get("model_references", [])
|
|
if isinstance(data.get("params"), dict)
|
|
else []
|
|
),
|
|
})
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
except OSError:
|
|
return []
|
|
versions.sort(key=lambda item: item["timestamp"], reverse=True)
|
|
return versions
|
|
|
|
|
|
def _delete_recipe_with_history(recipes_dir, filename):
|
|
"""Moves an explicitly selected recipe, its history and its images to the Recycle Bin."""
|
|
path = resolve_within(recipes_dir, filename)
|
|
if not os.path.isfile(path):
|
|
raise FileNotFoundError(path)
|
|
move_to_trash(path, _history_dir(recipes_dir, filename), _recipe_assets_dir(recipes_dir, filename))
|