Improve snapshot reliability and usability
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Canceled after 0s
Tests / test (push) Canceled after 0s

This commit is contained in:
2026-08-24 23:50:31 +02:00
parent 6648d4b9d6
commit ff59e58b33
11 changed files with 1487 additions and 579 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Test JavaScript comparison logic
run: npm test
- name: Check JavaScript syntax
run: npm run check
- name: Test storage layer
run: python -m unittest discover -s tests -p 'test_*.py' -v
- name: Compile Python sources
run: python -m compileall -q .
+42 -39
View File
@@ -5,7 +5,7 @@
<p align="center">
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
<img src="https://img.shields.io/badge/version-3.0.1-blue" alt="Version"/>
<img src="https://img.shields.io/badge/version-3.1.0-blue" alt="Version"/>
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
</p>
@@ -19,11 +19,12 @@
## Features
- **Auto-capture** — Snapshots are saved automatically as you edit, with configurable debounce
- **Quieter auto-capture** — Meaningful edits are saved after a debounce and a configurable minimum interval; layout-only changes wait for the next real checkpoint
- **Truthful save status** — The sidebar says whether the graph is saved, pending, layout-only, paused, loading, or failed
- **Custom naming** — Name your snapshots when taking them manually ("Before merge", "Working v2", etc.); double-click a snapshot's name in the sidebar to rename it later
- **Notes** — Attach a freeform note to any snapshot with the note (pencil) button; notes are searchable from the filter bar
- **Search & filter** — Quickly find snapshots by name or note with the filter bar
- **Restore or Swap** — Open a snapshot as a new workflow, or replace the current one in-place
- **Open Copy or Replace** — Open a snapshot in a separate workflow tab, or replace the current tab after a verified return point is saved
- **Workflow browser** — Browse and recover snapshots from any workflow, including renamed or deleted ones
- **Per-workflow storage** — Each workflow has its own independent snapshot history
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
@@ -31,14 +32,14 @@
- **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit
- **Change-type icons** — Timeline markers show what kind of change each snapshot represents (node add, remove, connection, parameter, move, mixed) with distinct colored icons — like Fusion 360's operation timeline
- **Timeline bar** — Optional centered floating bar on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures; tucks behind the sidebar when open
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring)
- **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back; browsing between saved snapshots skips redundant saves
- **Active & return markers** — After replacing the graph, the timeline highlights the loaded snapshot (white ring) and the return point (green dot)
- **Verified return point** — Replace/Open Copy abort if the current graph cannot be protected first
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
- **SVG graph previews** — Hover any snapshot for a tooltip preview of the workflow graph; click the eye button for a full-size modal; diff view now shows side-by-side SVG comparison with color-coded highlights (green = added, red = removed, amber = modified)
- **Diff view** — Compare any snapshot against the current workflow (one click) or two snapshots against each other (Shift+click to set base); see added/removed/modified nodes, widget value changes, and rewired connections in a single modal
- **Profile manager** — Save and load named sets of workflows as session profiles (like browser tab groups); profiles track which workflows you visited and restore the latest snapshot for each
- **Hide auto-saves** — Toggle button next to the search bar hides auto-save snapshots to reduce clutter while keeping manual, locked, and node-triggered snapshots visible
- **Pause auto-capture** — Toggle automatic capture on/off for the session without leaving the panel (Auto: On/Off button in the filter row)
- **Profile manager** — Save and load named sets of workflows as session profiles; new profiles pin exact snapshot IDs so their contents do not drift
- **Hide auto-saves** — The persistent filter hides auto history while keeping manual, locked, return-point, and node-triggered snapshots visible
- **Pause auto-capture** — Pause/resume from the panel; pausing cancels any pending timer and stays synchronized with the setting
- **Export / Import** — Download a workflow's full snapshot history as a JSON file and re-import it on another machine or server
- **Storage usage** — The sidebar footer shows total snapshot storage used on the server across all workflows
- **Retention by age** — Optionally auto-delete snapshots older than a configurable number of days (off by default), alongside the per-workflow count limit
@@ -70,7 +71,9 @@ Click the **clock icon** (<img src="https://img.shields.io/badge/-pi pi--history
### 2. Snapshots are Captured Automatically
As you edit your workflow, snapshots are saved automatically after a configurable delay (default: 3 seconds). An initial snapshot is also captured when the workflow loads.
As you edit, meaningful changes are saved after a short quiet period (default: 3 seconds), with at least 60 seconds between automatic snapshots by default. Repositioning or resizing nodes alone does not create an entry; that layout rides with the next meaningful or manual checkpoint. An initial snapshot is created only when a workflow has no history.
The status line at the top of the sidebar always describes the live graph: saved, pending, layout-only, paused, loading, or failed.
### 3. Take a Named Snapshot
@@ -80,16 +83,15 @@ Click **Take Snapshot** to manually save the current state. A prompt lets you en
Use the filter bar at the top of the panel to search snapshots by name. The clear button (**&times;**) resets the filter.
### 5. Restore or Swap
### 5. Open Copy or Replace
Each snapshot has action buttons:
| Button | Action |
|--------|--------|
| **Preview** (eye icon) | Opens a full-size SVG preview of the workflow graph |
| **Lock** | Toggles lock protection (padlock icon) |
| **Swap** | Replaces the current workflow in-place (same tab) |
| **Restore** | Opens the snapshot as a new workflow |
| **Open Copy** | Opens the snapshot in a separate workflow tab |
| **Replace** | Replaces the graph in the current tab after saving a return point |
| **More (…)** | Preview, Diff, Add/Edit Note, Lock/Unlock, and Delete |
### 6. Lock / Pin Snapshots
@@ -132,15 +134,15 @@ Additional marker styles are layered on top of the change-type icon:
| **Purple background** | Node-triggered snapshot (overrides change-type color) |
| **Yellow border** | Locked snapshot |
| **White ring (larger)** | Active — the snapshot you swapped TO |
| **Green background** | Current — your auto-saved state before the swap |
| **Green background** | Return point — the protected state from before Replace/Open Copy |
Click any marker to swap to that snapshot. Hover to see a tooltip with the snapshot name, time, and change description. The **Snapshot** button on the right takes a quick manual snapshot. The bar is centered at 80% width to clear both the sidebar icon strip and bottom-right controls, and tucks behind the sidebar panel when it's open.
Click any marker to replace the current graph with that snapshot. Hover to see a tooltip with the snapshot name, time, and change description. The **Snapshot** button on the right takes a quick manual snapshot. The bar is centered at 80% width to clear both the sidebar icon strip and bottom-right controls, and tucks behind the sidebar panel when it's open.
The sidebar list also shows the change type in the meta line below each snapshot (e.g., "5 nodes · Parameters changed").
### 9. Auto-save Before Swap
### 9. Return Point Before Replace
When you swap to an older snapshot (via the sidebar or timeline), the extension automatically captures a "Current" snapshot of your work-in-progress first. This green-marked snapshot appears on the timeline so you can click it to get back. The marker disappears once you edit the graph (since auto-capture creates a proper snapshot at that point). Browsing between existing snapshots does not create additional "Current" entries the auto-save only triggers on the first swap away from unsaved work.
Before Open Copy or Replace changes workflow navigation, the extension saves a typed **Return point** of your current graph. If that write fails, the requested action is cancelled. The green timeline marker identifies the return point; the white ring identifies the loaded snapshot.
### 10. Keyboard Shortcut
@@ -169,6 +171,7 @@ The diff modal shows:
| **Removed Nodes** | Nodes present in the base but not the target |
| **Modified Nodes** | Nodes with changed position, size, title, mode, widget values, or properties — each change shown as old (red strikethrough) → new (green) |
| **Link Changes** | Added/removed connections with node names and slot indices |
| **Group Changes** | Added, removed, renamed, moved, or restyled graph groups |
Sections are collapsible (click the header to toggle). If the two snapshots are identical, a "No differences found." message is shown. Dismiss the modal with **Escape**, the **X** button, or by clicking outside.
@@ -184,9 +187,9 @@ The SVG renderer draws nodes with their stored position, size, and colors. Links
### 14. Hide Auto-saves
Click the **Hide Auto** button next to the search bar to hide all auto-save snapshots ("Auto" and "Initial"). The button turns blue and switches to **Show Auto** when active.
Click the **Hide Auto** button next to the search bar to hide all auto-save snapshots ("Auto" and "Initial"). The choice persists when the sidebar or ComfyUI is reopened. The button turns blue and switches to **Show Auto** when active.
The filter works together with text search — both are applied simultaneously. Manual, locked, node-triggered, and "Current" snapshots are always shown.
The filter works together with text search — both are applied simultaneously. Manual, locked, node-triggered, and return-point snapshots are always shown.
### 15. Session Profiles
@@ -201,15 +204,13 @@ Save and load named sets of workflows — like browser tab groups for ComfyUI.
**Load a profile:**
1. Expand the Profiles section
2. Click **Load** on any profile — the extension fetches the latest snapshot for each workflow and loads them via `loadGraphData`
2. Click **Load** on any profile — the extension opens the exact snapshot saved for each workflow (legacy profiles fall back to the latest snapshot)
3. The profile's active workflow is loaded last so it ends up visible
4. A toast reports how many workflows were loaded and how many were skipped (missing snapshots)
**Delete a profile:** Click **X** on any profile (with confirmation).
Profiles are stored as JSON files on the server at `<extension_dir>/data/profiles/`.
> **Note:** ComfyUI's `loadGraphData` replaces the current workflow — there is no API to open new tabs. Each loaded workflow overwrites the previous one. The user ends up seeing the last loaded workflow (the active one). Previously loaded workflows may appear in ComfyUI's workflow history/tabs depending on the frontend version.
Profiles are stored as JSON files on the server at `<user_dir>/snapshot_manager/profiles/`.
## Settings
@@ -219,10 +220,12 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
|---------|------|---------|-------------|
| **Auto-capture on edit** | Toggle | `On` | Automatically save snapshots when the workflow changes |
| **Capture delay** | Slider | `3s` | Seconds to wait after the last edit before auto-capturing (130s) |
| **Minimum time between auto-snapshots** | Slider | `60s` | Prevent rapid edits from producing a wall of automatic entries (0300s) |
| **Max snapshots per workflow** | Slider | `50` | Maximum number of unlocked snapshots kept per workflow (5200). Oldest unlocked are pruned automatically; locked snapshots are never pruned |
| **Capture on workflow load** | Toggle | `On` | Save an "Initial" snapshot when a workflow is first loaded |
| **Initial snapshot for new workflows** | Toggle | `On` | Save an "Initial" snapshot only when the workflow has no history |
| **Max node-triggered snapshots** | Slider | `5` | Rolling limit for SaveSnapshot node captures per workflow (150). Node snapshots are pruned independently from auto/manual snapshots |
| **Show snapshot timeline** | Toggle | `Off` | Display a timeline bar at the bottom of the canvas with snapshot markers, active/current indicators, and a quick Snapshot button |
| **Auto-delete by age** | Slider | `0` | Delete old unlocked snapshots after this many days; 0 disables age pruning |
| **Show snapshot timeline** | Toggle | `Off` | Display a timeline bar with snapshot markers, active/return-point indicators, and a quick Snapshot button |
## Architecture
@@ -233,12 +236,12 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
**Auto/manual capture flow:**
1. **Graph edits** trigger a `graphChanged` event
2. A **debounce timer** prevents excessive writes
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
2. A **debounce and minimum interval** prevent bursty writes; pausing cancels a pending timer
3. The workflow is serialized and **structurally compared** against the last capture to avoid duplicate and layout-only auto history
4. The previous graph state is diffed against the current to **detect the change type** (node add/remove, connection, parameter, move, or mixed) — stored as a `changeType` field on the record
5. New snapshots are sent to the **server** and stored as individual JSON files under `<user_dir>/snapshot_manager/snapshots/`
6. The **sidebar panel** and **timeline bar** fetch snapshots from the server and render them with change-type icons
7. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations, and updates the graph cache so the next diff is accurate
7. **Open Copy/Replace** first verifies a return-point write, then loads graph data with a lock guard and resets the live comparison baseline
**Node-triggered capture flow:**
@@ -247,13 +250,13 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
3. The snapshot is saved with `source: "node"` and pruned against its own rolling limit (`maxNodeSnapshots`)
4. Node snapshots appear in the sidebar with a **purple left border** and **"Node" badge**
**Swap with auto-save:**
**Replace with a return point:**
1. User clicks **Swap** (sidebar or timeline marker)
2. If the current graph is unsaved work (not already a swapped snapshot), `captureSnapshot("Current")` saves it **before** the swap — browsing between existing snapshots skips this step
1. User clicks **Replace** (sidebar or timeline marker)
2. `captureSnapshot("Return point")` completes **before** the graph is changed; failure aborts the action
3. The target snapshot is loaded into the graph
4. The **timeline** updates: the swapped-to snapshot gets a white ring (active), the auto-saved snapshot gets a green dot (current)
5. Clicking the green dot swaps back; editing the graph clears both markers (the next auto-capture supersedes them)
4. The **timeline** updates: the loaded snapshot gets a white ring and the return point gets a green dot
5. Clicking the green dot replaces the graph with the protected return point
**Branching** (implemented but disabled in the current release — the code remains for a future enable):
@@ -265,8 +268,8 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
**Profiles:**
1. Session tracking records each visited workflow key with timestamps
2. **Save** creates a JSON file at `<user_dir>/snapshot_manager/profiles/<id>.json` with the workflow list and active workflow
3. **Load** fetches the latest snapshot for each workflow in the profile and calls `loadGraphData`
2. **Save** records the exact snapshot ID for each workflow plus the active workflow
3. **Load** opens those pinned snapshots in workflow tabs; legacy profiles without IDs use the latest available snapshot
**Storage:** Snapshots are stored as JSON files on the server in ComfyUI's user directory at `<user_dir>/snapshot_manager/snapshots/<workflow_key>/<id>.json`. Profiles are stored at `<user_dir>/snapshot_manager/profiles/<id>.json`. Data from older versions (kept under the extension's own `data/` folder) is migrated here automatically on first load. Both persist across browser sessions, ComfyUI restarts, and are accessible from any browser connecting to the same server.
@@ -279,16 +282,16 @@ On the server's filesystem in ComfyUI's user directory under `snapshot_manager/s
On first load after upgrading, the extension automatically migrates all snapshots from your browser's IndexedDB to the server. Once migration succeeds, the old IndexedDB database is deleted. If migration fails (e.g., server unreachable), your old data is preserved and migration will retry on the next load.
**Will this slow down ComfyUI?**
No. Snapshots are captured asynchronously after a debounce delay. The hash check prevents redundant writes.
Snapshots are captured asynchronously after a debounce and minimum interval. Structural comparison prevents duplicate and layout-only automatic writes.
**What happens if I switch workflows?**
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list. You can also browse snapshots from other workflows using the workflow picker.
Each workflow has its own snapshot history. If an edit is still waiting on its debounce when you switch, that graph is frozen and queued under the workflow you left, then the sidebar follows the new workflow. You can browse other histories with the workflow picker.
**I renamed/deleted a workflow — are my snapshots gone?**
No. Snapshots are keyed by the workflow name at capture time. Use the workflow picker to find and restore them under the old name.
**What are profiles?**
Profiles save a list of workflows you've visited in a session. Loading a profile restores the latest snapshot for each workflow. They're useful for switching between project contexts — like browser tab groups.
Profiles save exact snapshots for workflows visited in a session. Loading a profile opens those pinned states, so a profile does not change merely because newer autosaves were created later.
**Can I use this with ComfyUI Manager?**
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.
+13 -5
View File
@@ -5,9 +5,17 @@ Automatically snapshots workflow state as you edit, with a sidebar panel
to browse and restore any previous version. Stored in server-side JSON files.
"""
from . import snapshot_routes
from .snapshot_node import SaveSnapshot
WEB_DIRECTORY = "./js"
NODE_CLASS_MAPPINGS = {"SaveSnapshot": SaveSnapshot}
NODE_DISPLAY_NAME_MAPPINGS = {"SaveSnapshot": "Save Snapshot"}
# ComfyUI loads custom nodes as packages. Test runners may import this file as
# a standalone module while discovering tests; avoid trying to register routes
# until package-relative imports are available.
if __package__:
from . import snapshot_routes
from .snapshot_node import SaveSnapshot
NODE_CLASS_MAPPINGS = {"SaveSnapshot": SaveSnapshot}
NODE_DISPLAY_NAME_MAPPINGS = {"SaveSnapshot": "Save Snapshot"}
else:
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+341
View File
@@ -0,0 +1,341 @@
/**
* Pure graph comparison and snapshot-diff helpers.
*
* This module intentionally has no ComfyUI or DOM dependencies so the capture
* policy and the UI use the same comparison rules, and the rules can be tested
* without booting the frontend.
*/
export function quickHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash;
}
export function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== typeof b || a == null || b == null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (typeof a === "object") {
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
if (!deepEqual(aKeys, bKeys)) return false;
for (const key of aKeys) {
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
export function validateSnapshotData(graphData) {
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
}
const COSMETIC_NODE_KEYS = ["pos", "size", "flags", "order", "color", "bgcolor", "boxcolor", "shape"];
const PARAM_NODE_KEYS = ["type", "title", "mode", "widgets_values", "properties"];
const CONNECTION_GRAPH_KEYS = ["links", "floatingLinks", "reroutes"];
const OTHER_MEANINGFUL_GRAPH_KEYS = ["definitions", "subgraphs", "config"];
function changedOnAnyKey(a, b, keys) {
return keys.some((key) => !deepEqual(a?.[key], b?.[key]));
}
function meaningfulGroupState(groups) {
return (groups || []).filter(Boolean).map((group, index) => ({
id: group.id ?? index,
title: group.title ?? group.name ?? "",
})).sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
/**
* Classify the user-visible change between two serialized ComfyUI graphs.
* Composite widget values are compared structurally, never by object identity.
*/
export function detectChangeType(prevGraph, currGraph) {
if (!prevGraph) return "initial";
const prevNodes = prevGraph.nodes || [];
const currNodes = currGraph.nodes || [];
const prevMap = new Map(prevNodes.filter(Boolean).map((node) => [node.id, node]));
const currMap = new Map(currNodes.filter(Boolean).map((node) => [node.id, node]));
let added = false;
let removed = false;
for (const id of currMap.keys()) if (!prevMap.has(id)) { added = true; break; }
for (const id of prevMap.keys()) if (!currMap.has(id)) { removed = true; break; }
// Node creation/removal is the most useful headline, even though ComfyUI
// may also add/remove associated links in the same transaction.
if (added || removed) {
if (added && removed) return "mixed";
return added ? "node_add" : "node_remove";
}
let connectionChanged = changedOnAnyKey(prevGraph, currGraph, CONNECTION_GRAPH_KEYS);
let paramChanged = changedOnAnyKey(prevGraph, currGraph, OTHER_MEANINGFUL_GRAPH_KEYS)
|| !deepEqual(
meaningfulGroupState(prevGraph.groups),
meaningfulGroupState(currGraph.groups),
);
let cosmeticChanged = !deepEqual(prevGraph.groups || [], currGraph.groups || []);
for (const [id, currNode] of currMap) {
const prevNode = prevMap.get(id);
if (!prevNode) continue;
if (!paramChanged && changedOnAnyKey(prevNode, currNode, PARAM_NODE_KEYS)) paramChanged = true;
if (!cosmeticChanged && changedOnAnyKey(prevNode, currNode, COSMETIC_NODE_KEYS)) cosmeticChanged = true;
if (connectionChanged && paramChanged && cosmeticChanged) break;
}
if (connectionChanged && paramChanged) return "mixed";
if (connectionChanged) return "connection";
if (paramChanged) return "param";
if (cosmeticChanged) return "cosmetic";
// Preserve unfamiliar/extension-defined graph changes instead of dropping
// them as layout noise. Exact duplicates are caught by content hash first.
return deepEqual(prevGraph, currGraph) ? "unchanged" : "unknown";
}
export function isMeaningfulChangeType(changeType) {
return changeType !== "unchanged" && changeType !== "cosmetic";
}
export function buildNodeLookup(...graphs) {
const map = new Map();
for (const graph of graphs) {
if (!graph || !Array.isArray(graph.nodes)) continue;
for (const node of graph.nodes) {
if (!node || map.has(node.id)) continue;
map.set(node.id, {
type: node.type || "?",
title: node.title || node.type || `#${node.id}`,
});
}
}
return map;
}
function widgetNameFor(widgetNames, node, index) {
if (!widgetNames || !node) return null;
const names = widgetNames.get(node.id);
return (names && names[index]) || null;
}
function displayValue(value) {
if (value == null) return "";
if (typeof value === "object") {
try { return JSON.stringify(value); } catch { return String(value); }
}
return String(value);
}
export function computeDetailedDiff(baseGraph, targetGraph, widgetMaps = null) {
const empty = {
addedNodes: [], removedNodes: [], modifiedNodes: [],
addedLinks: [], removedLinks: [], groupChanges: [],
summary: {
nodesAdded: 0, nodesRemoved: 0, nodesModified: 0,
linksAdded: 0, linksRemoved: 0, groupsChanged: 0,
},
};
if (!baseGraph && !targetGraph) return empty;
const baseNodes = baseGraph?.nodes || [];
const targetNodes = targetGraph?.nodes || [];
const baseMap = new Map(baseNodes.filter(Boolean).map((node) => [node.id, node]));
const targetMap = new Map(targetNodes.filter(Boolean).map((node) => [node.id, node]));
const addedNodes = [];
const removedNodes = [];
const modifiedNodes = [];
for (const [id, node] of baseMap) {
if (!targetMap.has(id)) {
removedNodes.push({ id, type: node.type || "?", title: node.title || node.type || `#${id}` });
}
}
for (const [id, targetNode] of targetMap) {
const baseNode = baseMap.get(id);
if (!baseNode) {
addedNodes.push({ id, type: targetNode.type || "?", title: targetNode.title || targetNode.type || `#${id}` });
continue;
}
const changes = {};
if (!deepEqual(baseNode.pos, targetNode.pos)) changes.position = { from: baseNode.pos, to: targetNode.pos };
if (!deepEqual(baseNode.size, targetNode.size)) changes.size = { from: baseNode.size, to: targetNode.size };
if ((baseNode.title || "") !== (targetNode.title || "")) {
changes.title = { from: baseNode.title || "", to: targetNode.title || "" };
}
if ((baseNode.mode || 0) !== (targetNode.mode || 0)) {
changes.mode = { from: baseNode.mode, to: targetNode.mode };
}
const baseWidgets = Array.isArray(baseNode.widgets_values) ? baseNode.widgets_values : [];
const targetWidgets = Array.isArray(targetNode.widgets_values) ? targetNode.widgets_values : [];
if (!deepEqual(baseNode.widgets_values, targetNode.widgets_values)) {
const widgetDiffs = [];
const length = Math.max(baseWidgets.length, targetWidgets.length);
for (let index = 0; index < length; index++) {
const from = baseWidgets[index];
const to = targetWidgets[index];
if (!deepEqual(from, to)) {
widgetDiffs.push({
index,
name: widgetNameFor(widgetMaps, targetNode, index),
from: displayValue(from),
to: displayValue(to),
});
}
}
if (widgetDiffs.length) changes.widgetValues = widgetDiffs;
}
const baseProps = baseNode.properties || {};
const targetProps = targetNode.properties || {};
const propDiffs = [];
for (const key of new Set([...Object.keys(baseProps), ...Object.keys(targetProps)])) {
if (!deepEqual(baseProps[key], targetProps[key])) {
propDiffs.push({ key, from: displayValue(baseProps[key]), to: displayValue(targetProps[key]) });
}
}
if (propDiffs.length) changes.properties = propDiffs;
if (Object.keys(changes).length) {
modifiedNodes.push({
id,
type: targetNode.type || "?",
title: targetNode.title || targetNode.type || `#${id}`,
changes,
});
}
}
const baseLinks = (baseGraph?.links || []).filter(Boolean);
const targetLinks = (targetGraph?.links || []).filter(Boolean);
const baseLinkMap = new Map(baseLinks.map((link) => [link[0], link]));
const targetLinkMap = new Map(targetLinks.map((link) => [link[0], link]));
const addedLinks = [];
const removedLinks = [];
const toLink = (link) => ({
linkId: link[0], srcNodeId: link[1], srcSlot: link[2],
destNodeId: link[3], destSlot: link[4], type: link[5],
});
for (const [linkId, link] of baseLinkMap) {
const targetLink = targetLinkMap.get(linkId);
if (!targetLink || !deepEqual(link, targetLink)) removedLinks.push(toLink(link));
}
for (const [linkId, link] of targetLinkMap) {
const baseLink = baseLinkMap.get(linkId);
if (!baseLink || !deepEqual(link, baseLink)) addedLinks.push(toLink(link));
}
const baseGroups = new Map((baseGraph?.groups || []).filter(Boolean).map(
(group, index) => [group.id ?? `index:${index}`, group],
));
const targetGroups = new Map((targetGraph?.groups || []).filter(Boolean).map(
(group, index) => [group.id ?? `index:${index}`, group],
));
const groupChanges = [];
for (const [id, group] of baseGroups) {
const targetGroup = targetGroups.get(id);
if (!targetGroup) {
groupChanges.push({ id, kind: "removed", title: group.title || group.name || `Group ${id}` });
} else if (!deepEqual(group, targetGroup)) {
const oldTitle = group.title || group.name || `Group ${id}`;
const newTitle = targetGroup.title || targetGroup.name || `Group ${id}`;
groupChanges.push({
id,
kind: "modified",
title: newTitle,
detail: oldTitle !== newTitle
? `Title: ${oldTitle}${newTitle}`
: "Layout or group settings changed",
});
}
}
for (const [id, group] of targetGroups) {
if (!baseGroups.has(id)) {
groupChanges.push({ id, kind: "added", title: group.title || group.name || `Group ${id}` });
}
}
return {
addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks, groupChanges,
summary: {
nodesAdded: addedNodes.length,
nodesRemoved: removedNodes.length,
nodesModified: modifiedNodes.length,
linksAdded: addedLinks.length,
linksRemoved: removedLinks.length,
groupsChanged: groupChanges.length,
},
};
}
export function computeCaptureMetaDiff(prevGraph, currGraph, widgetMaps = null) {
if (!prevGraph || !currGraph) return null;
const diff = computeDetailedDiff(prevGraph, currGraph, widgetMaps);
const result = {};
if (diff.addedNodes.length) result.added = diff.addedNodes.map((node) => node.title);
if (diff.removedNodes.length) result.removed = diff.removedNodes.map((node) => node.title);
const paramChanged = diff.modifiedNodes.filter((node) =>
node.changes.widgetValues || node.changes.properties || node.changes.title || node.changes.mode
);
if (paramChanged.length) {
result.params = paramChanged.map((node) => {
const names = [];
if (Array.isArray(node.changes.widgetValues)) {
for (const value of node.changes.widgetValues) if (value.name) names.push(value.name);
}
if (Array.isArray(node.changes.properties)) {
for (const value of node.changes.properties) if (value.key) names.push(value.key);
}
if (node.changes.title) names.push("title");
if (node.changes.mode) names.push("mode");
const uniqueNames = [...new Set(names)];
if (uniqueNames.length) return `${node.title} (${uniqueNames.join(", ")})`;
const widgetCount = Array.isArray(node.changes.widgetValues) ? node.changes.widgetValues.length : 0;
const count = widgetCount + (node.changes.properties?.length ?? 0);
return count ? `${node.title} (${count} value${count === 1 ? "" : "s"})` : node.title;
});
}
if (diff.addedLinks.length || diff.removedLinks.length) {
result.links = { added: diff.addedLinks.length, removed: diff.removedLinks.length };
}
if (diff.groupChanges.length) {
result.groups = diff.groupChanges.map((group) => `${group.kind}: ${group.title}`);
}
return Object.keys(result).length ? result : null;
}
export function formatCaptureDiffLines(captureDiff) {
if (!captureDiff) return [];
const lines = [];
if (captureDiff.added?.length) lines.push(`+ ${captureDiff.added.join(", ")}`);
if (captureDiff.removed?.length) lines.push(` ${captureDiff.removed.join(", ")}`);
if (captureDiff.params?.length) lines.push(`~ ${captureDiff.params.join(", ")}`);
if (captureDiff.links) {
const parts = [];
if (captureDiff.links.added) parts.push(`+${captureDiff.links.added} link${captureDiff.links.added === 1 ? "" : "s"}`);
if (captureDiff.links.removed) parts.push(`${captureDiff.links.removed} link${captureDiff.links.removed === 1 ? "" : "s"}`);
if (parts.length) lines.push(parts.join(", "));
}
if (captureDiff.groups?.length) lines.push(`${captureDiff.groups.join(", ")}`);
return lines;
}
+715 -526
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"name": "comfyui-snapshot-manager-tests",
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs",
"check": "node --check js/snapshot_manager.js && node --check js/snapshot_diff.js"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-snapshot-manager"
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
version = "3.0.1"
version = "3.1.0"
license = {text = "MIT"}
[project.urls]
+18 -7
View File
@@ -176,10 +176,20 @@ async def prune_snapshots(request):
max_age_days = data.get("maxAgeDays")
if not workflow_key or max_snapshots is None:
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
max_snapshots = int(max_snapshots)
if max_snapshots < 0 or max_snapshots > 10000:
raise ValueError("maxSnapshots must be between 0 and 10000")
if source not in (None, "node", "regular"):
raise ValueError("Invalid prune source")
if protected_ids is not None and not isinstance(protected_ids, list):
raise ValueError("protectedIds must be an array")
max_age_days = int(max_age_days) if max_age_days else None
if max_age_days is not None and (max_age_days < 0 or max_age_days > 36500):
raise ValueError("maxAgeDays must be between 0 and 36500")
deleted = storage.prune(
workflow_key, int(max_snapshots),
workflow_key, max_snapshots,
source=source, protected_ids=protected_ids,
max_age_days=int(max_age_days) if max_age_days else None,
max_age_days=max_age_days,
)
return web.json_response({"deleted": deleted})
except ValueError as e:
@@ -200,12 +210,13 @@ async def migrate_snapshots(request):
return web.json_response({"error": "Missing records array"}, status=400)
if len(records) > _MAX_MIGRATE_RECORDS:
return web.json_response({"error": "Too many records"}, status=413)
imported = 0
# Validate the entire batch before writing so a bad record cannot leave
# a surprising half-imported history.
for record in records:
if "id" in record and "workflowKey" in record:
storage.put(record)
imported += 1
return web.json_response({"imported": imported})
storage.validate_record(record)
for record in records:
storage.put(record)
return web.json_response({"imported": len(records)})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception:
+106 -1
View File
@@ -11,6 +11,7 @@ operations. Only get_full_record() reads a file from disk after warm-up.
"""
import json
import math
import os
import shutil
import tempfile
@@ -37,6 +38,13 @@ _DATA_DIR = os.path.join(_USER_SM_DIR, "snapshots")
_cache = {}
_cache_warmed = set() # workflow keys already loaded from disk
_ALLOWED_SOURCES = {"auto", "manual", "initial", "node", "restore_guard"}
_ALLOWED_META_FIELDS = {"label", "notes", "locked", "parentId"}
_MAX_WORKFLOW_KEY_LENGTH = 4096
_MAX_ID_LENGTH = 255
_MAX_LABEL_LENGTH = 500
_MAX_NOTES_LENGTH = 50000
def _extract_meta(record):
"""Return a lightweight copy of *record* without graphData or thumbnail.
@@ -76,6 +84,8 @@ def _ensure_cached(workflow_key):
def _workflow_dir(workflow_key):
if not workflow_key or not isinstance(workflow_key, str):
raise ValueError(f"Invalid workflow key: {workflow_key!r}")
if len(workflow_key) > _MAX_WORKFLOW_KEY_LENGTH:
raise ValueError("Workflow key is too long")
encoded = urllib.parse.quote(workflow_key, safe="")
path = os.path.normpath(os.path.join(_DATA_DIR, encoded))
# Defense in depth: urllib.parse.quote() leaves "." and ".." unescaped, so a
@@ -88,10 +98,79 @@ def _workflow_dir(workflow_key):
def _validate_id(snapshot_id):
if not snapshot_id or "/" in snapshot_id or "\\" in snapshot_id or ".." in snapshot_id:
if (
not isinstance(snapshot_id, str)
or not snapshot_id
or len(snapshot_id) > _MAX_ID_LENGTH
or "/" in snapshot_id
or "\\" in snapshot_id
or ".." in snapshot_id
):
raise ValueError(f"Invalid snapshot id: {snapshot_id!r}")
def _validate_meta_fields(fields):
if not isinstance(fields, dict):
raise ValueError("Metadata fields must be an object")
unknown = set(fields) - _ALLOWED_META_FIELDS
if unknown:
raise ValueError(f"Unsupported metadata fields: {', '.join(sorted(unknown))}")
if "label" in fields:
label = fields["label"]
if not isinstance(label, str) or not label.strip() or len(label) > _MAX_LABEL_LENGTH:
raise ValueError("Invalid snapshot label")
if "notes" in fields:
notes = fields["notes"]
if notes is not None and (not isinstance(notes, str) or len(notes) > _MAX_NOTES_LENGTH):
raise ValueError("Invalid snapshot notes")
if "locked" in fields and not isinstance(fields["locked"], bool):
raise ValueError("Invalid locked value")
if "parentId" in fields and fields["parentId"] is not None:
_validate_id(fields["parentId"])
def validate_record(record):
"""Validate the persisted snapshot envelope and graph container."""
if not isinstance(record, dict):
raise ValueError("Snapshot record must be an object")
snapshot_id = record.get("id")
workflow_key = record.get("workflowKey")
_validate_id(snapshot_id)
_workflow_dir(workflow_key)
timestamp = record.get("timestamp")
if (
not isinstance(timestamp, (int, float))
or isinstance(timestamp, bool)
or not math.isfinite(timestamp)
or timestamp < 0
):
raise ValueError("Invalid snapshot timestamp")
label = record.get("label")
if not isinstance(label, str) or not label.strip() or len(label) > _MAX_LABEL_LENGTH:
raise ValueError("Invalid snapshot label")
graph_data = record.get("graphData")
if not isinstance(graph_data, dict) or not isinstance(graph_data.get("nodes"), list):
raise ValueError("Invalid snapshot graphData")
source = record.get("source")
if source is not None and source not in _ALLOWED_SOURCES:
raise ValueError(f"Invalid snapshot source: {source!r}")
if "locked" in record and not isinstance(record["locked"], bool):
raise ValueError("Invalid locked value")
if record.get("parentId") is not None:
_validate_id(record["parentId"])
if "notes" in record and (
not isinstance(record["notes"], str)
or len(record["notes"]) > _MAX_NOTES_LENGTH
):
raise ValueError("Invalid snapshot notes")
return record
def _atomic_write_json(path, obj):
"""Write *obj* as JSON to *path* atomically (temp file + os.replace).
@@ -116,6 +195,7 @@ def _atomic_write_json(path, obj):
def put(record):
"""Write one snapshot record to disk and update the cache."""
validate_record(record)
snapshot_id = record["id"]
workflow_key = record["workflowKey"]
_validate_id(snapshot_id)
@@ -158,6 +238,7 @@ def update_meta(workflow_key, snapshot_id, fields):
Returns True on success, False if the file does not exist.
"""
_validate_id(snapshot_id)
_validate_meta_fields(fields)
path = os.path.join(_workflow_dir(workflow_key), f"{snapshot_id}.json")
if not os.path.isfile(path):
return False
@@ -404,8 +485,32 @@ def _invalidate_profile_cache():
def profile_put(profile):
"""Create or update a profile. profile must have 'id'."""
if not isinstance(profile, dict):
raise ValueError("Profile must be an object")
pid = profile["id"]
_validate_id(pid)
name = profile.get("name")
if not isinstance(name, str) or not name.strip() or len(name) > _MAX_LABEL_LENGTH:
raise ValueError("Invalid profile name")
timestamp = profile.get("timestamp")
if (
not isinstance(timestamp, (int, float))
or isinstance(timestamp, bool)
or not math.isfinite(timestamp)
or timestamp < 0
):
raise ValueError("Invalid profile timestamp")
workflows = profile.get("workflows")
if not isinstance(workflows, list) or len(workflows) > 500:
raise ValueError("Invalid profile workflows")
for workflow in workflows:
if not isinstance(workflow, dict):
raise ValueError("Invalid profile workflow")
_workflow_dir(workflow.get("workflowKey"))
if workflow.get("snapshotId") is not None:
_validate_id(workflow["snapshotId"])
if profile.get("activeWorkflowKey") is not None:
_workflow_dir(profile["activeWorkflowKey"])
_ensure_profiles_dir()
path = os.path.join(_PROFILES_DIR, f"{pid}.json")
_atomic_write_json(path, profile)
+88
View File
@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
computeDetailedDiff,
deepEqual,
detectChangeType,
isMeaningfulChangeType,
} from "../js/snapshot_diff.js";
function graph(overrides = {}) {
return {
nodes: [{
id: 1,
type: "Example",
pos: [10, 20],
size: [200, 100],
flags: {},
mode: 0,
widgets_values: [{ enabled: true, values: [1, 2] }],
properties: { precision: { value: 16 } },
}],
links: [[1, 1, 0, 2, 0, "IMAGE"]],
...overrides,
};
}
test("deepEqual compares composite widget values structurally", () => {
assert.equal(deepEqual({ a: [1, { b: true }] }, { a: [1, { b: true }] }), true);
assert.equal(deepEqual({ a: [1, { b: true }] }, { a: [1, { b: false }] }), false);
});
test("equal composite widget content is not a parameter change", () => {
const before = graph();
const after = structuredClone(before);
after.nodes[0].pos = [40, 60];
assert.equal(detectChangeType(before, after), "cosmetic");
assert.equal(isMeaningfulChangeType(detectChangeType(before, after)), false);
});
test("nested widget content changes are parameter changes", () => {
const before = graph();
const after = structuredClone(before);
after.nodes[0].widgets_values[0].values[1] = 3;
assert.equal(detectChangeType(before, after), "param");
});
test("properties changed during a move remain meaningful", () => {
const before = graph();
const after = structuredClone(before);
after.nodes[0].pos = [100, 200];
after.nodes[0].properties.precision.value = 32;
assert.equal(detectChangeType(before, after), "param");
});
test("a rewired link with the same id is represented as remove plus add", () => {
const before = graph();
const after = structuredClone(before);
after.links[0][3] = 3;
assert.equal(detectChangeType(before, after), "connection");
const diff = computeDetailedDiff(before, after);
assert.equal(diff.removedLinks.length, 1);
assert.equal(diff.addedLinks.length, 1);
assert.equal(diff.removedLinks[0].destNodeId, 2);
assert.equal(diff.addedLinks[0].destNodeId, 3);
});
test("node additions take precedence over their associated links", () => {
const before = graph({ nodes: [], links: [] });
const after = graph();
assert.equal(detectChangeType(before, after), "node_add");
});
test("group movement is cosmetic but group naming is meaningful", () => {
const before = graph({
groups: [{ id: 1, title: "Inputs", bounding: [0, 0, 200, 200], color: "#333" }],
});
const moved = structuredClone(before);
moved.groups[0].bounding = [50, 50, 200, 200];
assert.equal(detectChangeType(before, moved), "cosmetic");
const renamed = structuredClone(before);
renamed.groups[0].title = "Sources";
assert.equal(detectChangeType(before, renamed), "param");
const diff = computeDetailedDiff(before, renamed);
assert.equal(diff.summary.groupsChanged, 1);
assert.match(diff.groupChanges[0].detail, /Inputs.*Sources/);
});
+126
View File
@@ -0,0 +1,126 @@
import json
import tempfile
import unittest
from pathlib import Path
import snapshot_storage as storage
def make_record(snapshot_id, timestamp=1, **overrides):
record = {
"id": snapshot_id,
"workflowKey": "workflow/example.json",
"timestamp": timestamp,
"label": "Auto",
"source": "auto",
"locked": False,
"graphData": {"nodes": [{"id": 1, "type": "Test"}], "links": []},
}
record.update(overrides)
return record
class SnapshotStorageTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.old_data_dir = storage._DATA_DIR
self.old_profiles_dir = storage._PROFILES_DIR
storage._DATA_DIR = self.tempdir.name
storage._PROFILES_DIR = str(Path(self.tempdir.name) / "profiles")
storage._cache.clear()
storage._cache_warmed.clear()
storage._profile_cache = None
def tearDown(self):
storage._cache.clear()
storage._cache_warmed.clear()
storage._profile_cache = None
storage._DATA_DIR = self.old_data_dir
storage._PROFILES_DIR = self.old_profiles_dir
self.tempdir.cleanup()
def test_put_lists_metadata_and_round_trips_graph(self):
record = make_record("snap-1")
storage.put(record)
listed = storage.get_all_for_workflow(record["workflowKey"])
self.assertEqual([entry["id"] for entry in listed], ["snap-1"])
self.assertNotIn("graphData", listed[0])
self.assertEqual(storage.get_full_record(record["workflowKey"], "snap-1"), record)
snapshot_files = [path for path in Path(self.tempdir.name).rglob("*.json") if path.is_file()]
self.assertEqual(len(snapshot_files), 1)
json.loads(snapshot_files[0].read_text(encoding="utf-8"))
def test_rejects_invalid_record_and_path_components(self):
with self.assertRaises(ValueError):
storage.put(make_record("../escape"))
with self.assertRaises(ValueError):
storage.put(make_record("snap-1", graphData={"links": []}))
with self.assertRaises(ValueError):
storage.put(make_record("snap-1", source="mystery"))
with self.assertRaises(ValueError):
storage.get_full_record("workflow", 123)
def test_metadata_allowlist_cannot_replace_graph(self):
record = make_record("snap-1")
storage.put(record)
with self.assertRaises(ValueError):
storage.update_meta(
record["workflowKey"],
record["id"],
{"graphData": {"nodes": []}},
)
full = storage.get_full_record(record["workflowKey"], record["id"])
self.assertEqual(full["graphData"], record["graphData"])
self.assertTrue(storage.update_meta(
record["workflowKey"], record["id"], {"notes": "keeper", "locked": True}
))
updated = storage.get_full_record(record["workflowKey"], record["id"])
self.assertEqual(updated["notes"], "keeper")
self.assertTrue(updated["locked"])
def test_prune_preserves_locked_and_keeps_newest_unlocked(self):
key = "workflow/example.json"
storage.put(make_record("old", timestamp=1))
storage.put(make_record("locked", timestamp=2, locked=True))
storage.put(make_record("new", timestamp=3))
self.assertEqual(storage.prune(key, 1, source="regular"), 1)
remaining = {entry["id"] for entry in storage.get_all_for_workflow(key)}
self.assertEqual(remaining, {"locked", "new"})
def test_delete_all_keeps_locked_snapshots(self):
key = "workflow/example.json"
storage.put(make_record("unlocked", timestamp=1))
storage.put(make_record("locked", timestamp=2, locked=True))
self.assertEqual(storage.delete_all_for_workflow(key), {"lockedCount": 1})
self.assertEqual(
[entry["id"] for entry in storage.get_all_for_workflow(key)],
["locked"],
)
def test_profile_accepts_exact_snapshot_refs_and_rejects_bad_shape(self):
profile = {
"id": "profile-1",
"name": "Editing",
"timestamp": 10,
"activeWorkflowKey": "workflow/example.json",
"workflows": [{
"workflowKey": "workflow/example.json",
"displayName": "Example",
"snapshotId": "snap-1",
}],
}
storage.profile_put(profile)
self.assertEqual(storage.profile_get("profile-1"), profile)
with self.assertRaises(ValueError):
storage.profile_put({"id": "bad", "name": "Bad", "timestamp": 1, "workflows": "nope"})
if __name__ == "__main__":
unittest.main()