* feat(sidebar): snapshot library — named presets for picks + workflows
Implements #46 item 1 (custom save location + cross-machine portability)
as a preset library: each preset is one named JSON file under
/userdata/koolook-presets/, capturing the full Kforge Labs state.
Use cases addressed
-------------------
- **Personal portability:** copy `koolook-presets/` between machines, or
sync ComfyUI's userdata folder via Dropbox/iCloud/Drive — your kit
follows.
- **Team / facility shared library:** multiple workstations on the same
ComfyUI server share `/userdata/koolook-presets/` natively. One person
publishes "facility kit", everyone else loads it.
- **One-off transfer (different servers):** Manage tab's Download button
saves a snapshot to local disk; Upload imports it into another machine's
library.
UI
--
New top-level **Snapshot** action row above the search field, mirroring
the Nodes/Workflows action-row pattern. Three icon buttons (Load / Save /
Manage), each opens the same modal at a different starting tab.
- Load tab: lists every preset with metadata (workflow count · pick
count · export date). Click a row → "Replace current state?" confirm
→ restores both picks and workflows.
- Save tab: input field defaults to `preset YYYY-MM-DD`, fully editable.
Existing-name save prompts to overwrite.
- Manage tab: per-row Download / Delete buttons + a top-of-tab Upload
action (file picker imports a JSON snapshot from disk into the library).
Schema
------
```json
{
"kind": "koolook-snapshot",
"version": 1,
"name": "Wan video kit",
"exportedAt": "2026-05-05T08:42:13.000Z",
"picks": [...],
"workflows": { "directories": {...} }
}
```
`kind` + `version` for future schema migrations. Anything without
`kind === "koolook-snapshot"` is rejected on import.
Module shape
------------
- `web/sidebar/snapshot.js` (new) — server I/O on the preset directory,
schema validation, gather/apply, download/upload helpers. UI-free.
- `web/sidebar/modals.js` — new `showSnapshotModal({initialTab, ...})`
that takes capability functions instead of importing snapshot.js
directly, so the UI module stays UI-only.
- `web/sidebar/workflows_store.js` — new `replaceAllWorkflows(rawStore)`
for bulk-replace (bypasses persistMutation since the entire cache is
being swapped) + `getAllWorkflowsForExport()` for the gather flow.
- `web/sidebar/picks_store.js` — new `setAllPicks(picks)` exported
helper.
- `web/sidebar/tree.js` — new top action row + 3 toolbar buttons +
one wiring helper.
- `web/sidebar/constants.js` — CSS for tab strip, list rows, manage
buttons.
Deliberate scope cuts (per the design discussion)
-------------------------------------------------
- No continuous sync. Each save is a deliberate act.
- No conflict resolution. Same preset name written from two machines
on the same server = last-write-wins. Documented as a known limit;
revisit when it bites.
- No selective import (preset = full state). Merge can be a v2.
- No custom userdata path config. The `koolook-presets/` location is
fixed; portability comes from the snapshot files themselves, not
from rerouting storage.
Verification
------------
- `node --input-type=module --check` on all modified + new JS files —
clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac for browser smoke-test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sidebar): snapshot library now uses configurable filesystem path
Reworks PR #67 (which served snapshots from /userdata/koolook-presets/)
to expose the library through Koolook's own server-side endpoints,
making the storage location configurable via an env var. The userdata
namespace is per-install — fine for personal use, useless for the
"facility shared library" use case the user explicitly called out.
Architectural change
--------------------
- The client no longer talks to ComfyUI's `/userdata/*` API for snapshot
files. New JS-side endpoints: `/koolook/presets/{info,list,file}`.
- Server side: new `koolook_routes.py` registers aiohttp routes on
ComfyUI's PromptServer at custom-node load time. Path-traversal
protection at the route boundary via a strict filename whitelist
regex (`^[A-Za-z0-9 _.()\-]+\.json$`).
- Storage location is read from the `KFORGELABS_PRESETS` env var on
every request (so re-launching ComfyUI with a new value picks it
up without any cache fuss). Default fallback:
`<comfyui-userdata>/koolook-presets/`.
- Auto-creates the configured directory on first save (parent must
already exist — for NFS/SMB mounts the mount must be present).
Use cases this enables vs. PR #67
---------------------------------
| Use case | PR #67 | This PR |
|-------------------------|--------|---------|
| Personal, single-machine | ✓ | ✓ |
| Personal cross-machine | symlink-or-sync userdata | point env var at Dropbox folder |
| NFS/SMB facility share | ✗ (locked to userdata) | ✓ point env var at mount |
| Multiple parallel libs | ✗ | per-instance env var |
| Read-only distribution | ✗ | ✓ (server detects, save fails cleanly) |
UI difference vs. PR #67
------------------------
- The Manage tab gains a small `i` icon at the right edge of the
upload row. Hover tooltip shows the current library path, source
(default vs `KFORGELABS_PRESETS` override), and writability status.
No header line, no extra UI weight.
Files added/changed
-------------------
- `koolook_routes.py` (new, ~180 lines) — aiohttp routes + env-var
config + filename validation. Loaded by `__init__.py` at startup;
failure is non-fatal (the rest of Koolook still works, just the
snapshot feature is unavailable).
- `__init__.py` — registers `koolook_routes` import-time side-effect.
- `scripts/sync_to_dev.py` — `koolook_routes.py` added to
`RUNTIME_PATHS` so dev-sync includes it.
- `web/sidebar/snapshot.js` — server I/O section rewritten to call
`/koolook/presets/*` instead of `/userdata/koolook-presets/*`. Drops
the URL-encoding workaround (we own the route now, so the
`name=<file>` query param is clean and the server validates).
- `web/sidebar/modals.js` — `showSnapshotModal` accepts a
`getLibraryInfo` capability function; the Manage tab calls it and
populates the info-icon tooltip.
- `web/sidebar/tree.js` — passes `getLibraryInfo` from `snapshot.js`
to the modal.
- `.env.example` — new `KFORGELABS_PRESETS` block with macOS / Linux /
Windows examples for personal-sync, NFS facility share, and SMB
facility share patterns.
- `CHANGELOG.md` — Added entry rewritten for the env-var design;
Internal entry on the new server-side module.
Why a separate v2 PR (instead of patching #67)
----------------------------------------------
The userdata-namespace design in #67 was the wrong architecture for
what the user actually wanted. Patching it to the env-var design
would have meant a near-total rewrite of #67's server I/O paths
mid-review. Cleaner: branch fresh from main, cherry-pick #67's UI
+ stores work as a base, layer the new endpoint design on top.
PR #67 will be closed as superseded once this lands.
Verification
------------
- `node --input-type=module --check` on every modified JS file —
clean.
- `python -c "import ast; ast.parse(...)"` on `koolook_routes.py`
+ `__init__.py` — clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac. User to smoke-test against a real
ComfyUI session (the previous URL-encoding 405 should be gone
since the new route handlers own the URL space).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sidebar): redesign snapshot UX — single-click Save, real Settings panel
User feedback on #68's first cut: tabbed modal is too complicated, can't
see existing presets in the Save flow, the env var alone isn't a real
settings UI, the default-path text was stale (still claimed
`/userdata/koolook-presets/` after the configurable-path landed).
UX changes
----------
- **Save button → one click.** If a preset is currently loaded
(tracked in localStorage as `koolook.snapshot.currentPresetName.v1`,
survives reloads), prompts "Save over '<name>'?" with three options:
Save (overwrite) / Save as new… (rename) / Cancel. If nothing is
loaded yet, prompts for a name with `preset YYYY-MM-DD` default,
fully editable.
- **Load → single dialog.** Preset list with per-row × Delete button.
Header line shows the current library path so the user can see where
they're loading from. No tabs.
- **Cog → real Settings panel.** Single field for the library path
(text input, absolute path). Save / Reset to default / Close. Read-
only line shows the currently-resolved path + source label
(settings panel / env var / built-in default). The "Manage" tab and
its Download / Upload sub-actions are gone — the user can already
reach the file on disk after a save, no in-app file ops needed.
Server side
-----------
- New `GET /koolook/presets/settings` returns the saved library path +
resolved path + source.
- New `POST /koolook/presets/settings` writes the saved library path
to `<comfyui-userdata>/koolook-settings.json`. Empty value clears
the override.
- Resolution chain in `_configured_dir()`:
settings file > KFORGELABS_PRESETS env var > built-in default
Reads each on every call; no module-load caching.
- `info` endpoint gains a `source` field ("settings" | "env" | "default")
the UI uses for the readout line.
Client side
-----------
- `showSnapshotModal` (tabbed) replaced by three exports:
showSaveSnapshotDialog, showLoadSnapshotDialog,
showSnapshotSettingsDialog.
Each is self-contained, no shared state.
- `snapshot.js` adds `getSettings`/`saveSettings` for the Settings
dialog and `getCurrentPresetName`/`setCurrentPresetName` for the
one-click Save tracking. Settings ride on the new server endpoints;
current-preset tracking is browser-local.
- `applySnapshot` now also updates the current-preset tracker (via
the dialog wrapper, not the function itself) on successful load.
- `tree.js` toolbar wires three direct callbacks instead of one
`openSnapshotModal(initialTab)` helper.
Stale text fix
--------------
- The Save dialog's hint text in #68's first cut hardcoded
"/userdata/koolook-presets/<name>.json" — stale from the userdata-
era PR #67. The redesigned Save flow doesn't show that text at all
(the path is no longer relevant to the user-typed name); the Load
dialog's header line and the Settings dialog's read-only field are
the canonical surfaces for "where my presets actually live."
Deliberately out of scope
-------------------------
- No per-save path picker. The library is one location at a time, set
in advance via Settings or env var. Saving to ad-hoc locations would
fragment the library and break the "facility shared library" use case.
- No in-app Download / Upload (was in #68's first cut). Files are on
disk at the configured path; the OS file manager handles transfer.
Verification
------------
- `node --input-type=module --check` on every modified JS file —
clean.
- `python -c "import ast; ast.parse(...)"` on `koolook_routes.py` —
clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- Synced to live install on Mac. User to smoke-test against a real
ComfyUI session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sidebar): address PR #68 review — atomicity, security, error UX
Multi-agent review surfaced 2 BLOCKING + 5 HIGH + 4 MEDIUM + 3 LOW.
Validated the BLOCKINGs against the actual code (both real) and
applied the bundle of fixes that hold up. Modal-size HIGH (#7 file
size) deferred to a follow-up — that's a wider refactor.
BLOCKING fixes
--------------
- **`applySnapshot` partial-failure tracker gating** (modals.js Load
dialog). `setCurrentPresetName(...)` was called unconditionally
before checking `{picksOk, workflowsOk}` from the apply. Partial
apply (one of picks/workflows persisted, the other didn't) left
the tracker pointing at the preset, so the next Save would offer
"Save over '<name>'?" and silently overwrite the on-disk preset
with the corrupted half-state. Now: tracker is set only on
`picksOk && workflowsOk`; partial failure clears it (and the toast
tells the user "Reload to recover prior state. Tracker cleared.").
- **`replaceAllWorkflows` non-atomic rebind** (workflows_store.js).
Rebinds `workflowsCache` before awaiting persist, with no rollback.
Now: snapshots the cache via the existing `snapshotCache()`
primitive that `persistMutation` uses; on persist failure, restore
the prior cache and return false. Load is fully atomic.
HIGH fixes
----------
- **Workflows-store invariant block updated.** Lists `replaceAllWorkflows`
as the fourth legitimate rebind site (with rollback semantics).
"Fifth rebind" warning bumped to "sixth."
- **Explicit HEAD route on `/koolook/presets/file`** that just does
`is_file()` + status, no body work. The auto-synthesis path was
reading the full file from disk just to discard it — wasteful on
multi-MB snapshots over Dropbox/iCloud/NFS.
- **Client `sanitizeName` mirrors server whitelist regex**
(`^[A-Za-z0-9 _.()\-]+$`). Characters outside the whitelist (`+`,
`&`, `'`, accented letters, emoji, etc.) collapse to `_` rather
than triggering an opaque HTTP 400 at save time.
- **Error-reason surfacing via `await resp.text()`.** RFC 7540 strips
HTTP/2 reason phrases; behind any HTTP/2-terminating proxy
`resp.statusText` is empty. Reading the body always carries
aiohttp's `reason=` text. Used by writePreset, deletePreset,
saveSettings, readPreset.
MEDIUM fixes
------------
- **Symlink follow-through hardening.** `_resolve_within_base` helper
computes `(base / name).resolve()` and asserts it's still under
`base.resolve()`. Defends against a symlink planted in a facility-
shared library dir redirecting reads/writes off-share.
- **Atomic settings write.** `_write_settings` writes to `<tmp>` then
`os.replace`, so an interrupted process can't truncate the file
and silently revert the user's saved library path on next load.
- **`__init__.py` import wrapped in try/except.** The "non-fatal"
comment promised that an import failure wouldn't break node
registration; previously only `install()` returning False was
guarded. Now any module-load exception is caught and logged with
the `[Koolook]` prefix, and the rest of the plugin loads.
LOW housekeeping
----------------
- Dropped dead exports `downloadSnapshotAsFile` + `readSnapshotFile`
(left over from an earlier iteration that had Download/Upload UI).
- Dropped 5 dead CSS rules (`.koolook-snapshot-tabs*`,
`.koolook-snapshot-tab-pane`) from when the modal was tabbed.
- `currentPresetName` cleared when the user deletes the currently-
loaded preset (Load dialog's promptDelete now checks via the new
`getCurrentPresetName` capability).
- `loadPreview` warns to console on `kind` mismatch — non-snapshot
JSON files in the library directory are now visible in DevTools
rather than silently filtered.
- `presetExists` is tri-state (`true` / `false` / `null`) — the
Save flow refuses to write when the library is unreachable rather
than silently skipping the overwrite-confirm prompt.
Deferred
--------
- modals.js size (1080 lines) — extracting the snapshot dialogs into
their own file is a follow-up refactor. Not blocking merge.
- Doc drift on maintainer docs / README env-var convention — separate
documentation sweep.
Verification
------------
- `python -c "import ast; ast.parse(...)"` on all .py files — clean.
- `node --input-type=module --check` on every modified JS file —
clean.
- `python tools/preflight_release.py --skip manager-meta -v` PASS.
- `grep -rn "downloadSnapshotAsFile\\|readSnapshotFile\\|koolook-snapshot-tab" web/`
— zero hits (dead code gone).
- Synced to live install for browser smoke-test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: nudge — re-trigger CI after webhook miss on d6d2bf8
The CI workflow didn't fire on the prior push (Socket Security ran but
GitHub Actions Lint/Smoke/Pre-flight/Security Gate didn't). Empty
commit to refire the pull_request webhook.
* fix(routes): drop unnecessary f-string prefixes flagged by ruff (F541)
Two adjacent string literals in the symlink-escape error message had
an `f` prefix despite containing no placeholders. CI's ruff check
fails on F541 — strip the prefixes so the lint gate passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>