From 49a6a5a2a863bf0e27dcf1488113338c8dba281e Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 19:24:43 -0700 Subject: [PATCH] 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. --- prompt_manager.py | 2 +- prompt_manager_base.py | 15 ++++++--- prompt_manager_pair.py | 2 +- prompt_manager_text.py | 2 +- py/api/admin.py | 14 ++++++-- tests/test_save_to_library.py | 61 +++++++++++++++++++++++++++++++++++ utils/prompt_graph.py | 13 +++++++- web/js/focused-node.js | 3 +- 8 files changed, 100 insertions(+), 12 deletions(-) diff --git a/prompt_manager.py b/prompt_manager.py index 150e5714..49f099ce 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -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]}...") diff --git a/prompt_manager_base.py b/prompt_manager_base.py index e8b0448b..dae39d70 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -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). diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index afb16d2f..efe84efa 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -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, diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 2590d4c2..eabcc657 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -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]}...") diff --git a/py/api/admin.py b/py/api/admin.py index 884770d8..36a61988 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -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) diff --git a/tests/test_save_to_library.py b/tests/test_save_to_library.py index 715a6225..3d8bee47 100644 --- a/tests/test_save_to_library.py +++ b/tests/test_save_to_library.py @@ -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 diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index 2b487d40..b21021af 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -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]: diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 15b049bf..4960278f 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -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) {