* fix(web): generation-agnostic Comfy draft-quota guard "Failed to save workflow draft" toasts returned on ComfyUI frontend 1.44+ because draft storage moved to new localStorage key names (per-workspace V1 keys, then a V2 index+payload scheme) that the old exact-name guard embedded in ltx_director.js never matched - and the 1.44 V2 layer latches a session-wide storage-unavailable flag after one failed save+evict cycle, re-toasting on every edit until reload. Move the guard to its own global extension, web/koolook_draft_guard.js: - match draft keys by prefix (Comfy.Workflow.Draft) across all known generations, with unknown future keys as last-resort evictions - rescue quota failures inside localStorage.setItem (evict the oldest draft across generations, retry) before Comfy's own catch sees the error, so the save succeeds and the latch never trips - boot prune: drop the suffixed-V1 dead weight the frontend's V1->V2 migration never deletes, scope corrupt-key cleanup to the offending key, drop oversized entries, enforce a total draft budget - ship the guard with dev-sync-audio so a scoped sync cannot strand a dev install guard-less Behavior is covered by tests/js/test_draft_guard.mjs (12 scenarios, wired into CI next to the existing node smoke test); static integration contracts live in tests/sidebar/test_draft_guard.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): address deep-review findings on the draft guard - latch the "still full" hint toast once per session so a genuinely full origin cannot re-toast on repeated failing autosave attempts; regression assertion added to the exhaustion scenario - document the bounded-eviction termination contract and the post-rethrow storage-unavailable latch at the wrapper, plus the budget's V1-blob-only (1.5M) -> all-generations (2M) scope widening (also now stated in the CHANGELOG entry) - version-robust wording for the suffixed-V1 cleanup rationale: the shipped 1.44 migration leaves the keys behind (verified against the installed 1.44.19 dist - no removeItem exists there), later frontends clean up on successful migration, and an interrupted migration can leak the keys on any version - comment the intentional best-effort swallow in the toast helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
227 lines
15 KiB
Markdown
227 lines
15 KiB
Markdown
# Workflows sidebar — quick reference
|
|
|
|
The "Workflows" section of the Kforge Labs sidebar tab; implementation
|
|
lives in [`web/sidebar/`](../../web/sidebar/), with the workflow store + ops
|
|
in [`workflows_store.js`](../../web/sidebar/workflows_store.js) and the tree
|
|
rendering in [`tree.js`](../../web/sidebar/tree.js).
|
|
|
|
## Save / load loop
|
|
|
|
1. Click **💾** (whole canvas) or **✂** (selection only) in the Workflows action bar.
|
|
2. Modal: **Directory** (flat path picker) → **Base on existing** (optional) → **Action** → **Workflow name** (shown only when needed).
|
|
3. Saved entry appears at the bottom of its directory in the tree.
|
|
4. Click any workflow row to load it onto a fresh tab; the tab title takes the workflow's name.
|
|
|
|
The directory dropdown lists every directory path in the tree as a flat
|
|
list (`UP-scale`, `UP-scale / Type-A`, `Depth`, …). The "+ New directory…"
|
|
option creates a new top-level directory. Subdirectories are created via
|
|
right-click on an existing directory (see below).
|
|
|
|
## Action semantics
|
|
|
|
| Action | Resulting name | Archive behavior |
|
|
|---|---|---|
|
|
| New name | typed | auto-archives only if the typed name happens to collide |
|
|
| Use existing name (archive previous) | base | always archives the existing entry |
|
|
| Modify existing name | base, edited | no archive (different name) |
|
|
|
|
The archive trigger is a name collision with any existing active entry,
|
|
regardless of which action was selected. The Action dropdown is UX
|
|
guidance; the underlying logic flows from the resulting `(name, dir)` pair.
|
|
|
|
## Modules — splice a saved cluster into your live canvas
|
|
|
|
A saved workflow tagged with the literal tag **`module`** behaves like a
|
|
reusable building block: clicking it **inserts** its nodes into the existing
|
|
canvas (placed at the viewport center, freshly id-remapped, internal links
|
|
re-created, the inserted nodes left selected) instead of replacing the
|
|
graph the way a normal Load does.
|
|
|
|
Typical use cases this is designed for:
|
|
|
|
- **EXR output stack** — a Save EXR + path-builder + format selector cluster
|
|
the user wants to drop into any render-time workflow.
|
|
- **Depth-only render** — a depth-map node + preview wired up, ready to be
|
|
spliced into a wider pipeline.
|
|
- **Wan 2.2 prompt module** — the `EasyWan22Prompt` node + a CLIP encoder
|
|
+ any conditioning glue, saved once, dropped into ten different
|
|
workflows.
|
|
|
|
How it works:
|
|
|
|
| Surface | Behavior |
|
|
|---|---|
|
|
| **Save modal** (selection save) | Includes a **`Save as module`** checkbox, **pre-checked** for selection saves. Whole-canvas saves get the same checkbox but **unchecked** by default. The saved entry gets `module: true` and the compatibility `module` tag in the same atomic `persistMutation` as the save — a commit failure rolls both back. |
|
|
| **Workflows section** | Module-tagged entries get a green **`pi pi-plus-circle`** icon and a distinct hover tooltip. **Left-click inserts** instead of loading. Right-click still offers **both** `Load` and `Insert into canvas` — `Load` stays available for every row regardless of tag. |
|
|
| **Tags section** | Same flip — a workflow tagged `module` is treated as a module no matter which tag folder it shows up under. |
|
|
| **Archive folder** | Archived module entries still left-click to **Load** (they're old versions, treated as "review" rather than "splice"). Right-click still has both Load and Insert. |
|
|
|
|
Implementation pieces:
|
|
|
|
- The literal tag string lives in [`web/sidebar/constants.js`](../../web/sidebar/constants.js) as `MODULE_TAG`. New saves store module state as first-class `module: true`; the tag is still honored for old entries and for the manual right-click Tags workflow.
|
|
- The non-destructive insert primitive is `insertWorkflowOntoCanvas(dirPath, wfName)` in [`web/sidebar/canvas_io.js`](../../web/sidebar/canvas_io.js). It pre-flights every referenced node type against `LiteGraph.registered_node_types`, aborts cleanly with a toast when any are missing (a partial insert with stub nodes is worse than no insert), then deep-clones the saved graph, normalizes Comfy/LiteGraph link records, configures each node with stale saved link IDs stripped, sets `node.id = -1` so `app.graph.add()` allocates fresh ids that can't collide with the live canvas, and finally recreates internal connections via `originNode.connect(...)` (which auto-allocates fresh link ids). Selection saves that include ComfyUI subgraph wrapper nodes also carry the required `definitions.subgraphs` entries so a later native Load has the definitions available.
|
|
- Bbox-of-cluster placement uses CSS pixels (`clientWidth`/`clientHeight`), not the HiDPI backing buffer — same correction as the existing `placeAtCanvasCenter` helper.
|
|
- Selection saves preserve ComfyUI group boxes that overlap selected nodes.
|
|
Insert translates those saved groups by the same placement delta as the
|
|
inserted node cluster, so group titles and boxes survive module round trips.
|
|
This is intentionally limited to groups touching the saved selection; unrelated
|
|
canvas groups are not copied into partial workflow saves.
|
|
|
|
To turn an **existing** saved workflow into a module: right-click → **Tags…**
|
|
→ add `module`. (No re-save needed; the row re-renders on the next
|
|
`workflows-changed` event.) To un-module: remove the `module` tag the same way;
|
|
the tag editor keeps the first-class `module` flag in sync.
|
|
|
|
## Right-click menus
|
|
|
|
- **Workflow row**: Load / **Insert into canvas** / Update from selection or canvas / Rename / Duplicate / Tags / Move to archive (or Restore from archive if already archived) / + New directory / + New subdirectory / Delete. Existing-folder moves are handled by drag-and-drop so the menu stays short in large libraries.
|
|
- **Directory row** (any depth): **Create subdirectory…** / Rename / Delete (with confirm if non-empty — the message names workflow + subdirectory counts).
|
|
- **Archive folder** (synthetic — appears only when a directory has archived workflows): **Clean up archive** runs across the whole selected Archive folder, groups entries by original workflow/setup name, keeps the newest archived entry from the last 5 minutes, last hour, and last day for each group (plus one fallback if none land in those windows), and confirms the keep/delete counts before mutating; **Delete archive (N)** removes every archived workflow in this directory in one go. Active workflows in the same directory are untouched by both actions.
|
|
|
|
## Drag-and-drop (Tier 1 — moves only; reordering is alphabetical)
|
|
|
|
| Drag | Drop on | Effect |
|
|
|---|---|---|
|
|
| Workflow row | Directory row | Move workflow to that directory (no-op if same dir) |
|
|
| Workflow row | Archive folder | Archive in that directory (move first if cross-dir) |
|
|
| Directory row | Directory row | Nest the dragged dir as a child of the target |
|
|
|
|
**Cycle prevention:** dragging a directory onto itself, its current parent, or any of its descendants is rejected. **Name collisions** (the destination already has a sibling with the same name) are rejected. **Reserved name:** dropping a directory literally named `Archive` into a non-root parent is rejected (would shadow the synthetic Archive folder).
|
|
|
|
Visual feedback: drop targets get a blue outline on hover. Failed drops surface a toast.
|
|
|
|
Sort within a level remains alphabetical — Tier 1 doesn't introduce a custom-order schema. (A future Tier 2 could.)
|
|
|
|
## Subdirectories
|
|
|
|
Every directory can host nested subdirectories at arbitrary depth. Each
|
|
nested directory behaves like a top-level directory: it can hold workflows,
|
|
its own Archive subfolder (when any of its workflows are archived), and
|
|
further subdirectories.
|
|
|
|
- **Create**: right-click a directory → "Create subdirectory…"
|
|
- **Save into**: pick the nested path from the save modal's directory dropdown (`UP-scale / Type-A`)
|
|
- **Move across**: drag a workflow row onto the target directory row
|
|
- **Reserved name**: subdirectory names cannot be `Archive` (case-insensitive) — that string is reserved for the synthetic Archive folder rendered for archived workflows. Top-level directories named `Archive` are allowed (no collision at root).
|
|
|
|
## Storage
|
|
|
|
| Where | What |
|
|
|---|---|
|
|
| `/userdata/koolook_workflows.json` (ComfyUI install) | Primary. Per-install. |
|
|
| `localStorage["koolook.workflows.fallback.v1"]` | Used only if `/userdata` is unreachable. |
|
|
| `localStorage["koolook.workflows.seeded.v1"]` | `"1"` once defaults have been seeded for this install. |
|
|
| `web/workflow_defaults.json` (in repo) | Distributed starter pack — seeded once into `/userdata` on first load with empty workflow data. |
|
|
|
|
## Comfy workflow draft quota gotcha
|
|
|
|
ComfyUI keeps its own browser-side workflow draft cache in localStorage,
|
|
and the key scheme has changed across frontend versions. All generations
|
|
share the `Comfy.Workflow.Draft` prefix:
|
|
|
|
| Generation | Keys | Written when |
|
|
|---|---|---|
|
|
| V1 (legacy; still written on tab switches as of frontend 1.44) | `Comfy.Workflow.Drafts` (one blob of all drafts) + `Comfy.Workflow.DraftOrder` (LRU) | switching/loading workflows |
|
|
| V1 per-workspace (interim) | `Comfy.Workflow.Drafts:<ws>` + `Comfy.Workflow.DraftOrder:<ws>` | older 1.4x frontends; the 1.44 migration reads these once **without deleting them** (later frontends added cleanup on successful migration, and an interrupted migration can still leave them behind) |
|
|
| V2 (frontend 1.44+) | `Comfy.Workflow.DraftIndex.v2:<ws>` (index) + `Comfy.Workflow.Draft.v2:<ws>:<hash>` (one payload per draft) | ~512 ms after every graph edit |
|
|
|
|
Once the origin's storage quota is hit, draft saves fail and Comfy shows
|
|
**"Failed to save workflow draft"** toasts. Worse, the 1.44+ V2 layer only
|
|
evicts its own current-workspace drafts on a quota error, and when that
|
|
isn't enough it latches a session-wide *storage unavailable* flag — after
|
|
which **every** edit toasts until the page is reloaded, regardless of which
|
|
workflow is open.
|
|
|
|
Koolook hit the symptom first in v0.3.6 with sidebar workflow loads; the
|
|
fix was a stable temporary workflow ID derived from the sidebar path/name
|
|
so the same workflow replaces the same draft entry instead of creating a
|
|
new one per load. For Koolook frontend extensions that ship large
|
|
timeline/editor data, the standing rules remain:
|
|
|
|
- Do not churn workflow IDs for equivalent exported/imported workflow files.
|
|
- Keep hidden widget payloads plain JSON and strip preview-only blobs before
|
|
serialization.
|
|
|
|
The systemic countermeasure is the global guard in
|
|
`web/koolook_draft_guard.js` (loaded on every page; behavior covered by
|
|
`tests/js/test_draft_guard.mjs`). It matches draft keys **by prefix**, so a
|
|
future upstream rename inside the prefix degrades to last-resort eviction
|
|
instead of going silently stale (which is how the previous exact-name
|
|
guard, then embedded in the LTX Director extension, died on frontend
|
|
1.44). Two mechanisms:
|
|
|
|
1. **Boot prune** — deletes suffixed-V1 families already migrated to V2
|
|
(dead weight 1.44-era migrations leave behind), corrupt keys (scoped to
|
|
the offending key only), oversized entries (>750k chars), and enforces a
|
|
~2M-char total draft budget, oldest drafts first.
|
|
2. **`localStorage.setItem` quota guard** — on `QuotaExceededError` for a
|
|
draft key, evicts the oldest draft across *all* generations and retries
|
|
inside `setItem`, so the V2 layer's own catch never sees the error: the
|
|
save succeeds, no toast, and the storage-unavailable latch never trips.
|
|
|
|
The guard never touches Koolook `/userdata`, snapshots, workflow library
|
|
entries, render outputs, or any non-draft localStorage key.
|
|
|
|
JSON shape (recursive — `directories` lives both at the root AND inside
|
|
every directory node):
|
|
|
|
```json
|
|
{
|
|
"directories": {
|
|
"<dir>": {
|
|
"workflows": {
|
|
"<name>": {
|
|
"savedAt": "<ISO timestamp>",
|
|
"graph": { /* serialized ComfyUI workflow JSON */ },
|
|
"module": true,
|
|
"tags": ["module"],
|
|
"archived": true
|
|
}
|
|
},
|
|
"directories": {
|
|
"<sub>": {
|
|
"workflows": { /* … */ },
|
|
"directories": { /* recurses */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`archived` is optional (false/missing means active). Pre-v0.3 stores
|
|
without the nested `directories` field still load fine — `normalize`
|
|
treats a missing `directories` as `{}` and the rest of the code assumes
|
|
it always exists after normalization.
|
|
|
|
## Reset (force re-seed from defaults)
|
|
|
|
Delete the userdata file in the live ComfyUI install:
|
|
|
|
```bash
|
|
rm <ComfyUI>/user/default/userdata/koolook_workflows.json
|
|
```
|
|
|
|
Then in the ComfyUI page DevTools console:
|
|
|
|
```js
|
|
localStorage.removeItem("koolook.workflows.seeded.v1");
|
|
localStorage.removeItem("koolook.workflows.fallback.v1");
|
|
location.reload();
|
|
```
|
|
|
|
## Things that surprised us — keep in mind
|
|
|
|
- **Selection saves are partial graphs.** Only the selected nodes + links between them survive; links into/out of non-selected nodes are nulled out, so the loaded workflow never has dangling references. Link handling accepts both serialized array links (`[id, origin, slot, target, slot, type]`) and object-shaped `LLink` records from `graph.links`.
|
|
- **Selection saves preserve relevant groups.** Any ComfyUI group box that
|
|
overlaps selected nodes is stored with the partial graph and restored on Load
|
|
or Insert. This supports the published-setup convention where
|
|
`Koolook Input` and `Koolook Output` groups mark the setup's app surface.
|
|
- **Subgraph wrappers are special.** The saved selection includes transitively referenced `definitions.subgraphs`, but insert still cannot register a never-loaded subgraph definition because ComfyUI's subgraph registration path lives inside native `app.loadGraphData`. If Insert reports an unregistered subgraph definition, native-Load the workflow once in that browser session, then retry Insert.
|
|
- **Workflow tab name comes from `app.loadGraphData(graph, true, true, name, {})`'s 4th arg.** The tab flips from "Unsaved Workflow (N)" to the saved name, and Ctrl+S pre-fills with that name. (Verified against `Comfy-Org/ComfyUI_frontend` `src/scripts/app.ts`.)
|
|
- **Folder expansion state persists across re-renders** (the `pathStates` Map in `web/sidebar/tree.js`). Saving never collapses the directory you were viewing — and a save into a nested path opens every ancestor folder.
|
|
- **Archive sub-folder is rendered *above* active workflows** in each directory (and *below* nested subdirectories), so the latest active workflow sits closest to the bottom of its directory — easy to spot.
|
|
- **Directory header counts include all descendants.** A parent dir with no direct workflows but multiple subdirectories shows the recursive total.
|
|
- **No background auto-pruning of archives.** Re-saving the same name can still leave many timestamped archive entries. Right-click the synthetic Archive folder → **Clean up archive** to triage all archived entries in that folder by original workflow/setup name, keeping the newest archived entry from the last 5 minutes, last hour, and last day for each group. Use **Delete archive (N)** / per-row Delete when you want explicit removal.
|
|
- **Same-pattern distribution as `starter_preset.json`** — see [`curated-sidebar.md`](curated-sidebar.md) for the seeding semantics that also apply here.
|