Files
malkuthroandClaude Opus 5 090eb460eb fix(snapshot): boot drift check no longer false-positives on unsaved edits (#278)
* fix(snapshot): boot drift check no longer false-positives on unsaved edits (#276)

The #161 boot-time drift guard flagged any canonical difference between
the tracked snapshot file and the live /userdata+picks state — which is
also what ordinary "kept working after the last named save" looks like,
so nearly every session booted into a false "drifted" warning and
periodic autosaves were misrouted to _unsaved_autosave/.

detectBootDrift() is now three-way: a mismatch counts as drift only when
the live state's fingerprint also matches the persisted last-named-save
baseline (it claims to be saved, yet the file disagrees — pinning the
out-of-band change to the file side). Otherwise it reports the honest
"unsaved". First-session baseline seeding in koolook_sidebar.js moved
after the drift check so a freshly seeded baseline can't fabricate the
claims-saved condition.

Tests: two new cases (unsaved-edits, no-baseline); drift-expecting cases
now establish the claims-saved baseline; the states-match and
canonicalization tests baseline first and assert reason is absent so
they pin the match branch rather than the new early exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snapshot): gate first-session baseline seeding on a proven boot check (#276)

Review follow-up. Ordering the drift check before the seeding was not
enough: seeding still ran whenever the fingerprint key was absent, so a
session whose tracked-preset name outlived its baseline (upgrade from
before the key existed, or a persist that lost to localStorage quota)
baselined whatever the live state happened to be. That both showed
"saved" for state never saved to the named file and handed the NEXT boot
a live state claiming saved against a file it never matched — a fresh
false "drifted", i.e. #276 re-opened from the other side.

Seeding is now gated on bootCheckProvedAligned(), a pure predicate
exported from snapshot.js (the entry point imports ComfyUI's app.js and
can't be loaded headlessly — same split as extension_guard.js's
claimSidebarRegistration). Only a boot check that reached the
fingerprint comparison and matched authorizes seeding; null and
reason-carrying outcomes prove nothing.

Also: branch the !claimsSaved log so the no-baseline case stops claiming
"unsaved edits since the last named save" when there is no such save,
and refresh the _autosaveSubdir redirect rationale, which still framed
the live state as the corrupt side.

Tests: predicate truth table, an end-to-end blocking case carrying a
counterfactual leg that proves the gate is load-bearing (ungated seeding
does flag drift on the next boot), and the allow case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:23:49 +02:00

291 lines
15 KiB
JavaScript

// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
// SPDX-License-Identifier: GPL-3.0-only
// =============================================================================
// Koolook curated-nodes sidebar — entry point.
//
// This file boots the sidebar; module bodies live in `web/sidebar/`. ComfyUI
// discovers the extension via `WEB_DIRECTORY = "./web"` (set in __init__.py)
// and calls `app.registerExtension(...).setup()` once during frontend startup.
// =============================================================================
import { app } from "../../../scripts/app.js";
import {
TAB_ID,
TAB_TITLE,
TAB_TOOLTIP,
TAB_ICON,
WORKFLOWS_FALLBACK_KEY,
criticalToast,
toast,
} from "./sidebar/constants.js";
import {
seedStarterPresetIfNeeded,
startPeriodicAutosave,
markStateSaved,
getCurrentPresetName,
detectBootDrift,
bootCheckProvedAligned,
SNAPSHOT_KIND,
SNAPSHOT_VERSION,
writePreset,
} from "./sidebar/snapshot.js";
import {
loadWorkflowsStore,
seedWorkflowDefaultsIfNeeded,
clearOfflineFallback,
} from "./sidebar/workflows_store.js";
import { loadUserPicks } from "./sidebar/picks_store.js";
import { showConfirmModal } from "./sidebar/modals.js";
import { renderSidebar } from "./sidebar/tree.js";
import { patchCanvasMenu } from "./sidebar/canvas_menu.js";
import { claimSidebarRegistration } from "./sidebar/extension_guard.js";
function wireOfflineFallbackRecovery(initialBlob) {
criticalToast(
"Offline workflow data found in browser localStorage from a " +
"previous /userdata outage. If workflows you saved earlier are " +
"missing now, click Restore to save them as a snapshot — or " +
"Copy details for the raw JSON. Discard removes the offline " +
"copy permanently.",
{
copyText: initialBlob,
actions: [
{
label: "Restore as snapshot",
primary: true,
busyLabel: "Restoring…",
onClick: async () => {
// Re-read at click time — the user may have already
// discarded the blob from another tab; `null` means
// there's nothing left to restore and we should
// just dismiss.
const blob = localStorage.getItem(WORKFLOWS_FALLBACK_KEY);
if (!blob) {
toast("Offline copy was already cleared.");
return { dismiss: true };
}
let fallbackStore;
try {
fallbackStore = JSON.parse(blob);
} catch (e) {
console.error("[Koolook] offline fallback parse failed:", e);
toast("Offline copy is unparseable — copy the raw JSON instead.");
return { dismiss: false };
}
const isoNow = new Date().toISOString();
const tsForFile = isoNow.replace(/[:.]/g, "-");
const fileName = `recovered-${tsForFile}`;
// Wrap as a full snapshot envelope so the recovered
// file is a first-class snapshot the user can
// inspect / Load via the regular Snapshot UI. Picks
// come from the live store (NOT the offline blob —
// the offline blob only holds workflows) so that
// loading the recovered snapshot later wouldn't
// wipe whatever picks the user has accumulated
// since the outage.
const envelope = {
kind: SNAPSHOT_KIND,
version: SNAPSHOT_VERSION,
name: `Offline recovery · ${isoNow}`,
exportedAt: isoNow,
picks: loadUserPicks(),
workflows: fallbackStore,
};
try {
await writePreset(fileName, envelope);
} catch (e) {
console.error("[Koolook] offline restore write failed:", e);
toast(`Could not save recovery snapshot: ${e.message}`);
return { dismiss: false };
}
clearOfflineFallback();
toast(
`Saved offline copy as snapshot "${fileName}". ` +
`Open Snapshot → Load to inspect or restore.`,
5500
);
return { dismiss: true };
},
},
{
label: "Discard offline copy",
busyLabel: "Discarding…",
onClick: () => new Promise((resolve) => {
showConfirmModal({
title: "Discard offline copy?",
message:
"This permanently removes the localStorage " +
"fallback. Make sure you've already recovered " +
"anything you needed — once cleared, this " +
"data cannot be retrieved.",
confirmLabel: "Discard",
danger: true,
onConfirm: () => {
clearOfflineFallback();
toast("Offline copy discarded.");
resolve({ dismiss: true });
},
// Cancel resolves with `{ dismiss: false }` so the
// toast button re-enables and the user can Dismiss
// or Restore from the still-open recovery banner
// instead of being stuck in a "Discarding…" state.
onCancel: () => resolve({ dismiss: false }),
});
}),
},
],
}
);
}
// Client-side duplicate-install guard (#162). The backend `__init__.py`
// guard already prevents two Koolook installations from publishing
// competing `./web/` folders to ComfyUI (the non-winning install drops
// its `WEB_DIRECTORY`), so this path normally fires only once. But a
// downgraded sibling install on an older Koolook version without the
// backend guard, or a stale Manager-side cache that loads two
// `koolook_sidebar.js` files anyway, would land here twice and the
// second `app.registerExtension(...)` would silently shadow the first
// in some ComfyUI builds. The claim helper sets a window sentinel on
// first call so the second load skips registration entirely.
if (!claimSidebarRegistration(window)) {
console.warn(
"[Koolook] duplicate Kforge Labs install detected — this file was loaded twice. " +
"Check ComfyUI's custom_nodes/ for both `koolook/` and `ComfyUI-Koolook/` folders " +
"(or any other Koolook copy) and remove one. The first registration stays live."
);
// Defer the user-facing toast until the next microtask — `criticalToast`
// appends to `document.body`, which exists at script-eval time in ComfyUI
// but the surrounding style sheet (`ensureStyle()`) is installed inside
// `renderPanel`. Queuing the toast lets the user see it as soon as the
// sidebar tab opens, even on a duplicate install where the second
// `setup()` never runs.
queueMicrotask(() => {
try {
criticalToast(
"Kforge Labs is installed twice in ComfyUI's custom_nodes/. " +
"Two copies (typically `koolook/` from the ComfyUI Manager / Registry " +
"and `ComfyUI-Koolook/` from a git clone) both register the same " +
"sidebar tab and write to the same /userdata file — running both " +
"silently corrupts your workflow store. Remove one of the folders " +
"and restart ComfyUI. See the server log for the resolved paths."
);
} catch (e) {
// No usable DOM yet — the console.warn above already carries
// the essential signal. Don't escalate.
}
});
} else {
app.registerExtension({
name: "koolook.curated_sidebar",
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) {
console.warn("[Koolook] extensionManager.registerSidebarTab not available; sidebar not registered.");
return;
}
// Seeds the bundled starter preset into the user's snapshot library
// on first run (replaces the legacy `curated_defaults.json` localStorage
// pick-seed). Async-fires server I/O — kept off the critical path so
// sidebar tab registration isn't blocked behind it on a slow server.
seedStarterPresetIfNeeded();
const loadResult = await loadWorkflowsStore();
// Skip workflow seeding when /userdata file is corrupt — we don't want
// to overwrite a recoverable-but-unparseable file with stock defaults.
if (!loadResult.corrupt) {
await seedWorkflowDefaultsIfNeeded();
}
// Offline-fallback recovery toast — fires when /userdata loaded
// successfully but a stale localStorage blob from a past outage
// still exists. Two recovery actions:
// • Restore as snapshot — wraps the offline workflows into a
// full snapshot envelope (with current picks so a later Load
// wouldn't wipe them) and writes it as `recovered-<iso>.json`
// into the snapshot library. Clears the fallback key on
// success so the banner self-extinguishes.
// • Discard offline copy — confirm-then-clear. Same end state
// for the banner, but the user explicitly accepts data loss.
// Wired here (not in `loadWorkflowsStore`) because the action
// handlers need `writePreset`, `loadUserPicks`, and
// `showConfirmModal` — pulling those into workflows_store would
// create a circular import via snapshot.js / modals.js.
if (loadResult.fallbackBlob) {
wireOfflineFallbackRecovery(loadResult.fallbackBlob);
}
// Boot-time tracked-snapshot drift check (#161, narrowed by #276).
// When a preset is tracked, compare the named snapshot file on
// disk against the live /userdata + picks state. On mismatch —
// and only when the live state also matches the persisted
// last-named-save baseline, i.e. it *claims* to be saved — flag
// drift: the status pill flips to "drifted (reload?)" and
// periodic auto-saves redirect to `_unsaved_autosave/` so the
// named preset's recovery folder stays untouched until the user
// decides which side to keep. A mismatch without that claim is
// ordinary unsaved work and reports "unsaved".
//
// Runs BEFORE the first-session baseline seeding below (#276):
// seeding fabricates a baseline from the live state, which would
// make the live state trivially "claim saved" and re-open the
// false-positive this ordering closes. Ordering alone isn't
// enough though — see the seeding block for why its outcome is
// also gated on what this check proved.
//
// Failure here is silent (logged, not toasted): a missing file or
// unreachable server are distinct failure modes the user already
// has surface for elsewhere. Drift specifically means "both
// present, both differ".
const trackedNameForDrift = getCurrentPresetName();
let bootDriftOutcome = null;
if (trackedNameForDrift) {
try {
bootDriftOutcome = await detectBootDrift(trackedNameForDrift);
} catch (e) {
console.warn("[Koolook] boot drift check threw:", e);
}
}
// Baseline the saved-state fingerprint at session start IFF the
// boot check above PROVED the named file and live state agree AND
// we don't already have a baseline persisted from a previous
// session. The "saved fingerprint persists across reloads" path is
// more accurate, but a first session with no stored baseline needs
// this seeding to avoid showing "unsaved" immediately for users who
// closed the tab on a clean state. Without it the indicator would
// flicker "unsaved → saved" the first time the user clicks Save
// (since markStateSaved baselines there).
//
// The `bootCheckProvedAligned` gate is what makes the seeding safe
// (#276 review). Seeding on an UNPROVEN boot — no tracked file, an
// unreadable one, or a file that demonstrably differs — baselines
// whatever the live state happens to be, which both shows "saved"
// for state that was never saved to the named file and hands the
// next boot's drift check a live state that now "claims saved"
// against a file it never matched: a fresh false "drifted". That
// path is reachable in practice whenever the tracked-preset name
// outlives its fingerprint (upgrade from a build before the
// baseline key existed, or a persist that lost to localStorage
// quota — the same pressure `koolook_draft_guard.js` exists for).
// When the boot check proved nothing, "unsaved" is the honest pill.
if (bootCheckProvedAligned(bootDriftOutcome)
&& !localStorage.getItem("koolook.snapshot.savedFingerprint.v1")) {
markStateSaved();
}
app.extensionManager.registerSidebarTab({
id: TAB_ID,
title: TAB_TITLE,
tooltip: TAB_TOOLTIP,
icon: TAB_ICON,
type: "custom",
render: (el) => renderSidebar(el),
});
patchCanvasMenu();
// Periodic defensive auto-save — fires every 5 minutes if state has
// changed since the last successful auto-save. Keeps last 5 in the
// snapshot library as `_autosave_periodic_<iso>.json`. Started AFTER
// sidebar registration so it doesn't compete with first-render work,
// and uses an internal grace period before the first tick so the
// load/seed flows above settle first.
startPeriodicAutosave();
},
});
}