From 96d4b51d7ba5edd71ef34752367e247a36a8e4ce Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Mon, 18 May 2026 00:54:49 +0200 Subject: [PATCH] Release v0.3.5 (#160) * Release v0.3.5 prep * fix(sidebar): bypass stale browser cache for snapshot state * test(sidebar): guard no-store snapshot fetches --- .gitignore | 1 + CHANGELOG.md | 34 ++++++++++ pyproject.toml | 2 +- scripts/bootstrap_test_env.sh | 14 +++- tests/sidebar/test_no_store_fetches.py | 50 ++++++++++++++ .../test_workflows_store_persistence.py | 66 +++++++++++++++++++ web/sidebar/constants.js | 5 ++ web/sidebar/snapshot.js | 30 ++++++--- web/sidebar/workflows_store.js | 5 +- 9 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 tests/sidebar/test_no_store_fetches.py diff --git a/.gitignore b/.gitignore index e60f1ed..2f0689f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ PLAN.md __pycache__/ *.pyc *.pyo +*.egg-info/ .DS_Store .pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c066bd0..249de56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is inspired by Keep a Changelog and SemVer. ## [Unreleased] +## [0.3.5] - 2026-05-18 + ### Added - **Easy Video Combine (`Easy_VideoCombine`).** A path-aware sibling of Kosinkadink/ComfyUI-VideoHelperSuite's `Video Combine` node. @@ -56,6 +58,21 @@ The format is inspired by Keep a Changelog and SemVer. and a subfolder, that `disable_versioning` drops the `vNNN` segment everywhere, that `enable_overwrite` only blocks an existing *file* — not an existing directory). +- **AI-assisted automation workflow framework.** Adds + [`docs/automations/`](docs/automations/) as the canonical home for + repeatable image/video iteration loops, with shared conventions, + a quick-reference cheatsheet, an LTX 2.3 model-specific workflow, + handoff checklist, promoted findings, and the companion + [`docs/investigations/ltx-director-4k-transitions.md`](docs/investigations/ltx-director-4k-transitions.md) + investigation. The new [`scripts/make_card.py`](scripts/make_card.py) + and [`scripts/watch_cards.py`](scripts/watch_cards.py) helpers generate + and monitor compact review cards from ComfyUI run metadata so experiment + results can be compared across runs without re-reading full workflow JSON. +- **Project-local test bootstrap helpers.** Adds macOS/Linux and Windows + scripts for creating the local test environment, plus `pip-audit` in the + optional `test` dependency set and a repository line-ending policy. The + POSIX helper accepts `PYTHON=...` and falls back to `python3` when `python` + is not available on `PATH`. ### Changed - **Workflow right-click menu shortened for large libraries.** The menu no @@ -73,6 +90,15 @@ The format is inspired by Keep a Changelog and SemVer. preview shown by the `Get output file path` button now matches. ### Fixed +- **Kforge Labs snapshot/workflow restore no longer hydrates from stale browser cache.** + The sidebar's startup read of `/userdata/koolook_workflows.json` now uses + a cache-busted `no-store` request, and snapshot-library reads do the same + for mutable preset/list/settings endpoints. This fixes the severe case + where the browser returned an older 5-workflow `/userdata` response even + though the on-disk file and a cache-busted fetch contained the correct + 59-workflow snapshot state; the status pill could appear to track the + right snapshot while the workflow tree rendered stale data and periodic + autosave captured that bad state. - **Easy AI Pipeline: trailing-slash on base path no longer creates a phantom `undefined/` folder.** Typing `n:\foo\bar\` (trailing backslash) into `base_directory_path` used to leak a trailing `/` onto @@ -175,6 +201,14 @@ The format is inspired by Keep a Changelog and SemVer. `easy getNode` back to its matching `easy setNode` input before reading the source widget value, so global path/name settings preview the same value that render-time execution receives instead of the GET key name. +- **Workflow sidebar persistence recovery.** Workflow library saves now + preserve the full workflow payload shape and recover existing user data + more defensively, with tests covering payload migration and store + persistence. +- **Loaded workflows stay untitled in ComfyUI.** Loading from the Kforge + Labs workflow sidebar no longer forces a saved workflow name into Comfy's + tab/title state, keeping inserted or loaded workflow sessions aligned with + ComfyUI's native untitled-workflow behavior. ## [0.3.2] - 2026-05-16 diff --git a/pyproject.toml b/pyproject.toml index e29e898..d701370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "koolook" description = "This repository provides custom nodes for ComfyUI tailored for VFX and AI image/video generation workflows" -version = "0.3.2" +version = "0.3.5" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/scripts/bootstrap_test_env.sh b/scripts/bootstrap_test_env.sh index 66c2706..41d7b0f 100755 --- a/scripts/bootstrap_test_env.sh +++ b/scripts/bootstrap_test_env.sh @@ -11,6 +11,18 @@ if [ "${1:-}" = "--force" ]; then FORCE="1" fi +PYTHON_BIN="${PYTHON:-}" +if [ -z "$PYTHON_BIN" ]; then + if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="python3" + elif command -v python >/dev/null 2>&1; then + PYTHON_BIN="python" + else + echo "Could not find python3 or python on PATH." >&2 + exit 1 + fi +fi + if [ -d .venv ]; then if [ -z "$FORCE" ]; then echo ".venv already exists. Pass --force to recreate." @@ -21,7 +33,7 @@ if [ -d .venv ]; then fi echo "Creating .venv ..." -python -m venv .venv +"$PYTHON_BIN" -m venv .venv echo "Upgrading pip ..." .venv/bin/python -m pip install --quiet --upgrade pip diff --git a/tests/sidebar/test_no_store_fetches.py b/tests/sidebar/test_no_store_fetches.py new file mode 100644 index 0000000..b8d87d8 --- /dev/null +++ b/tests/sidebar/test_no_store_fetches.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SNAPSHOT_JS = REPO_ROOT / "web" / "sidebar" / "snapshot.js" +WORKFLOWS_STORE_JS = REPO_ROOT / "web" / "sidebar" / "workflows_store.js" +CONSTANTS_JS = REPO_ROOT / "web" / "sidebar" / "constants.js" + + +def test_cache_bust_helper_is_shared() -> None: + constants = CONSTANTS_JS.read_text(encoding="utf-8") + snapshot = SNAPSHOT_JS.read_text(encoding="utf-8") + workflows = WORKFLOWS_STORE_JS.read_text(encoding="utf-8") + + assert "export function noStoreUrl(" in constants + assert "function noStoreUrl(" not in snapshot + assert "function noStoreUrl(" not in workflows + assert "noStoreUrl," in snapshot + assert "noStoreUrl," in workflows + + +def test_mutable_snapshot_reads_bypass_browser_cache() -> None: + text = SNAPSHOT_JS.read_text(encoding="utf-8") + + mutable_read_patterns = [ + "fetch(\n noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(fullName)}${dirParam}`),\n { cache: \"no-store\" }\n )", + "fetch(noStoreUrl(ROUTE_INFO), { cache: \"no-store\" })", + "fetch(noStoreUrl(ROUTE_SETTINGS), { cache: \"no-store\" })", + "fetch(noStoreUrl(`${ROUTE_BROWSE}${q ? `?${q}` : \"\"}`), {\n cache: \"no-store\",\n })", + "fetch(noStoreUrl(url), { cache: \"no-store\" })", + "fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), {\n cache: \"no-store\",\n })", + "fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), {\n method: \"HEAD\",\n cache: \"no-store\",\n })", + "fetch(noStoreUrl(ROUTE_AUTOSAVES_LIST), { cache: \"no-store\" })", + "fetch(\n noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(row.name)}${dirParam}`),\n { cache: \"no-store\" }\n )", + ] + + for pattern in mutable_read_patterns: + assert pattern in text + + +def test_workflow_startup_read_bypasses_browser_cache_in_source() -> None: + text = WORKFLOWS_STORE_JS.read_text(encoding="utf-8") + + assert ( + "fetch(noStoreUrl(`/userdata/${WORKFLOWS_USERDATA_PATH}`), {\n" + " cache: \"no-store\",\n" + " })" + ) in text diff --git a/tests/sidebar/test_workflows_store_persistence.py b/tests/sidebar/test_workflows_store_persistence.py index 078c151..9dcb313 100644 --- a/tests/sidebar/test_workflows_store_persistence.py +++ b/tests/sidebar/test_workflows_store_persistence.py @@ -122,6 +122,72 @@ def test_workflow_save_survives_reload_when_server_write_succeeds() -> None: assert result.returncode == 0, result.stderr +def test_workflow_startup_read_bypasses_browser_cache() -> None: + script = textwrap.dedent( + """ + import assert from "node:assert/strict"; + import { loadWorkflowsStore } from "./web/sidebar/workflows_store.js"; + + setupBrowserStubs(); + + const serverStore = { directories: {} }; + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), cache: init.cache || null }); + if (String(url).startsWith("/userdata/koolook_workflows.json")) { + return okResponse(JSON.stringify(serverStore)); + } + return { ok: false, status: 404, text: async () => "", json: async () => ({}) }; + }; + + await loadWorkflowsStore(); + + assert.equal(calls.length, 1); + assert.match(calls[0].url, /^\\/userdata\\/koolook_workflows\\.json\\?_/); + assert.equal(calls[0].cache, "no-store"); + + function okResponse(body) { + return { ok: true, status: 200, text: async () => body, json: async () => JSON.parse(body || "{}") }; + } + + function setupBrowserStubs() { + const storage = new Map(); + globalThis.localStorage = { + getItem: (key) => storage.has(key) ? storage.get(key) : null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), + }; + globalThis.CustomEvent = class CustomEvent { constructor(type) { this.type = type; } }; + globalThis.window = { dispatchEvent() {} }; + Object.defineProperty(globalThis, "navigator", { + value: {}, + configurable: true, + }); + const makeEl = () => ({ + className: "", + textContent: "", + style: {}, + classList: { add() {}, remove() {} }, + appendChild() {}, + remove() {}, + addEventListener() {}, + setAttribute() {}, + }); + globalThis.document = { + createElement: makeEl, + body: { appendChild() {} }, + head: { appendChild() {} }, + querySelectorAll: () => [], + getElementById: () => null, + }; + } + """ + ) + + result = run_node_scenario(script) + assert result.returncode == 0, result.stderr + + def test_fallback_only_workflow_save_becomes_live_again_after_reload() -> None: script = textwrap.dedent( """ diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js index 88864b3..f0db200 100644 --- a/web/sidebar/constants.js +++ b/web/sidebar/constants.js @@ -81,6 +81,11 @@ export const WORKFLOWS_CHANGED_EVENT = "koolook-workflows-changed"; export const WORKFLOWS_DEFAULTS_URL = new URL("../workflow_defaults.json", import.meta.url).href; export const GUIDE_URL = new URL("../guide/index.html", import.meta.url).href; +export function noStoreUrl(url) { + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}_=${Date.now()}`; +} + // Convention-driven "module" classification — a saved workflow tagged with // this literal string is treated as a reusable building block (insert into // the existing canvas) instead of a full session (replace the canvas). diff --git a/web/sidebar/snapshot.js b/web/sidebar/snapshot.js index 962af22..b908c9c 100644 --- a/web/sidebar/snapshot.js +++ b/web/sidebar/snapshot.js @@ -42,6 +42,7 @@ import { STARTER_URL, STARTER_PRESET_FILENAME, SNAPSHOT_STATUS_CHANGED_EVENT, + noStoreUrl, toast, } from "./constants.js"; @@ -656,7 +657,10 @@ async function loadPreview(fullName, dir, rowMeta) { try { const bareName = fullName.replace(/\.json$/i, ""); const dirParam = dir ? `&dir=${encodeURIComponent(dir)}` : ""; - const resp = await fetch(`${ROUTE_FILE}?name=${encodeURIComponent(fullName)}${dirParam}`); + const resp = await fetch( + noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(fullName)}${dirParam}`), + { cache: "no-store" } + ); if (!resp.ok) return null; const text = await resp.text(); const obj = JSON.parse(text); @@ -708,7 +712,7 @@ async function loadPreview(fullName, dir, rowMeta) { // reachable — caller falls back to a generic message. export async function getLibraryInfo() { try { - const resp = await fetch(ROUTE_INFO); + const resp = await fetch(noStoreUrl(ROUTE_INFO), { cache: "no-store" }); if (!resp.ok) return null; return await resp.json(); } catch (e) { @@ -723,7 +727,7 @@ export async function getLibraryInfo() { // "currently in effect" readout, and `source` to label the chain. export async function getSettings() { try { - const resp = await fetch(ROUTE_SETTINGS); + const resp = await fetch(noStoreUrl(ROUTE_SETTINGS), { cache: "no-store" }); if (!resp.ok) return null; return await resp.json(); } catch (e) { @@ -759,7 +763,9 @@ export async function browseDirectories(path, { files = false } = {}) { if (path) params.set("path", path); if (files) params.set("files", "1"); const q = params.toString(); - const resp = await fetch(`${ROUTE_BROWSE}${q ? `?${q}` : ""}`); + const resp = await fetch(noStoreUrl(`${ROUTE_BROWSE}${q ? `?${q}` : ""}`), { + cache: "no-store", + }); if (!resp.ok) { throw new Error(`Could not browse directories: ${await readErrorReason(resp)}.`); } @@ -790,7 +796,7 @@ export async function listPresets({ dir } = {}) { let resp; const url = dir ? `${ROUTE_LIST}?dir=${encodeURIComponent(dir)}` : ROUTE_LIST; try { - resp = await fetch(url); + resp = await fetch(noStoreUrl(url), { cache: "no-store" }); } catch (e) { console.warn("[Koolook] preset listing failed (network):", e); return []; @@ -820,7 +826,9 @@ export async function listPresets({ dir } = {}) { // scopes the read to an autosave subfolder. Returns the parsed + // validated snapshot, throws on read or parse failure. export async function readPreset(fileName, { dir } = {}) { - const resp = await fetch(`${ROUTE_FILE}${fileQuery(fileName, dir)}`); + const resp = await fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), { + cache: "no-store", + }); if (!resp.ok) { throw new Error(`Could not read preset "${fileName}": ${await readErrorReason(resp)}.`); } @@ -863,7 +871,10 @@ export async function writePreset(fileName, snapshot, { dir } = {}) { // so this is a cheap probe even on multi-MB snapshots over Dropbox/NFS. export async function presetExists(fileName, { dir } = {}) { try { - const resp = await fetch(`${ROUTE_FILE}${fileQuery(fileName, dir)}`, { method: "HEAD" }); + const resp = await fetch(noStoreUrl(`${ROUTE_FILE}${fileQuery(fileName, dir)}`), { + method: "HEAD", + cache: "no-store", + }); if (resp.ok) return true; if (resp.status === 404) return false; // 5xx, 4xx other than 404, opaque proxy errors → unreachable. @@ -901,7 +912,7 @@ export async function deletePreset(fileName, { dir } = {}) { export async function listAutosaves() { let resp; try { - resp = await fetch(ROUTE_AUTOSAVES_LIST); + resp = await fetch(noStoreUrl(ROUTE_AUTOSAVES_LIST), { cache: "no-store" }); } catch (e) { console.warn("[Koolook] autosave listing failed (network):", e); return []; @@ -924,7 +935,8 @@ export async function listAutosaves() { const bareName = row.name.replace(/\.json$/i, ""); const dirParam = `&dir=${encodeURIComponent(row.dir)}`; const r = await fetch( - `${ROUTE_FILE}?name=${encodeURIComponent(row.name)}${dirParam}` + noStoreUrl(`${ROUTE_FILE}?name=${encodeURIComponent(row.name)}${dirParam}`), + { cache: "no-store" } ); if (!r.ok) return null; const obj = JSON.parse(await r.text()); diff --git a/web/sidebar/workflows_store.js b/web/sidebar/workflows_store.js index e47c9fb..472084d 100644 --- a/web/sidebar/workflows_store.js +++ b/web/sidebar/workflows_store.js @@ -30,6 +30,7 @@ import { WORKFLOWS_DEFAULTS_URL, MODULE_TAG, compareNames, + noStoreUrl, toast, criticalToast, } from "./constants.js"; @@ -195,7 +196,9 @@ const SERVER_FILE_CORRUPT = Symbol("workflows-server-corrupt"); async function fetchWorkflowsFromServer() { let resp; try { - resp = await fetch(`/userdata/${WORKFLOWS_USERDATA_PATH}`); + resp = await fetch(noStoreUrl(`/userdata/${WORKFLOWS_USERDATA_PATH}`), { + cache: "no-store", + }); } catch (e) { console.warn("[Koolook] /userdata read failed (network):", e); return undefined;