fix(nodes): Save to library review fixes (#90)

- A switch wired from a node the graph cannot read and off at run time
  marks the node's run key, so an earlier run's tracker entry never
  links this run's images.
- The scan reports such files as not_saved_wired ("only the run itself
  can link it") instead of not_saved.
- Workflow-only metadata ignores muted and bypassed PromptManager nodes
  and reads nodes inside subgraph definitions.
- The Focused, Split and Pair footer says when the switch comes from a
  link.
This commit is contained in:
Vito Sansevero
2026-10-27 19:24:43 -07:00
parent bb0a8bf5ed
commit 49a6a5a2a8
8 changed files with 100 additions and 12 deletions
+1 -1
View File
@@ -209,7 +209,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
prompt_id = None
extended_tags = []
if not save_to_library:
self._skip_not_saved()
self._skip_not_saved(prompt, unique_id)
elif storage_text and storage_text.strip():
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
+11 -4
View File
@@ -208,16 +208,23 @@ class PromptManagerBase:
"run_id": executing_run_id(),
}
def _skip_not_saved(self) -> None:
def _skip_not_saved(self, prompt_graph: Any = None, unique_id: Any = None) -> None:
"""``save_to_library`` is off: nothing is saved, counted or tracked (#90).
Nothing is marked on the tracker either: a node with its switch off is
no run prompt (``utils.prompt_graph``), so no image looks up its run key.
A literal off makes the node no run prompt (``utils.prompt_graph``), so
no image looks up its run key. A switch wired from a node the graph
cannot read keeps the node a run prompt: its run key is marked like an
empty editor's, so an earlier run's entry of that key never links this
run's images.
"""
self.logger.info(
message = (
"Save to library is off for this node: its prompt is not saved, the "
"run is not counted and its images are not linked"
)
if isinstance(prompt_graph, dict) and unique_id is not None:
self._mark_untracked(prompt_graph, unique_id, message)
else:
self.logger.info(message)
def _skip_empty_prompt(self, prompt_graph: Any, unique_id: Any) -> None:
"""The ``text`` editor is empty: save and track nothing (#342).
+1 -1
View File
@@ -108,7 +108,7 @@ class PromptManagerPair(PromptManagerFocused):
)
result = positive_conditioning, negative_conditioning, positive, negative_text
if not save_to_library:
self._skip_not_saved()
self._skip_not_saved(prompt, unique_id)
return result
record_pair_execution(
extra_pnginfo,
+1 -1
View File
@@ -188,7 +188,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
prompt_id = None
extended_tags = []
if not save_to_library:
self._skip_not_saved()
self._skip_not_saved(prompt, unique_id)
elif storage_text and storage_text.strip():
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
+11 -3
View File
@@ -257,6 +257,10 @@ SCAN_DETAILS = {
"error": "Error: the file could not be processed (see the server log)",
"found": "Metadata found, but no prompt text to store",
"not_saved": "Skipped · its PromptManager node does not save to the library",
"not_saved_wired": (
"Skipped · its Save to library switch is wired from another node, so only "
"the run itself can link it"
),
None: "No prompt metadata in the file",
}
@@ -1710,10 +1714,14 @@ class AdminRoutesMixin:
except (ValueError, TypeError):
# Preserve malformed metadata for conservative resolver rejection.
pass
if metadata_opted_out(canonical, unreadable=True):
# Save to library was off (or unreadable) for the run's prompt: no
# prompt, link, recorded-run fallback or Needs review item (#90)
if metadata_opted_out(canonical):
# Save to library was off for the run's prompt: no prompt, link,
# recorded-run fallback or Needs review item (#90)
return "not_saved"
if metadata_opted_out(canonical, unreadable=True):
# The switch is wired from a node the file cannot show: only the
# live run (the node saw the value) can link it, never a scan
return "not_saved_wired"
if not canonical.get("prompt") and not canonical.get("parameters"):
return self._link_recorded_run(media_file, canonical, detailed, history)
_with_file_size(canonical, media_file)
+61
View File
@@ -281,6 +281,39 @@ class TestNodesSaveNothing(NodeCase):
self.assertEqual(self.db.get_prompt_by_id(prompt_id)["run_count"], 1)
class TestWiredSwitchOffMarksItsRun(NodeCase):
"""Review finding: a wired switch off at run time keeps the node a run prompt."""
def wired_graph(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
return graph
def test_an_earlier_entry_of_the_key_never_stands_in(self):
graph = self.wired_graph()
key = prompt_node_key(graph, "1")
node = self.node(PromptManager)
node.encode_prompt(
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=True
)
node.encode_prompt(
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=False
)
latest, _run, _count = self.tracker.keyed_snapshot()[key]
self.assertTrue(latest.get("placeholder"))
def test_literal_off_marks_nothing(self):
node = self.node(PromptManager)
node.encode_prompt(
self.clip(),
POSITIVE,
prompt=run_graph(save=False),
unique_id="1",
save_to_library=False,
)
self.assertEqual(self.tracker.keyed_snapshot(), {})
class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase):
def test_existing_prompt_is_not_linked_from_metadata(self):
self.save(POSITIVE)
@@ -325,6 +358,27 @@ class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase):
state, _prompt = self.handler._find_prompt_from_metadata(metadata)
self.assertEqual(state, "opted_out")
def test_workflow_only_ignores_muted_nodes_and_reads_subgraphs(self):
from utils.prompt_graph import metadata_opted_out
node = {
"type": "PromptManager",
"widgets_values": [POSITIVE, "", "", "", "", "", False],
}
self.assertFalse(
metadata_opted_out({"workflow": {"nodes": [{**node, "mode": 4}]}})
)
self.assertTrue(
metadata_opted_out(
{
"workflow": {
"nodes": [],
"definitions": {"subgraphs": [{"nodes": [node]}]},
}
}
)
)
def test_unknown_setting_is_left_to_the_run(self):
self.save(POSITIVE)
graph = run_graph(save=["9", 0])
@@ -410,6 +464,13 @@ class TestScanSkipsOptedOutFiles(NodeCase):
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
self.assertEqual(self.count_rows("prompt_pairs"), 0)
def test_wired_switch_is_left_to_the_live_run(self):
graph = run_graph(save=["9", 0])
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
status = self.mixin._ingest_scanned_file(self.file, {"prompt": graph})
self.assertEqual(status, "not_saved_wired")
self.assertEqual(self.count_rows("prompts"), 0)
def test_status_has_a_scan_detail(self):
from py.api import admin
+12 -1
View File
@@ -133,6 +133,8 @@ def _workflow_node_opted_out(node: Any) -> bool:
return False
if (node.get("type") or node.get("class_type")) not in PROMPT_MANAGER_TYPES:
return False
if node.get("mode") in (2, 4):
return False # muted or bypassed: it did not run
values = node.get("widgets_values")
return isinstance(values, list) and any(value is False for value in values)
@@ -160,7 +162,16 @@ def metadata_opted_out(metadata: Any, *, unreadable: bool = False) -> bool:
nodes = list(metadata.get("text_encoder_nodes") or [])
if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list):
nodes.extend(workflow["nodes"])
return any(_workflow_node_opted_out(node) for node in nodes)
definitions = workflow.get("definitions")
subgraphs = (
definitions.get("subgraphs") if isinstance(definitions, dict) else None
)
for subgraph in subgraphs if isinstance(subgraphs, list) else []:
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
nodes.extend(inner if isinstance(inner, list) else [])
# Without the executed graph the node's role is unknown: any opted-out
# PromptManager node opts the file out (never a guessed link).
return any(_workflow_node_opted_out(node) for node in nodes[:MAX_VISITS])
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
+2 -1
View File
@@ -151,7 +151,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview);
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status');
const SAVE_OFF = 'Save to library is off: runs of this node are not added to the library.';
function runNote(saved) { return savesToLibrary(thisNode) ? saved : SAVE_OFF; }
const SAVE_LINKED = 'Save to library comes from a link: runs are added to the library only while it is on.';
function runNote(saved) { return isConnected(SAVE_WIDGET) ? SAVE_LINKED : savesToLibrary(thisNode) ? saved : SAVE_OFF; }
footer.textContent = runNote(pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.');
const saveConnectionNotice = pair ? document.createElement('p') : null;
if (saveConnectionNotice) {