From ff59e58b335866ebc203ad4c506633531f091709 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 24 Aug 2026 23:50:31 +0200 Subject: [PATCH] Improve snapshot reliability and usability --- .github/workflows/test.yml | 28 + README.md | 81 ++- __init__.py | 18 +- js/snapshot_diff.js | 341 +++++++++ js/snapshot_manager.js | 1241 ++++++++++++++++++-------------- package.json | 9 + pyproject.toml | 2 +- snapshot_routes.py | 25 +- snapshot_storage.py | 107 ++- tests/snapshot_diff.test.mjs | 88 +++ tests/test_snapshot_storage.py | 126 ++++ 11 files changed, 1487 insertions(+), 579 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 js/snapshot_diff.js create mode 100644 package.json create mode 100644 tests/snapshot_diff.test.mjs create mode 100644 tests/test_snapshot_storage.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..62f5c54 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 . diff --git a/README.md b/README.md index 4763965..a739e46 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

ComfyUI Registry MIT License - Version + Version ComfyUI Extension

@@ -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** (/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 `/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 (1–30s) | +| **Minimum time between auto-snapshots** | Slider | `60s` | Prevent rapid edits from producing a wall of automatic entries (0–300s) | | **Max snapshots per workflow** | Slider | `50` | Maximum number of unlocked snapshots kept per workflow (5–200). 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 (1–50). 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 `/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 `/snapshot_manager/profiles/.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 `/snapshot_manager/snapshots//.json`. Profiles are stored at `/snapshot_manager/profiles/.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/`. diff --git a/__init__.py b/__init__.py index b416767..e7d5067 100644 --- a/__init__.py +++ b/__init__.py @@ -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 = {} diff --git a/js/snapshot_diff.js b/js/snapshot_diff.js new file mode 100644 index 0000000..4f5198c --- /dev/null +++ b/js/snapshot_diff.js @@ -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; +} diff --git a/js/snapshot_manager.js b/js/snapshot_manager.js index 16a6e23..1faeed3 100644 --- a/js/snapshot_manager.js +++ b/js/snapshot_manager.js @@ -8,6 +8,17 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +import { + buildNodeLookup, + computeCaptureMetaDiff, + computeDetailedDiff, + deepEqual, + detectChangeType, + formatCaptureDiffLines, + isMeaningfulChangeType, + quickHash, + validateSnapshotData, +} from "./snapshot_diff.js"; const EXTENSION_NAME = "ComfyUI.SnapshotManager"; const RESTORE_GUARD_MS = 500; @@ -24,6 +35,7 @@ const OLD_STORE_NAME = "snapshots"; let maxSnapshots = 50; let debounceMs = 3000; +let minAutoIntervalMs = 60000; let autoCaptureEnabled = true; let captureOnLoad = true; let maxNodeSnapshots = 5; @@ -44,13 +56,17 @@ let viewingWorkflowKey = null; // null = follow active workflow; string = overri let pickerDirty = true; // forces workflow picker to re-fetch on next expand let timelineEl = null; // root DOM element for timeline bar let timelineRefresh = null; // callback to re-render timeline -let activeSnapshotId = null; // ID of the snapshot currently loaded via swap -let currentSnapshotId = null; // ID of the auto-saved "Current" snapshot before a swap let diffBaseSnapshot = null; // snapshot record selected as diff base (shift+click) const svgCache = new Map(); // "snapshotId:WxH" -> SVGElement template let svgClipCounter = 0; // unique prefix for SVG clipPath IDs let sidebarTooltipEl = null; // tooltip element for sidebar hover previews const lastCapturedIdMap = new Map(); // workflowKey -> id of most recent capture (for parentId chaining) +const lastAutoCaptureAtMap = new Map(); // workflowKey -> timestamp of last appended auto snapshot +const workflowUiStateMap = new Map(); // workflowKey -> live saved/dirty/navigation state +const snapshotListErrorMap = new Map(); // workflowKey -> last list error +let sidebarStatusRefresh = null; +let snapshotLoadMode = null; // null | "replace" | "open-copy" +let captureQueue = Promise.resolve(); const activeBranchSelections = new Map(); // forkPointId -> selected child index const workflowBranchOverrides = new Map(); // workflowKey -> true|false try { @@ -60,6 +76,139 @@ try { let timelineExpanded = localStorage.getItem("snapshotManager_timelineExpanded") === "true"; const sessionWorkflows = new Map(); // workflowKey -> { firstSeen, lastSeen } +function getWorkflowUiState(workflowKey = getWorkflowKey()) { + if (!workflowUiStateMap.has(workflowKey)) { + workflowUiStateMap.set(workflowKey, { + phase: autoCaptureEnabled ? "untracked" : "paused", + savedAt: null, + activeRef: null, + returnRef: null, + detail: "Current graph has not been checkpointed this session", + }); + } + return workflowUiStateMap.get(workflowKey); +} + +function updateWorkflowUiState(workflowKey, fields) { + Object.assign(getWorkflowUiState(workflowKey), fields); + if (sidebarStatusRefresh) sidebarStatusRefresh(); +} + +function recordRef(record) { + return record ? { workflowKey: record.workflowKey, id: record.id } : null; +} + +function refMatchesRecord(ref, record) { + return !!ref && ref.id === record.id && ref.workflowKey === record.workflowKey; +} + +function sourceForRecord(record) { + if (record.source) return record.source; + if (record.label === "Auto") return "auto"; + if (record.label === "Initial") return "initial"; + if (record.label === "Current") return "restore_guard"; + return "manual"; +} + +function setAutoCaptureEnabled(value) { + const next = Boolean(value); + const changed = autoCaptureEnabled !== next; + autoCaptureEnabled = next; + + const workflowKey = getWorkflowKey(); + const state = getWorkflowUiState(workflowKey); + const hasUnsavedChanges = state.phase === "dirty" + || state.phase === "layout" + || state.detail?.startsWith("Unsaved changes"); + + if (!next) { + if (captureTimer) { + clearTimeout(captureTimer); + captureTimer = null; + } + updateWorkflowUiState(workflowKey, { + phase: "paused", + detail: hasUnsavedChanges + ? "Unsaved changes · Auto-capture paused" + : "Auto-capture paused", + }); + } else if (changed && hasUnsavedChanges) { + scheduleCaptureSnapshot(); + } else if (state.phase === "paused") { + updateWorkflowUiState(workflowKey, { + phase: state.savedAt ? "saved" : "untracked", + detail: state.savedAt + ? "No unsaved snapshot changes" + : "Current graph has not been checkpointed this session", + }); + } + + for (const button of document.querySelectorAll(".snap-auto-pause-btn")) { + button.classList.toggle("active", !next); + button.textContent = next ? "Auto: On" : "Auto: Off"; + button.setAttribute("aria-pressed", String(!next)); + } +} + +function scheduleInitialCaptureIfEmpty(workflowKey, knownRecords = null) { + if (!captureOnLoad || snapshotLoadMode || snapshotListErrorMap.has(workflowKey)) return; + setTimeout(async () => { + if (!captureOnLoad || snapshotLoadMode || getWorkflowKey() !== workflowKey) return; + const records = knownRecords ?? await db_getAllForWorkflow(workflowKey); + if (snapshotListErrorMap.has(workflowKey) || records.length > 0) return; + captureSnapshot("Initial", { + source: "initial", + dedupe: true, + skipCosmetic: false, + }).catch((err) => { + console.warn(`[${EXTENSION_NAME}] Initial capture failed:`, err); + }); + }, INITIAL_CAPTURE_DELAY_MS); +} + +async function reconcileWorkflowStatus(workflowKey, records) { + if (getWorkflowKey() !== workflowKey || !records?.length) return; + const latest = records.reduce( + (best, record) => !best || record.timestamp > best.timestamp ? record : best, + null, + ); + const full = latest && await db_getFullRecord(latest.workflowKey, latest.id); + const liveGraph = getGraphData(); + if (!full?.graphData || !liveGraph || getWorkflowKey() !== workflowKey) return; + + const changeType = detectChangeType(full.graphData, liveGraph); + if (changeType === "unchanged") { + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled ? "saved" : "paused", + savedAt: latest.timestamp, + activeRef: recordRef(latest), + detail: autoCaptureEnabled ? "Matches latest snapshot" : "Auto-capture paused", + }); + } else if (changeType === "unknown") { + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled ? "untracked" : "paused", + savedAt: latest.timestamp, + activeRef: null, + detail: autoCaptureEnabled + ? "Latest snapshot found; exact match not verified" + : "Auto-capture paused", + }); + } else { + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled + ? changeType === "cosmetic" ? "layout" : "dirty" + : "paused", + savedAt: latest.timestamp, + activeRef: null, + detail: autoCaptureEnabled + ? changeType === "cosmetic" + ? "Layout differs from latest snapshot" + : "Current graph differs from latest snapshot" + : "Unsaved changes · Auto-capture paused", + }); + } +} + // ─── Server API Layer ─────────────────────────────────────────────── async function _respError(resp) { @@ -101,9 +250,12 @@ async function db_getAllForWorkflow(workflowKey) { if (!resp.ok) { throw await _respError(resp); } - return await resp.json(); + const records = await resp.json(); + snapshotListErrorMap.delete(workflowKey); + return records; } catch (err) { console.warn(`[${EXTENSION_NAME}] List failed:`, err); + snapshotListErrorMap.set(workflowKey, err); showToast("Failed to read snapshots", "error"); return []; } @@ -122,6 +274,7 @@ async function db_delete(workflowKey, id) { } catch (err) { console.warn(`[${EXTENSION_NAME}] Delete failed:`, err); showToast("Failed to delete snapshot", "error"); + throw err; } } @@ -169,6 +322,7 @@ async function db_updateMeta(workflowKey, id, fields) { } catch (err) { console.warn(`[${EXTENSION_NAME}] Update meta failed:`, err); showToast("Failed to update snapshot", "error"); + throw err; } } @@ -293,6 +447,7 @@ async function profile_delete(profileId) { } catch (err) { console.warn(`[${EXTENSION_NAME}] Profile delete failed:`, err); showToast("Failed to delete profile", "error"); + throw err; } } @@ -365,14 +520,6 @@ async function migrateFromIndexedDB() { // ─── Helpers ───────────────────────────────────────────────────────── -function quickHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0; - } - return hash; -} - // Bound the in-memory previous-graph copies (used for change-type detection and // diff summaries). One full graph per workflow can be large, so keep an LRU of // the few most-recently-touched workflows instead of growing without limit. @@ -484,343 +631,10 @@ function getLiveWidgetNames() { return byId; } -// Resolve a human widget name for a node's widgets_values[index], or null. -function widgetNameFor(widgetNames, node, index) { - if (!widgetNames || !node) return null; - const names = widgetNames.get(node.id); - const nm = names && names[index]; - return nm || null; -} - function generateId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -function validateSnapshotData(graphData) { - return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes); -} - -// ─── Change-Type Detection ────────────────────────────────────────── - -function detectChangeType(prevGraph, currGraph) { - if (!prevGraph) return "initial"; - - const prevNodes = prevGraph.nodes || []; - const currNodes = currGraph.nodes || []; - - // Quick length check before building Sets - if (prevNodes.length !== currNodes.length) { - return prevNodes.length < currNodes.length ? "node_add" : "node_remove"; - } - - const prevIds = new Set(prevNodes.map(n => n.id)); - let hasAdded = false; - let hasRemoved = false; - for (let i = 0; i < currNodes.length; i++) { - if (!prevIds.has(currNodes[i].id)) { hasAdded = true; break; } - } - if (hasAdded) { - // Same length but different IDs → both add and remove - return "mixed"; - } - - // Node sets identical (same length, all curr IDs exist in prev) - // — check links, params, positions with early exits. - // Cosmetic flags (move/size/collapse) describe canvas-only changes the user - // doesn't care to version; meaningful flags (connection/param) do. - let flags = 0; - const FLAG_CONNECTION = 1; - const FLAG_PARAM = 2; - const FLAG_MOVE = 4; - const FLAG_SIZE = 8; - const FLAG_COLLAPSE = 16; - const FLAG_MODE = 32; - const MEANINGFUL = FLAG_CONNECTION | FLAG_PARAM | FLAG_MODE; - const COSMETIC = FLAG_MOVE | FLAG_SIZE | FLAG_COLLAPSE; - const ALL_FLAGS = MEANINGFUL | COSMETIC; - - // Compare links — check length first to avoid stringify when possible - const prevLinks = prevGraph.links || []; - const currLinks = currGraph.links || []; - if (prevLinks.length !== currLinks.length) { - flags |= FLAG_CONNECTION; - } else if (prevLinks.length > 0) { - // Same length — spot-check first/last before full stringify - const pFirst = prevLinks[0], cFirst = currLinks[0]; - const pLast = prevLinks[prevLinks.length - 1], cLast = currLinks[currLinks.length - 1]; - if (pFirst?.[0] !== cFirst?.[0] || pFirst?.[1] !== cFirst?.[1] - || pLast?.[0] !== cLast?.[0] || pLast?.[1] !== cLast?.[1]) { - flags |= FLAG_CONNECTION; - } else if (JSON.stringify(prevLinks) !== JSON.stringify(currLinks)) { - flags |= FLAG_CONNECTION; - } - } - - // Build lookup for prev nodes by id - const prevNodeMap = new Map(prevNodes.map(n => [n.id, n])); - - for (const cn of currNodes) { - const pn = prevNodeMap.get(cn.id); - if (!pn) continue; - - // Compare widget values — cheap ref/length check before stringify - if (!(flags & FLAG_PARAM)) { - const cw = cn.widgets_values; - const pw = pn.widgets_values; - if (cw !== pw) { - if (cw == null || pw == null - || !Array.isArray(cw) || !Array.isArray(pw) - || cw.length !== pw.length) { - flags |= FLAG_PARAM; - } else { - // Same-length arrays — compare elements directly - for (let i = 0; i < cw.length; i++) { - if (cw[i] !== pw[i]) { flags |= FLAG_PARAM; break; } - } - } - } - } - - // Compare positions - if (!(flags & FLAG_MOVE)) { - const cp = cn.pos, pp = pn.pos; - if (cp?.[0] !== pp?.[0] || cp?.[1] !== pp?.[1]) flags |= FLAG_MOVE; - } - - // Compare size (manual node resize) - if (!(flags & FLAG_SIZE)) { - const cs = cn.size, ps = pn.size; - if (cs?.[0] !== ps?.[0] || cs?.[1] !== ps?.[1]) flags |= FLAG_SIZE; - } - - // Compare collapse/pin state (node.flags.{collapsed,pinned}) - if (!(flags & FLAG_COLLAPSE)) { - const cf = cn.flags || {}, pf = pn.flags || {}; - if (!!cf.collapsed !== !!pf.collapsed || !!cf.pinned !== !!pf.pinned) flags |= FLAG_COLLAPSE; - } - - // Compare mode (mute/bypass) — a functional change, not cosmetic - if (!(flags & FLAG_MODE)) { - if ((cn.mode || 0) !== (pn.mode || 0)) flags |= FLAG_MODE; - } - - if (flags === ALL_FLAGS) break; - } - - if (flags === 0) return "unknown"; - - // Only canvas-cosmetic changes (move/resize/collapse) → "cosmetic". - if (!(flags & MEANINGFUL)) return "cosmetic"; - - // A meaningful change is present; cosmetic flags don't escalate to "mixed". - const meaningfulCount = ((flags & FLAG_CONNECTION) ? 1 : 0) - + ((flags & FLAG_PARAM) ? 1 : 0) - + ((flags & FLAG_MODE) ? 1 : 0); - if (meaningfulCount > 1) return "mixed"; - if (flags & FLAG_CONNECTION) return "connection"; - return "param"; // param or mode-only → treated as a parameter change -} - -// ─── Detailed Diff ────────────────────────────────────────────────── - -function buildNodeLookup(...graphs) { - const map = new Map(); - for (const g of graphs) { - if (!g || !Array.isArray(g.nodes)) continue; - for (const n of g.nodes) { - if (!map.has(n.id)) { - map.set(n.id, { type: n.type || "?", title: n.title || n.type || `#${n.id}` }); - } - } - } - return map; -} - -function computeDetailedDiff(baseGraph, targetGraph, widgetMaps = null) { - const empty = { - addedNodes: [], removedNodes: [], modifiedNodes: [], - addedLinks: [], removedLinks: [], - summary: { nodesAdded: 0, nodesRemoved: 0, nodesModified: 0, linksAdded: 0, linksRemoved: 0 }, - }; - if (!baseGraph && !targetGraph) return empty; - const bNodes = (baseGraph?.nodes || []); - const tNodes = (targetGraph?.nodes || []); - - const baseMap = new Map(bNodes.map(n => [n.id, n])); - const targetMap = new Map(tNodes.map(n => [n.id, n])); - - const addedNodes = []; - const removedNodes = []; - const modifiedNodes = []; - - // Removed: in base but not in target - for (const [id, n] of baseMap) { - if (!targetMap.has(id)) { - removedNodes.push({ id, type: n.type || "?", title: n.title || n.type || `#${id}` }); - } - } - - // Added or modified: in target - for (const [id, tn] of targetMap) { - const bn = baseMap.get(id); - if (!bn) { - addedNodes.push({ id, type: tn.type || "?", title: tn.title || tn.type || `#${id}` }); - continue; - } - // Check modifications - const changes = {}; - - // Position - if (bn.pos?.[0] !== tn.pos?.[0] || bn.pos?.[1] !== tn.pos?.[1]) { - changes.position = { from: bn.pos, to: tn.pos }; - } - - // Size - if (bn.size?.[0] !== tn.size?.[0] || bn.size?.[1] !== tn.size?.[1]) { - changes.size = { from: bn.size, to: tn.size }; - } - - // Title - if ((bn.title || "") !== (tn.title || "")) { - changes.title = { from: bn.title || "", to: tn.title || "" }; - } - - // Mode - if (bn.mode !== tn.mode) { - changes.mode = { from: bn.mode, to: tn.mode }; - } - - // Widget values - const bw = bn.widgets_values; - const tw = tn.widgets_values; - if (bw !== tw) { - if (bw == null || tw == null || !Array.isArray(bw) || !Array.isArray(tw) || bw.length !== tw.length) { - changes.widgetValues = { from: bw, to: tw }; - } else { - const diffs = []; - for (let i = 0; i < Math.max(bw.length, tw.length); i++) { - const bv = i < bw.length ? bw[i] : undefined; - const tv = i < tw.length ? tw[i] : undefined; - if (bv !== tv) { - const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? ""); - const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? ""); - if (bs !== ts) diffs.push({ index: i, name: widgetNameFor(widgetMaps, tn, i), from: bs, to: ts }); - } - } - if (diffs.length > 0) changes.widgetValues = diffs; - } - } - - // Properties (shallow key comparison) - const bp = bn.properties || {}; - const tp = tn.properties || {}; - const allPropKeys = new Set([...Object.keys(bp), ...Object.keys(tp)]); - const propDiffs = []; - for (const key of allPropKeys) { - const bv = bp[key]; - const tv = tp[key]; - if (bv !== tv) { - const bs = typeof bv === "object" ? JSON.stringify(bv) : String(bv ?? ""); - const ts = typeof tv === "object" ? JSON.stringify(tv) : String(tv ?? ""); - if (bs !== ts) propDiffs.push({ key, from: bs, to: ts }); - } - } - if (propDiffs.length > 0) changes.properties = propDiffs; - - if (Object.keys(changes).length > 0) { - modifiedNodes.push({ - id, type: tn.type || "?", title: tn.title || tn.type || `#${id}`, changes, - }); - } - } - - // Links - const bLinks = (baseGraph?.links || []).filter(Boolean); - const tLinks = (targetGraph?.links || []).filter(Boolean); - - const baseLinkMap = new Map(bLinks.map(l => [l[0], l])); - const targetLinkMap = new Map(tLinks.map(l => [l[0], l])); - - const addedLinks = []; - const removedLinks = []; - - for (const [linkId, l] of baseLinkMap) { - if (!targetLinkMap.has(linkId)) { - removedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] }); - } - } - for (const [linkId, l] of targetLinkMap) { - if (!baseLinkMap.has(linkId)) { - addedLinks.push({ linkId, srcNodeId: l[1], srcSlot: l[2], destNodeId: l[3], destSlot: l[4], type: l[5] }); - } - } - - return { - addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks, - summary: { - nodesAdded: addedNodes.length, - nodesRemoved: removedNodes.length, - nodesModified: modifiedNodes.length, - linksAdded: addedLinks.length, - linksRemoved: removedLinks.length, - }, - }; -} - -// Compact diff stored in snapshot metadata for hover display. widgetMaps comes -// from the live graph at capture time so changed parameters are named. -function computeCaptureMetaDiff(prevGraph, currGraph, widgetMaps = null) { - if (!prevGraph || !currGraph) return null; - const diff = computeDetailedDiff(prevGraph, currGraph, widgetMaps); - const result = {}; - if (diff.addedNodes.length > 0) - result.added = diff.addedNodes.map(n => n.title); - if (diff.removedNodes.length > 0) - result.removed = diff.removedNodes.map(n => n.title); - // Nodes with param/property changes (ignore pure position/size changes) - const paramChanged = diff.modifiedNodes.filter(n => - n.changes.widgetValues || n.changes.properties || n.changes.title || n.changes.mode - ); - if (paramChanged.length > 0) - result.params = paramChanged.map(n => { - // Prefer naming the changed widgets/props; fall back to a count. - const names = []; - if (Array.isArray(n.changes.widgetValues)) { - for (const wv of n.changes.widgetValues) if (wv.name) names.push(wv.name); - } - if (Array.isArray(n.changes.properties)) { - for (const pv of n.changes.properties) if (pv.key) names.push(pv.key); - } - if (n.changes.title) names.push("title"); - if (n.changes.mode) names.push("mode"); - if (names.length > 0) return `${n.title} (${names.join(", ")})`; - const wvCount = Array.isArray(n.changes.widgetValues) ? n.changes.widgetValues.length : (n.changes.widgetValues ? 1 : 0); - const count = wvCount + (n.changes.properties?.length ?? 0); - return count > 0 ? `${n.title} (${count} value${count > 1 ? "s" : ""})` : n.title; - }); - if (diff.addedLinks.length > 0 || diff.removedLinks.length > 0) - result.links = { added: diff.addedLinks.length, removed: diff.removedLinks.length }; - return Object.keys(result).length > 0 ? result : null; -} - -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(", ")); - } - return lines; -} - // ─── SVG Graph Renderer ───────────────────────────────────────────── const SVG_NS = "http://www.w3.org/2000/svg"; @@ -1345,6 +1159,7 @@ function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, ta { count: summary.nodesModified, label: "modified", color: "#f59e0b" }, { count: summary.linksAdded, label: "links +", color: "#3b82f6" }, { count: summary.linksRemoved, label: "links \u2212", color: "#3b82f6" }, + { count: summary.groupsChanged || 0, label: "groups", color: "#8b5cf6" }, ]; for (const p of pills) { if (p.count === 0) continue; @@ -1358,7 +1173,8 @@ function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, ta const body = document.createElement("div"); body.className = "snap-diff-body"; - const totalChanges = summary.nodesAdded + summary.nodesRemoved + summary.nodesModified + summary.linksAdded + summary.linksRemoved; + const totalChanges = summary.nodesAdded + summary.nodesRemoved + summary.nodesModified + + summary.linksAdded + summary.linksRemoved + (summary.groupsChanged || 0); if (totalChanges === 0) { const emptyMsg = document.createElement("div"); @@ -1486,6 +1302,16 @@ function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, ta return el; }); if (linkSec) body.appendChild(linkSec); + + const groupChanges = diff.groupChanges || []; + const groupSec = makeSection("Group Changes", groupChanges.length, groupChanges, (group) => { + const el = document.createElement("div"); + el.className = "snap-diff-node-entry snap-diff-neutral"; + el.textContent = `${group.kind === "added" ? "+" : group.kind === "removed" ? "−" : "~"} ${group.title}` + + (group.detail ? ` · ${group.detail}` : ""); + return el; + }); + if (groupSec) body.appendChild(groupSec); } function makeValueChange(label, oldVal, newVal) { @@ -1644,50 +1470,88 @@ async function showPreviewModal(record) { // ─── Snapshot Capture ──────────────────────────────────────────────── -let captureInProgress = false; - -// skipCosmetic is an AUTO-capture concern only: the debounced auto path passes -// true so node moves/resizes/collapses don't spawn snapshots. Manual saves and -// the pre-swap/pre-restore "Current" capture pass false so an explicit save — -// or preserving unsaved layout work before a load — is never silently dropped. -async function captureSnapshot(label = "Auto", { skipCosmetic = false } = {}) { - if (restoreLock) return false; - if (captureInProgress) return false; - captureInProgress = true; - try { return await _captureCore({ label, dedupe: true, skipCosmetic }); } finally { captureInProgress = false; } +// Serialize all capture sources through one queue. The previous boolean guard +// silently dropped manual saves and final debounced edits when another write +// was in flight; queued duplicates cheaply collapse at execution time. +function enqueueCapture(options) { + const task = captureQueue.catch(() => {}).then(() => _captureCore(options)); + captureQueue = task.catch(() => {}); + return task; } -async function _captureCore({ label, source = null, thumbnail = null, dedupe = false, skipCosmetic = false }) { +async function captureSnapshot(label = "Auto", { + source = "auto", + dedupe = true, + skipCosmetic = source === "auto", +} = {}) { + if (restoreLock && source !== "restore_guard") return false; + return enqueueCapture({ label, source, dedupe, skipCosmetic }); +} - const graphData = getGraphData(); +async function _captureCore({ + label, + source = null, + thumbnail = null, + dedupe = false, + skipCosmetic = false, + graphDataOverride = null, + workflowKeyOverride = null, + widgetNamesOverride = null, +}) { + + const graphData = graphDataOverride || getGraphData(); if (!graphData) return false; const nodes = graphData.nodes || []; if (nodes.length === 0) return false; - const workflowKey = getWorkflowKey(); + const workflowKey = workflowKeyOverride || getWorkflowKey(); + if (source === "auto" && !autoCaptureEnabled) { + updateWorkflowUiState(workflowKey, { + phase: "paused", + activeRef: null, + detail: "Unsaved changes · Auto-capture paused", + }); + return false; + } const serialized = JSON.stringify(graphData); const hash = quickHash(serialized); - if (dedupe && hash === lastCapturedHashMap.get(workflowKey)) return false; + if (dedupe && hash === lastCapturedHashMap.get(workflowKey)) { + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled ? "saved" : "paused", + detail: autoCaptureEnabled ? "No unsaved snapshot changes" : "Auto-capture paused", + }); + return false; + } const prevGraph = lastGraphDataMap.get(workflowKey); const changeType = detectChangeType(prevGraph, graphData); // Auto-captures ignore canvas-cosmetic changes (move/resize/collapse); the // cosmetic edit will ride along with the next meaningful snapshot. Manual // and node-triggered captures (skipCosmetic=false) always save. - if (skipCosmetic && changeType === "cosmetic") return false; + if (skipCosmetic && !isMeaningfulChangeType(changeType)) { + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled ? "layout" : "paused", + activeRef: null, + detail: autoCaptureEnabled + ? "Layout changed; it will ride with the next checkpoint" + : "Auto-capture paused", + }); + return false; + } // Determine parentId for branching let parentId = null; if (isBranchingEnabled(workflowKey)) { - if (activeSnapshotId) { - parentId = activeSnapshotId; // fork from swapped snapshot + const activeRef = getWorkflowUiState(workflowKey).activeRef; + if (activeRef?.workflowKey === workflowKey) { + parentId = activeRef.id; // fork from swapped snapshot } else if (lastCapturedIdMap.has(workflowKey)) { parentId = lastCapturedIdMap.get(workflowKey); // continuation } } - const captureDiff = computeCaptureMetaDiff(prevGraph, graphData, getLiveWidgetNames()); + const captureDiff = computeCaptureMetaDiff(prevGraph, graphData, widgetNamesOverride || getLiveWidgetNames()); const record = { id: generateId(), workflowKey, @@ -1698,12 +1562,13 @@ async function _captureCore({ label, source = null, thumbnail = null, dedupe = f locked: false, changeType, parentId, - ...(source ? { source } : {}), + source, ...(captureDiff ? { captureDiff } : {}), ...(thumbnail ? { thumbnail } : {}), }; const pruneFn = source === "node" ? pruneNodeSnapshots : pruneSnapshots; + updateWorkflowUiState(workflowKey, { phase: "saving", detail: `Saving ${label}…` }); try { await db_put(record); if (isBranchingEnabled(workflowKey)) { @@ -1728,16 +1593,26 @@ async function _captureCore({ label, source = null, thumbnail = null, dedupe = f } else { await pruneFn(workflowKey); } - } catch { - return false; + } catch (err) { + updateWorkflowUiState(workflowKey, { + phase: "error", + activeRef: null, + detail: "Save failed; current graph is not protected", + }); + throw err; } lastCapturedHashMap.set(workflowKey, hash); setLastGraphData(workflowKey, graphData); lastCapturedIdMap.set(workflowKey, record.id); + if (source === "auto") lastAutoCaptureAtMap.set(workflowKey, record.timestamp); pickerDirty = true; - currentSnapshotId = null; // new capture supersedes "current" bookmark - activeSnapshotId = null; // graph has changed, no snapshot is "active" + updateWorkflowUiState(workflowKey, { + phase: autoCaptureEnabled ? "saved" : "paused", + savedAt: record.timestamp, + activeRef: recordRef(record), + detail: source === "auto" ? "Auto-saved" : `Saved “${label}”`, + }); if (sidebarRefresh) { sidebarRefresh().catch(() => {}); @@ -1754,41 +1629,102 @@ async function captureNodeSnapshot(label = "Node Trigger", thumbnail = null) { // node trigger should always produce a snapshot), but they share the same // core so the in-memory hash/id state stays consistent afterwards (fixes // the duplicate-auto-snapshot-after-node-capture bug). - return await _captureCore({ label, source: "node", thumbnail }); + return enqueueCapture({ label, source: "node", thumbnail }); } function scheduleCaptureSnapshot() { - if (!autoCaptureEnabled) return; - if (restoreLock) return; - if (Date.now() < suppressAutoCaptureUntil) return; + const workflowKey = getWorkflowKey(); + if (snapshotLoadMode || restoreLock) return; + if (!autoCaptureEnabled) { + updateWorkflowUiState(workflowKey, { phase: "paused", activeRef: null, detail: "Unsaved changes · Auto-capture paused" }); + return; + } if (captureTimer) clearTimeout(captureTimer); + const now = Date.now(); + const sinceLastAuto = now - (lastAutoCaptureAtMap.get(workflowKey) || 0); + const intervalDelay = Math.max(0, minAutoIntervalMs - sinceLastAuto); + const suppressionDelay = Math.max(0, suppressAutoCaptureUntil - now); + const delay = Math.max(debounceMs, intervalDelay, suppressionDelay); + updateWorkflowUiState(workflowKey, { + phase: "dirty", + activeRef: null, + detail: "Unsaved changes · Auto-save pending", + }); captureTimer = setTimeout(() => { captureTimer = null; - captureSnapshot("Auto", { skipCosmetic: true }).catch((err) => { + if (!autoCaptureEnabled) { + updateWorkflowUiState(workflowKey, { phase: "paused", detail: "Unsaved changes · Auto-capture paused" }); + return; + } + if (restoreLock || Date.now() < suppressAutoCaptureUntil) { + scheduleCaptureSnapshot(); + return; + } + captureSnapshot("Auto", { source: "auto", dedupe: true, skipCosmetic: true }).catch((err) => { console.warn(`[${EXTENSION_NAME}] Auto-capture failed:`, err); }); - }, debounceMs); + }, delay); } -// ─── Restore ───────────────────────────────────────────────────────── +// ─── Snapshot Navigation ───────────────────────────────────────────── + +async function protectCurrentBeforeLoad() { + const workflowKey = getWorkflowKey(); + const state = getWorkflowUiState(workflowKey); + try { + const capturedId = await captureSnapshot("Return point", { + source: "restore_guard", + dedupe: true, + skipCosmetic: false, + }); + if (capturedId) { + state.returnRef = { workflowKey, id: capturedId }; + } else { + // Dedup means the live graph already matches the last durable + // checkpoint. Use that existing record as the return point. + const existingRef = state.activeRef + || (lastCapturedIdMap.has(workflowKey) + ? { workflowKey, id: lastCapturedIdMap.get(workflowKey) } + : null); + if (existingRef) state.returnRef = existingRef; + } + // The guard now durably represents the pending edits. Do not let the + // old debounce timer fire against whichever graph is loaded next. + if (captureTimer) { + clearTimeout(captureTimer); + captureTimer = null; + } + return true; + } catch (err) { + console.warn(`[${EXTENSION_NAME}] Protective snapshot failed; load aborted:`, err); + showToast("Could not protect the current graph. Open/replace cancelled.", "error"); + return false; + } +} + +function restoredCopyName(record) { + const safeLabel = String(record.label || "Snapshot") + .replace(/[\\/:*?"<>|]+/g, "-") + .trim() + .slice(0, 48) || "Snapshot"; + return `Restored - ${safeLabel}`; +} async function restoreSnapshot(record) { - if (!record.graphData) { - const full = await db_getFullRecord(record.workflowKey, record.id); - if (!full) { showToast("Failed to load snapshot data", "error"); return; } - record = full; - } - // Preserve any unsaved meaningful edit before replacing the canvas (parity - // with swap). Deduped by hash and skips cosmetic-only changes, so it's a - // no-op when there is nothing meaningful new to save. - await captureSnapshot("Current", { skipCosmetic: true }).catch(() => {}); await withRestoreLock(async () => { + if (!record.graphData) { + const full = await db_getFullRecord(record.workflowKey, record.id); + if (!full) { showToast("Failed to load snapshot data", "error"); return; } + record = full; + } if (!validateSnapshotData(record.graphData)) { showToast("Invalid snapshot data", "error"); return; } + if (!await protectCurrentBeforeLoad()) return; try { - await app.loadGraphData(record.graphData, true, true); + snapshotLoadMode = "open-copy"; + await app.loadGraphData(record.graphData, true, true, restoredCopyName(record)); const wfKey = getWorkflowKey(); // Seed the dedup baseline from the LIVE re-serialization, not the // stored record: ComfyUI's serialize() of the loaded graph isn't @@ -1797,49 +1733,48 @@ async function restoreSnapshot(record) { const liveGraph = getGraphData() || record.graphData; lastCapturedHashMap.set(wfKey, quickHash(JSON.stringify(liveGraph))); setLastGraphData(wfKey, liveGraph); - showToast("Snapshot restored", "success"); + updateWorkflowUiState(wfKey, { + phase: "loaded", + savedAt: record.timestamp, + activeRef: recordRef(record), + detail: `Opened copy of “${record.label}”`, + }); + showToast("Snapshot opened as a new workflow", "success"); } catch (err) { - console.warn(`[${EXTENSION_NAME}] Restore failed:`, err); - showToast("Failed to restore snapshot", "error"); + console.warn(`[${EXTENSION_NAME}] Open copy failed:`, err); + showToast("Failed to open snapshot copy", "error"); + } finally { + snapshotLoadMode = null; } }); } async function swapSnapshot(record, { quiet = false } = {}) { - // Warn when swapping in a snapshot from a different workflow - const currentKey = getWorkflowKey(); - if (record.workflowKey && record.workflowKey !== currentKey) { - const confirmed = await showConfirmDialog( - `This snapshot belongs to a different workflow ("${record.workflowKey}").\nSwap it into the current workflow anyway?` - ); - if (!confirmed) return; - } - - // Auto-save current state before swapping (so the user can get back). - // captureSnapshot() dedupes by hash, so this is a no-op when browsing - // between already-saved snapshots, but it WILL save any unsaved live edits - // made after a previous swap (when activeSnapshotId is still set) — which - // would otherwise be silently discarded by the load below. - // Preserve a genuine unsaved edit before loading, but skip cosmetic-only - // changes (move/resize/collapse) and dedup no-ops so that merely browsing - // between snapshots never spawns a "Current" snapshot (the reported spam). - const prevCurrentId = currentSnapshotId; - const capturedId = await captureSnapshot("Current", { skipCosmetic: true }); - currentSnapshotId = capturedId || prevCurrentId; - - if (!record.graphData) { - const full = await db_getFullRecord(record.workflowKey, record.id); - if (!full) { showToast("Failed to load snapshot data", "error"); return; } - record = full; - } - await withRestoreLock(async () => { + // Warn when replacing from a different workflow's history. + const currentKey = getWorkflowKey(); + if (record.workflowKey && record.workflowKey !== currentKey) { + const confirmed = await showConfirmDialog( + `This snapshot belongs to a different workflow ("${record.workflowKey}").\nReplace the current workflow with it anyway?` + ); + if (!confirmed) return; + } + if (!record.graphData) { + const full = await db_getFullRecord(record.workflowKey, record.id); + if (!full) { showToast("Failed to load snapshot data", "error"); return; } + record = full; + } if (!validateSnapshotData(record.graphData)) { showToast("Invalid snapshot data", "error"); return; } + if (!await protectCurrentBeforeLoad()) return; try { const workflow = app.extensionManager?.workflow?.activeWorkflow; + const originalInitial = workflow?.changeTracker?.initialState + ? structuredClone(workflow.changeTracker.initialState) + : null; + snapshotLoadMode = "replace"; await app.loadGraphData(record.graphData, true, true, workflow); const wfKey = getWorkflowKey(); // Seed the dedup baseline from the LIVE re-serialization, not the @@ -1850,11 +1785,26 @@ async function swapSnapshot(record, { quiet = false } = {}) { const liveGraph = getGraphData() || record.graphData; lastCapturedHashMap.set(wfKey, quickHash(JSON.stringify(liveGraph))); setLastGraphData(wfKey, liveGraph); - activeSnapshotId = record.id; - if (!quiet) showToast("Snapshot swapped", "success"); + const tracker = workflow?.changeTracker; + if (tracker) { + tracker.undoQueue = []; + tracker.redoQueue = []; + if (originalInitial) tracker.initialState = originalInitial; + tracker.activeState = structuredClone(liveGraph); + workflow.isModified = originalInitial ? !deepEqual(originalInitial, liveGraph) : true; + } + updateWorkflowUiState(wfKey, { + phase: "loaded", + savedAt: record.timestamp, + activeRef: recordRef(record), + detail: `Loaded “${record.label}” · current workflow is modified`, + }); + if (!quiet) showToast("Snapshot loaded into current workflow", "success"); } catch (err) { - console.warn(`[${EXTENSION_NAME}] Swap failed:`, err); - showToast("Failed to swap snapshot", "error"); + console.warn(`[${EXTENSION_NAME}] Replace failed:`, err); + showToast("Failed to replace current workflow", "error"); + } finally { + snapshotLoadMode = null; } }); } @@ -1877,7 +1827,8 @@ async function stepToSnapshot(direction) { try { recs = await db_getAllForWorkflow(wfKey); } catch { return; } if (!recs || recs.length === 0) return; recs.sort((a, b) => a.timestamp - b.timestamp); - const currentId = activeSnapshotId ?? currentSnapshotId ?? lastCapturedIdMap.get(wfKey); + const state = getWorkflowUiState(wfKey); + const currentId = state.activeRef?.id ?? state.returnRef?.id ?? lastCapturedIdMap.get(wfKey); let idx = recs.findIndex(r => r.id === currentId); if (idx === -1) idx = recs.length - 1; // unknown position → treat as latest const nextIdx = idx + direction; @@ -1936,6 +1887,38 @@ const CSS = ` color: var(--descrip-text, #888); white-space: nowrap; } +.snap-status { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color, #444); + background: color-mix(in srgb, var(--comfy-menu-bg, #242424) 88%, transparent); + color: var(--descrip-text, #aaa); + font-size: 11px; + line-height: 1.3; + flex-shrink: 0; +} +.snap-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: #64748b; +} +.snap-status[data-phase="saved"] .snap-status-dot, +.snap-status[data-phase="loaded"] .snap-status-dot { background: #22c55e; } +.snap-status[data-phase="dirty"] .snap-status-dot, +.snap-status[data-phase="layout"] .snap-status-dot { background: #f59e0b; } +.snap-status[data-phase="saving"] .snap-status-dot { background: #3b82f6; } +.snap-status[data-phase="paused"] .snap-status-dot { background: #a78bfa; } +.snap-status[data-phase="error"] .snap-status-dot { background: #ef4444; } +.snap-status-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .snap-search { padding: 6px 10px; border-bottom: 1px solid var(--border-color, #444); @@ -2099,6 +2082,8 @@ const CSS = ` display: flex; gap: 4px; flex-shrink: 0; + align-items: center; + position: relative; } .snap-item-actions button { padding: 3px 8px; @@ -2147,6 +2132,54 @@ const CSS = ` background: #dc2626; color: #fff; } +.snap-item-more { + position: relative; +} +.snap-item-more > summary { + list-style: none; + cursor: pointer; + min-width: 28px; + padding: 3px 7px; + border-radius: 3px; + text-align: center; + color: var(--input-text, #ccc); + background: var(--comfy-menu-bg, #444); + user-select: none; +} +.snap-item-more > summary::-webkit-details-marker { + display: none; +} +.snap-item-more-menu { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + right: 0; + display: grid; + min-width: 112px; + padding: 4px; + gap: 2px; + border: 1px solid var(--border-color, #555); + border-radius: 5px; + background: var(--comfy-menu-bg, #292929); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35); +} +.snap-item-more-menu button, +.snap-item-more-menu .snap-btn-note, +.snap-item-more-menu .snap-btn-lock { + width: 100%; + justify-content: flex-start; + padding: 5px 8px; + background: transparent; + color: var(--input-text, #ccc); + opacity: 1; + text-align: left; +} +.snap-item-more-menu button:hover { + background: color-mix(in srgb, var(--comfy-menu-bg, #444) 75%, #fff 10%); +} +.snap-item-more-menu .snap-btn-delete:hover { + background: #dc2626; +} .snap-footer { padding: 8px 10px; border-top: 1px solid var(--border-color, #444); @@ -3049,7 +3082,7 @@ async function buildSidebar(el) { takeBtn.disabled = true; takeBtn.textContent = "Saving..."; try { - const saved = await captureSnapshot(name); + const saved = await captureSnapshot(name, { source: "manual", dedupe: false, skipCosmetic: false }); showToast(saved ? "Snapshot saved" : "No changes since last snapshot", saved ? "success" : "info"); } finally { const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== getWorkflowKey(); @@ -3064,6 +3097,34 @@ async function buildSidebar(el) { header.appendChild(takeBtn); header.appendChild(countSpan); + // Truthful live-state indicator: this changes immediately on graph edits, + // before the debounced snapshot write finishes. + const statusRow = document.createElement("div"); + statusRow.className = "snap-status"; + const statusDot = document.createElement("span"); + statusDot.className = "snap-status-dot"; + const statusText = document.createElement("span"); + statusText.className = "snap-status-text"; + statusRow.appendChild(statusDot); + statusRow.appendChild(statusText); + + function refreshStatus() { + const workflowKey = getWorkflowKey(); + const state = getWorkflowUiState(workflowKey); + statusRow.dataset.phase = state.phase; + const savedSuffix = state.savedAt + ? ["saved", "loaded"].includes(state.phase) + ? ` · ${formatRelativeTime(state.savedAt)}` + : ` · last saved ${formatRelativeTime(state.savedAt)}` + : ""; + statusText.textContent = `${state.detail}${savedSuffix}`; + statusRow.title = state.savedAt + ? `Last snapshot: ${formatDate(state.savedAt)} ${formatTime(state.savedAt)}` + : state.detail; + } + sidebarStatusRefresh = refreshStatus; + refreshStatus(); + // Search const searchRow = document.createElement("div"); searchRow.className = "snap-search"; @@ -3087,28 +3148,36 @@ async function buildSidebar(el) { filterItems(term.toLowerCase()); }); - let hideAutoSaves = false; + let hideAutoSaves = localStorage.getItem("snapshotManager_hideAuto") === "true"; const autoFilterBtn = document.createElement("button"); - autoFilterBtn.className = "snap-filter-auto-btn"; - autoFilterBtn.textContent = "Hide Auto"; + autoFilterBtn.className = "snap-filter-auto-btn" + (hideAutoSaves ? " active" : ""); + autoFilterBtn.textContent = hideAutoSaves ? "Show Auto" : "Hide Auto"; autoFilterBtn.title = "Hide auto-save snapshots"; autoFilterBtn.addEventListener("click", () => { hideAutoSaves = !hideAutoSaves; + localStorage.setItem("snapshotManager_hideAuto", String(hideAutoSaves)); autoFilterBtn.classList.toggle("active", hideAutoSaves); autoFilterBtn.textContent = hideAutoSaves ? "Show Auto" : "Hide Auto"; filterItems(searchInput.value.toLowerCase()); }); const pauseBtn = document.createElement("button"); - pauseBtn.className = "snap-filter-auto-btn" + (autoCaptureEnabled ? "" : " active"); + pauseBtn.className = "snap-filter-auto-btn snap-auto-pause-btn" + (autoCaptureEnabled ? "" : " active"); pauseBtn.textContent = autoCaptureEnabled ? "Auto: On" : "Auto: Off"; - pauseBtn.title = "Pause/resume automatic snapshot capture for this session"; + pauseBtn.setAttribute("aria-pressed", String(!autoCaptureEnabled)); + pauseBtn.title = "Pause/resume automatic snapshot capture"; pauseBtn.addEventListener("click", () => { - autoCaptureEnabled = !autoCaptureEnabled; - pauseBtn.classList.toggle("active", !autoCaptureEnabled); - pauseBtn.textContent = autoCaptureEnabled ? "Auto: On" : "Auto: Off"; - showToast(autoCaptureEnabled ? "Auto-capture resumed" : "Auto-capture paused", "info"); + const next = !autoCaptureEnabled; + setAutoCaptureEnabled(next); + // Keep the sidebar control and the Settings panel in sync when the + // current frontend exposes the writable settings store. + try { + app.extensionManager?.setting?.set?.("SnapshotManager.autoCapture", next); + } catch (err) { + console.warn(`[${EXTENSION_NAME}] Could not persist auto-capture setting:`, err); + } + showToast(next ? "Auto-capture resumed" : "Auto-capture paused", "info"); }); const branchToggleBtn = document.createElement("button"); @@ -3202,8 +3271,8 @@ async function buildSidebar(el) { lastCapturedHashMap.delete(entry.workflowKey); lastGraphDataMap.delete(entry.workflowKey); if (entry.workflowKey === currentKey) { - activeSnapshotId = null; - currentSnapshotId = null; + workflowUiStateMap.delete(entry.workflowKey); + lastAutoCaptureAtMap.delete(entry.workflowKey); activeBranchSelections.clear(); } if (viewingWorkflowKey === entry.workflowKey) { @@ -3357,8 +3426,8 @@ async function buildSidebar(el) { lastCapturedIdMap.delete(effKey); lastCapturedHashMap.delete(effKey); lastGraphDataMap.delete(effKey); - activeSnapshotId = null; - currentSnapshotId = null; + workflowUiStateMap.delete(effKey); + lastAutoCaptureAtMap.delete(effKey); activeBranchSelections.clear(); pickerDirty = true; if (lockedCount > 0) { @@ -3403,19 +3472,27 @@ async function buildSidebar(el) { if (name == null) return; const trimmed = name.trim() || "My Profile"; - // Gather session workflows - const workflows = []; - for (const [wk, info] of sessionWorkflows) { - workflows.push({ workflowKey: wk, displayName: wk }); - } - if (workflows.length === 0) { - // At least include current workflow - const currentKey = getWorkflowKey(); - workflows.push({ workflowKey: currentKey, displayName: currentKey }); - } + // Pin each workflow to the exact snapshot visible when the profile is + // saved. Legacy profiles without snapshotId still fall back to latest. + const workflowKeys = sessionWorkflows.size > 0 + ? [...sessionWorkflows.keys()] + : [getWorkflowKey()]; + const workflows = await Promise.all(workflowKeys.map(async (workflowKey) => { + const records = await db_getAllForWorkflow(workflowKey); + const latest = records.reduce( + (best, record) => !best || record.timestamp > best.timestamp ? record : best, + null, + ); + return { + workflowKey, + displayName: workflowKey, + snapshotId: latest?.id ?? null, + }; + })); const profile = { id: generateId(), + version: 2, name: trimmed, timestamp: Date.now(), workflows, @@ -3480,35 +3557,36 @@ async function buildSidebar(el) { const workflows = p.workflows || []; let loaded = 0; let skipped = 0; - // Load non-active workflows first (each overwrites previous — - // ComfyUI can only display one workflow at a time, but loading - // them populates the workflow history/tabs in some frontends) - for (const wf of workflows) { - // Skip active workflow — loaded last so it ends up visible - if (wf.workflowKey === p.activeWorkflowKey) continue; - const records = await db_getAllForWorkflow(wf.workflowKey); - if (records.length === 0) { skipped++; continue; } - records.sort((a, b) => b.timestamp - a.timestamp); - const full = await db_getFullRecord(records[0].workflowKey, records[0].id); - if (!full || !full.graphData) { skipped++; continue; } + const ordered = [...workflows].sort((a, b) => + Number(a.workflowKey === p.activeWorkflowKey) + - Number(b.workflowKey === p.activeWorkflowKey)); + snapshotLoadMode = "open-copy"; + for (const wf of ordered) { + let snapshotId = wf.snapshotId; + // Compatibility with profiles saved before exact IDs + // were recorded: resolve their most recent snapshot. + if (!Object.hasOwn(wf, "snapshotId")) { + const records = await db_getAllForWorkflow(wf.workflowKey); + const latest = records.reduce( + (best, record) => !best || record.timestamp > best.timestamp ? record : best, + null, + ); + snapshotId = latest?.id; + } + if (!snapshotId) { skipped++; continue; } + const full = await db_getFullRecord(wf.workflowKey, snapshotId); + if (!full?.graphData) { skipped++; continue; } try { - await app.loadGraphData(full.graphData, true, true); + await app.loadGraphData( + full.graphData, + true, + true, + wf.displayName || wf.workflowKey, + ); loaded++; - } catch { skipped++; } - } - // Load the active workflow last so it's the one visible - if (p.activeWorkflowKey) { - const activeRecs = await db_getAllForWorkflow(p.activeWorkflowKey); - if (activeRecs.length > 0) { - activeRecs.sort((a, b) => b.timestamp - a.timestamp); - const activeFull = await db_getFullRecord(activeRecs[0].workflowKey, activeRecs[0].id); - if (activeFull?.graphData) { - try { - await app.loadGraphData(activeFull.graphData, true, true); - loaded++; - } catch { skipped++; } - } else { skipped++; } - } else { skipped++; } + } catch { + skipped++; + } } let msg = `Profile "${p.name}" loaded (${loaded} workflow${loaded === 1 ? "" : "s"})`; if (skipped > 0) msg += `, ${skipped} skipped`; @@ -3517,6 +3595,7 @@ async function buildSidebar(el) { console.warn(`[${EXTENSION_NAME}] Profile load failed:`, err); showToast("Failed to load profile", "error"); } finally { + snapshotLoadMode = null; loadBtn.disabled = false; loadBtn.textContent = "Load"; } @@ -3530,7 +3609,11 @@ async function buildSidebar(el) { e.stopPropagation(); const confirmed = await showConfirmDialog(`Delete profile "${p.name}"?`); if (!confirmed) return; - await profile_delete(p.id); + try { + await profile_delete(p.id); + } catch { + return; + } showToast(`Profile "${p.name}" deleted`, "info"); await refreshProfiles(); }); @@ -3547,6 +3630,7 @@ async function buildSidebar(el) { profilesSection.appendChild(profilesBody); container.appendChild(header); + container.appendChild(statusRow); container.appendChild(selectorRow); container.appendChild(pickerList); container.appendChild(viewingBanner); @@ -3583,6 +3667,16 @@ async function buildSidebar(el) { const isViewingOther = viewingWorkflowKey != null && viewingWorkflowKey !== currentKey; const allRecords = await db_getAllForWorkflow(effKey); + if (snapshotListErrorMap.has(effKey)) { + countSpan.textContent = "Unavailable"; + list.innerHTML = ""; + itemEntries = []; + const error = document.createElement("div"); + error.className = "snap-empty"; + error.textContent = "Snapshot history could not be loaded. Use Refresh to try again."; + list.appendChild(error); + return; + } // Keep immutable SVG previews across refreshes; only drop stale/excess. pruneSvgCache(allRecords); @@ -3656,10 +3750,17 @@ async function buildSidebar(el) { if (diffBaseSnapshot && diffBaseSnapshot.id === rec.id) { item.classList.add("snap-diff-base"); } - if (rec.id === activeSnapshotId) { + // A loaded snapshot can originate in another workflow's history, + // while the live state still belongs to the active tab. + const activeTabState = getWorkflowUiState(currentKey); + const workflowState = refMatchesRecord(activeTabState.activeRef, rec) + || refMatchesRecord(activeTabState.returnRef, rec) + ? activeTabState + : getWorkflowUiState(effKey); + if (refMatchesRecord(workflowState.activeRef, rec)) { item.classList.add("snap-item-active"); } - if (rec.id === currentSnapshotId) { + if (refMatchesRecord(workflowState.returnRef, rec)) { item.classList.add("snap-item-current"); } @@ -3692,8 +3793,13 @@ async function buildSidebar(el) { committed = true; const newLabel = input.value.trim() || originalLabel; if (newLabel !== originalLabel) { + try { + await db_updateMeta(rec.workflowKey, rec.id, { label: newLabel }); + } catch { + labelDiv.textContent = originalLabel; + return; + } rec.label = newLabel; - await db_updateMeta(rec.workflowKey, rec.id, { label: newLabel }); await refresh(); } else { labelDiv.textContent = originalLabel; @@ -3754,9 +3860,20 @@ async function buildSidebar(el) { const actions = document.createElement("div"); actions.className = "snap-item-actions"; + const moreMenu = document.createElement("details"); + moreMenu.className = "snap-item-more"; + const moreSummary = document.createElement("summary"); + moreSummary.textContent = "⋯"; + moreSummary.title = "More snapshot actions"; + moreSummary.setAttribute("aria-label", "More snapshot actions"); + const moreMenuBody = document.createElement("div"); + moreMenuBody.className = "snap-item-more-menu"; + moreMenu.appendChild(moreSummary); + moreMenu.appendChild(moreMenuBody); + const noteBtn = document.createElement("button"); noteBtn.className = "snap-btn-note" + (rec.notes ? " has-note" : ""); - noteBtn.innerHTML = ''; + noteBtn.textContent = rec.notes ? "Edit Note" : "Add Note"; noteBtn.title = rec.notes ? "Edit note" : "Add note"; noteBtn.addEventListener("click", (e) => { e.stopPropagation(); @@ -3773,11 +3890,17 @@ async function buildSidebar(el) { if (saved) return; saved = true; const newNotes = textarea.value.trim(); + try { + await db_updateMeta(rec.workflowKey, rec.id, { notes: newNotes || null }); + } catch { + textarea.remove(); + return; + } rec.notes = newNotes || undefined; - await db_updateMeta(rec.workflowKey, rec.id, { notes: newNotes || null }); textarea.remove(); // Update this item in place instead of rebuilding the list. noteBtn.className = "snap-btn-note" + (rec.notes ? " has-note" : ""); + noteBtn.textContent = rec.notes ? "Edit Note" : "Add Note"; noteBtn.title = rec.notes ? "Edit note" : "Add note"; if (rec.notes) { notesDiv.textContent = rec.notes; @@ -3798,49 +3921,64 @@ async function buildSidebar(el) { const lockBtn = document.createElement("button"); lockBtn.className = rec.locked ? "snap-btn-lock snap-btn-locked" : "snap-btn-lock"; - lockBtn.innerHTML = rec.locked - ? '' - : ''; + lockBtn.textContent = rec.locked ? "Unlock" : "Lock"; lockBtn.title = rec.locked ? "Unlock snapshot" : "Lock snapshot"; - const LOCK_ICON_LOCKED = ''; - const LOCK_ICON_UNLOCKED = ''; lockBtn.addEventListener("click", async () => { - rec.locked = !rec.locked; - await db_updateMeta(rec.workflowKey, rec.id, { locked: rec.locked }); + const nextLocked = !rec.locked; + try { + await db_updateMeta(rec.workflowKey, rec.id, { locked: nextLocked }); + } catch { + return; + } + rec.locked = nextLocked; // Update just this item in place — locking is purely visual and // does not change list membership/order, so skip a full rebuild. lockBtn.className = rec.locked ? "snap-btn-lock snap-btn-locked" : "snap-btn-lock"; - lockBtn.innerHTML = rec.locked ? LOCK_ICON_LOCKED : LOCK_ICON_UNLOCKED; + lockBtn.textContent = rec.locked ? "Unlock" : "Lock"; lockBtn.title = rec.locked ? "Unlock snapshot" : "Lock snapshot"; + const entry = itemEntries.find((candidate) => candidate.element === item); + if (entry) { + const source = sourceForRecord(rec); + entry.isAuto = !rec.locked && (source === "auto" || source === "initial"); + filterItems(searchInput.value.toLowerCase()); + } }); const swapBtn = document.createElement("button"); swapBtn.className = "snap-btn-swap"; - swapBtn.textContent = "Swap"; - swapBtn.title = "Replace current workflow in-place"; + swapBtn.textContent = "Replace"; + swapBtn.title = "Replace the current workflow (a return point is saved first)"; swapBtn.addEventListener("click", async () => { setActionButtonsDisabled(true); - await swapSnapshot(rec); + try { + await swapSnapshot(rec); + } finally { + setActionButtonsDisabled(false); + } }); const restoreBtn = document.createElement("button"); restoreBtn.className = "snap-btn-restore"; - restoreBtn.textContent = "Restore"; - restoreBtn.title = "Open as new workflow"; + restoreBtn.textContent = "Open Copy"; + restoreBtn.title = "Open this snapshot as a separate workflow tab"; restoreBtn.addEventListener("click", async () => { setActionButtonsDisabled(true); - await restoreSnapshot(rec); + try { + await restoreSnapshot(rec); + } finally { + setActionButtonsDisabled(false); + } }); const deleteBtn = document.createElement("button"); deleteBtn.className = "snap-btn-delete"; - deleteBtn.textContent = "\u2715"; + deleteBtn.textContent = "Delete"; deleteBtn.title = "Delete this snapshot"; deleteBtn.addEventListener("click", async () => { if (rec.locked) { const confirmed = await showConfirmDialog("This snapshot is locked. Delete anyway?"); if (!confirmed) return; - } else if (rec.label && !["Auto", "Initial", "Current"].includes(rec.label)) { + } else if (!["auto", "initial", "restore_guard"].includes(sourceForRecord(rec))) { // Confirm before deleting a named/manual/node snapshot; auto // snapshots are disposable and frequent, so skip the prompt. const confirmed = await showConfirmDialog(`Delete snapshot "${rec.label}"? This cannot be undone.`); @@ -3858,11 +3996,19 @@ async function buildSidebar(el) { if (!confirmed) return; const newParent = freshTree.parentOf.get(rec.id) ?? null; for (const child of children) { - await db_updateMeta(rec.workflowKey, child.id, { parentId: newParent }); + try { + await db_updateMeta(rec.workflowKey, child.id, { parentId: newParent }); + } catch { + return; + } } } } - await db_delete(rec.workflowKey, rec.id); + try { + await db_delete(rec.workflowKey, rec.id); + } catch { + return; + } pickerDirty = true; await refresh(); if (timelineRefresh) { @@ -3919,20 +4065,24 @@ async function buildSidebar(el) { const previewBtn = document.createElement("button"); previewBtn.className = "snap-btn-preview"; - previewBtn.innerHTML = ''; + previewBtn.textContent = "Preview"; previewBtn.title = "Preview workflow graph"; previewBtn.addEventListener("click", (e) => { e.stopPropagation(); showPreviewModal(rec); }); - actions.appendChild(noteBtn); - actions.appendChild(previewBtn); - actions.appendChild(diffBtn); - actions.appendChild(lockBtn); - actions.appendChild(swapBtn); actions.appendChild(restoreBtn); - actions.appendChild(deleteBtn); + actions.appendChild(swapBtn); + actions.appendChild(moreMenu); + moreMenuBody.appendChild(previewBtn); + moreMenuBody.appendChild(diffBtn); + moreMenuBody.appendChild(noteBtn); + moreMenuBody.appendChild(lockBtn); + moreMenuBody.appendChild(deleteBtn); + moreMenuBody.addEventListener("click", (event) => { + if (event.target.closest("button")) moreMenu.open = false; + }); // Hover tooltip item.addEventListener("mouseenter", () => { @@ -3993,7 +4143,13 @@ async function buildSidebar(el) { item.appendChild(actions); list.appendChild(item); - itemEntries.push({ element: item, label: rec.label, notes: rec.notes || "", isAuto: rec.label === "Auto" || rec.label === "Initial" }); + const recordSource = sourceForRecord(rec); + itemEntries.push({ + element: item, + label: rec.label, + notes: rec.notes || "", + isAuto: !rec.locked && (recordSource === "auto" || recordSource === "initial"), + }); } // Re-apply current filters to newly built items @@ -4042,7 +4198,7 @@ function buildTimeline() { snapBtn.title = "Take a manual snapshot (Ctrl+S)"; snapBtn.addEventListener("click", async () => { snapBtn.disabled = true; - const saved = await captureSnapshot("Manual"); + const saved = await captureSnapshot("Manual", { source: "manual", dedupe: false, skipCosmetic: false }); if (saved) showToast("Snapshot saved", "success"); snapBtn.disabled = false; }); @@ -4082,7 +4238,7 @@ function buildTimeline() { } if (rec.locked) marker.classList.add("snap-timeline-marker-locked"); if (rec.id === activeId) marker.classList.add("snap-timeline-marker-active"); - if (rec.id === currentSnapshotId) { + if (refMatchesRecord(getWorkflowUiState(getWorkflowKey()).returnRef, rec)) { marker.classList.add("snap-timeline-marker-current"); marker.style.setProperty("--snap-marker-color", "#10b981"); } @@ -4133,7 +4289,8 @@ function buildTimeline() { } // Compute once for all markers - const effectiveActiveId = activeSnapshotId ?? lastCapturedIdMap.get(wfKey) ?? null; + const activeRef = getWorkflowUiState(wfKey).activeRef; + const effectiveActiveId = activeRef?.workflowKey === wfKey ? activeRef.id : null; const latestId = allRecords.reduce((best, r) => (!best || r.timestamp > best.timestamp) ? r : best, null)?.id ?? null; let tree = null; @@ -4267,7 +4424,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { defaultValue: true, category: ["Snapshot Manager", "Capture Settings", "Auto-capture on edit"], onChange(value) { - autoCaptureEnabled = value; + setAutoCaptureEnabled(value); }, }, { @@ -4281,6 +4438,17 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { debounceMs = value * 1000; }, }, + { + id: "SnapshotManager.minAutoIntervalSeconds", + name: "Minimum time between auto-snapshots (seconds)", + type: "slider", + defaultValue: 60, + attrs: { min: 0, max: 300, step: 15 }, + category: ["Snapshot Manager", "Capture Settings", "Minimum auto-snapshot interval"], + onChange(value) { + minAutoIntervalMs = value * 1000; + }, + }, { id: "SnapshotManager.maxSnapshots", name: "Max snapshots per workflow", @@ -4294,10 +4462,10 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { }, { id: "SnapshotManager.captureOnLoad", - name: "Capture on workflow load", + name: "Create an initial snapshot for workflows with no history", type: "boolean", defaultValue: true, - category: ["Snapshot Manager", "Capture Settings", "Capture on workflow load"], + category: ["Snapshot Manager", "Capture Settings", "Initial snapshot for new workflows"], onChange(value) { captureOnLoad = value; }, @@ -4362,6 +4530,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { }, destroy: () => { sidebarRefresh = null; + sidebarStatusRefresh = null; viewingWorkflowKey = null; // Clean up tooltip if (sidebarTooltipEl) { @@ -4396,18 +4565,35 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { workflowStore.$onAction(({ name, after }) => { if (name === "openWorkflow") { const prevKey = getWorkflowKey(); // capture BEFORE switch - after(() => { - // Cancel any pending capture from the previous workflow - if (captureTimer) { - clearTimeout(captureTimer); - captureTimer = null; + const openingSnapshotCopy = snapshotLoadMode === "open-copy"; + // A workflow switch used to cancel the trailing debounce + // and lose the last editing burst. Freeze that graph now + // and queue it with its original workflow identity. + if (captureTimer) { + clearTimeout(captureTimer); + captureTimer = null; + if (!snapshotLoadMode && autoCaptureEnabled) { + const previousGraph = getGraphData(); + const previousWidgetNames = getLiveWidgetNames(); + if (previousGraph) { + enqueueCapture({ + label: "Auto", + source: "auto", + dedupe: true, + skipCosmetic: true, + graphDataOverride: previousGraph, + workflowKeyOverride: prevKey, + widgetNamesOverride: previousWidgetNames, + }).catch((err) => { + console.warn(`[${EXTENSION_NAME}] Pre-switch capture failed:`, err); + }); + } } + } + after(() => { viewingWorkflowKey = null; - activeSnapshotId = null; - currentSnapshotId = null; diffBaseSnapshot = null; // Clear branching state for the old workflow - lastCapturedIdMap.delete(prevKey); activeBranchSelections.clear(); // Seed active ring for the new workflow tab const newKey = getWorkflowKey(); @@ -4428,6 +4614,14 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { lastCapturedIdMap.set(newKey, latest.id); if (timelineRefresh) timelineRefresh().catch(() => {}); } + if (!openingSnapshotCopy) { + reconcileWorkflowStatus(newKey, recs).catch((err) => { + console.warn(`[${EXTENSION_NAME}] Status reconciliation failed:`, err); + }); + } + if (recs.length === 0 && !openingSnapshotCopy) { + scheduleInitialCaptureIfEmpty(newKey, recs); + } }).catch(() => {}); if (sidebarRefresh) { sidebarRefresh(true).catch(() => {}); @@ -4448,7 +4642,7 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { const t = e.target; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (viewingWorkflowKey != null && viewingWorkflowKey !== getWorkflowKey()) return; - captureSnapshot("Manual (Ctrl+S)").then((saved) => { + captureSnapshot("Manual (Ctrl+S)", { source: "manual", dedupe: false, skipCosmetic: false }).then((saved) => { if (saved) showToast("Snapshot saved", "success"); }).catch(() => {}); // Don't preventDefault — let ComfyUI's own workflow save still fire @@ -4476,19 +4670,14 @@ if (window.__COMFYUI_FRONTEND_VERSION__) { setLastGraphData(wfKey, graphData); } if (timelineRefresh) timelineRefresh().catch(() => {}); + await reconcileWorkflowStatus(wfKey, records); } + scheduleInitialCaptureIfEmpty(wfKey, records); } catch {} // Track initial workflow for profiles trackSessionWorkflow(getWorkflowKey()); - // Capture initial state after a short delay (decoupled from debounceMs) - setTimeout(() => { - if (!captureOnLoad) return; - captureSnapshot("Initial").catch((err) => { - console.warn(`[${EXTENSION_NAME}] Initial capture failed:`, err); - }); - }, INITIAL_CAPTURE_DELAY_MS); }, }); } else { diff --git a/package.json b/package.json new file mode 100644 index 0000000..c26df74 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml index f54405e..453e952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/snapshot_routes.py b/snapshot_routes.py index 4d81869..2ae3e8a 100644 --- a/snapshot_routes.py +++ b/snapshot_routes.py @@ -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: diff --git a/snapshot_storage.py b/snapshot_storage.py index a02b15b..9b6a441 100644 --- a/snapshot_storage.py +++ b/snapshot_storage.py @@ -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) diff --git a/tests/snapshot_diff.test.mjs b/tests/snapshot_diff.test.mjs new file mode 100644 index 0000000..a8c0014 --- /dev/null +++ b/tests/snapshot_diff.test.mjs @@ -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/); +}); diff --git a/tests/test_snapshot_storage.py b/tests/test_snapshot_storage.py new file mode 100644 index 0000000..8b708dd --- /dev/null +++ b/tests/test_snapshot_storage.py @@ -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()