Compare commits

...

37 Commits

Author SHA1 Message Date
Ethanfel 6d433ba371 feat: native disable/enable fallback and package whitelist; bump to 1.8.0
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run
Disable/enable no longer require ComfyUI Manager:
- New pack_fs.py moves packs in/out of custom_nodes/.disabled/ (no import,
  delete, or re-clone). Fallback for hand-cloned packs, loose single-file
  nodes, or when Manager is absent. enable strips the @version suffix so
  packs restore as clean, importable dir names.
- Routes: native-disable, native-enable, disabled-packs.
- Frontend routes each disable per-pack (Manager queue vs native move), and
  shows an Enable button on recoverable packs in the Uninstalled tier. The
  restart banner degrades to a manual-restart notice when no Manager exists.

Whitelist (packages-only): a star toggle protects a pack — pulled into its
own pinned group, no Disable button, skipped by the 7-day trial auto-disable.
- New whitelist_packages table; whitelisted flag on package stats.
- Routes: whitelist, whitelist/add, whitelist/remove.

Tests: test_pack_fs.py, test_whitelist.py (60 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:16:29 +02:00
Ethanfel 7ca7d95ef3 feat(workflow): make auto-open on load opt-in; default off
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Loading a workflow with missing/disabled nodes no longer pops the Node
Stats dialog every time. Gated behind a new ComfyUI setting
"Auto-open Node Stats on workflow load" (off by default); the scan still
runs so the Workflow tab is accurate when opened manually. Bump to 1.7.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:11:54 +02:00
Ethanfel 2692bb1752 docs: note built-in manager (--enable-manager-legacy-ui) support; bump to 1.7.1
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:24:30 +02:00
Ethanfel e043ec865c fix(manager): support ComfyUI core's bundled manager (v4 /v2 API)
ComfyUI core now bundles comfyui_manager v4 (enabled via --enable-manager),
which serves all Manager HTTP routes under a /v2 prefix and replaces the
per-item /manager/queue/install and /manager/queue/disable endpoints with a
single /v2/manager/queue/batch call. The extension called the old unprefixed
paths, so every request 404'd, fetchManagerInfo() returned null, and all
disable/enable/install/trial features went inert ("can't find manager").

Add detectManagerApi() to probe once (cheap queue/status) and cache which
generation answers: v3 (old git Manager, unprefixed, per-item) or v4-legacy
(/v2 + batch). Route every read through a prefix-aware mgrFetch(), and branch
install/disable on the batch transport via a shared runManagerBatch() helper.
Payload shapes are unchanged — v4's _install_custom_node/_disable_node read the
same fields. Verified against a live v4-legacy server on :8189.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:23:35 +02:00
Ethanfel 4ffaddef7c docs: document 7-day trial-install for missing nodes; bump to 1.7.0
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-22 17:57:46 +02:00
Ethanfel 19b96de322 feat(install): wire Install 7d / Install buttons 2026-06-22 17:56:41 +02:00
Ethanfel 406a4bf9ff feat(install): Missing rows offer Install 7d + Install 2026-06-22 17:48:37 +02:00
Ethanfel 0707a76768 feat(install): handleTrialInstall orchestrator with Manager fallback 2026-06-22 17:48:10 +02:00
Ethanfel a67b628140 feat(install): install-target resolution helpers for missing packs 2026-06-22 17:47:46 +02:00
Ethanfel 73185ad638 docs: implementation plan for 7-day trial-install of missing nodes
5-task frontend-only plan: install-resolution helpers, handleTrialInstall
orchestrator (reusing the Manager install queue + existing trial routes +
processExpiredTrials expiry), Install 7d / Install buttons + wiring, and
docs/version bump to 1.7.0. Manual verification + node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:37:02 +02:00
Ethanfel cc1369a38b docs: design for 7-day trial-install of missing nodes
Extend the rolling 7-day trial from disabled packs to missing (not-installed)
ones: Workflow tab Missing rows get Install 7d (real install + trial) and
Install (permanent). Expiry auto-disables (reuses processExpiredTrials, zero
new expiry code). Install spec resolved from not-installed getlist entries;
resulting dir discovered by re-reading getlist to key the trial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:35:20 +02:00
Ethanfel acaa9f0168 feat(search): draw real node box from disabled-pack source
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Click a node name (or 'Draw this node') in the mirror-search palette to render
an imitation ComfyUI node box with its real input sockets, widget defaults, and
output sockets. Since disabled packs aren't loaded (no /object_info entry), a
new read-only backend module (node_introspect.py) AST-parses the pack on disk —
never importing or executing it — to recover INPUT_TYPES / RETURN_TYPES from a
literal NODE_CLASS_MAPPINGS. New GET /nodes-stats/node-schema endpoint resolves
the disabled pack dir (handling @version suffixes / case) and returns the schema
off the event loop. Frontend lazily fetches + caches per node, renders sockets
vs widgets with type-colored dots, and falls back to a placeholder for packs
that build their node list dynamically.

End-to-end against the live install: 67/68 disabled packs resolve on disk, ~92%
of nodes render a real box, the rest fall back cleanly. Adds 7 parser unit
tests (36 total green). Bump to 1.6.0.
2026-06-21 15:23:58 +02:00
Ethanfel 77c159a918 feat(search): side preview panel for mirror search + strip stray NUL byte
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Splits the palette into results (left) + a preview panel (right) that updates
on hover / arrow-key navigation, modern-search style. Since disabled-pack
nodes have no loaded definition (can't render a real node graphic), the panel
shows the pack metadata we do have: title, author, description, repo link,
version, and the sibling nodes in the pack (active node highlighted). Enable
7d / Enable available in both the row and the panel.

buildDisabledCatalog now attaches a shared per-pack meta object (from
getmappings entry[1]) to each catalog entry. Also removes a literal NUL byte
that had slipped into the dedup separator string, which made grep treat the
file as binary; replaced with a newline. Bump to 1.5.0.
2026-06-21 14:49:40 +02:00
Ethanfel 5860b232d4 fix(search): match disabled packs by all identifiers, not repo URL only
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
getmappings keys packs by dir name/registry id far more often than by repo
URL, so the url-only join found just 7 of 73 disabled packs (71 nodes).
Resolve each getmappings key against every identifier a pack exposes (dir,
id, cnr_id, aux_id, files) like classifyUnresolved does — now 69 packs /
2301 nodes. Bump to 1.4.1.
2026-06-21 14:38:47 +02:00
Ethanfel d8f94ca371 docs: document mirror search; bump to 1.4.0
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-21 14:28:36 +02:00
Ethanfel a8fb5ae8b4 feat(search): toolbar button + hotkey to open mirror search 2026-06-21 14:27:46 +02:00
Ethanfel 0dfa14384d feat(search): mirror search palette UI + enable actions 2026-06-21 14:27:14 +02:00
Ethanfel 8c35ac6f09 feat(search): catalog ranking + filter helpers 2026-06-21 14:26:20 +02:00
Ethanfel b6635c9f3e feat(search): build disabled-node catalog from getmappings x getlist 2026-06-21 14:25:35 +02:00
Ethanfel fb3a785027 refactor: extract enablePackage core from handleEnable 2026-06-21 14:25:08 +02:00
Ethanfel 8cb0e32739 docs: implementation plan for disabled-node mirror search
6-task frontend-only plan: extract shared enablePackage core, build the
disabled-node catalog (getmappings x getlist), ranking/filter helpers, the
palette UI, toolbar button + hotkey, and docs/version. Manual verification
(no JS harness) + node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:15:16 +02:00
Ethanfel aa2f1f62bf docs: design for disabled-node mirror search
Approved design: a standalone search palette (toolbar button + hotkey) over
nodes belonging to disabled packs, built frontend-only by joining Manager's
getmappings x getlist, with Enable 7d / Enable actions reusing the trial
feature. Native-search injection is infeasible (bundled frontend), so this
runs alongside as a mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:12:25 +02:00
Ethanfel 62e4b9df8c fix(workflow): skip Manager queue ops when Manager is mid-operation
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-21 13:00:35 +02:00
Ethanfel 7cd67cdda1 docs: document workflow tab + trial-enable; bump to 1.3.0 2026-06-21 12:55:18 +02:00
Ethanfel c6601bc86f feat(workflow): auto-disable expired trial packages on load 2026-06-21 12:54:02 +02:00
Ethanfel c7b06f5906 feat(workflow): missing nodes defer to ComfyUI Manager installer 2026-06-21 12:52:31 +02:00
Ethanfel 427a509e4c feat(workflow): enable temporary (7d trial) / permanent actions 2026-06-21 12:48:38 +02:00
Ethanfel fed626685b feat(workflow): workflow tab UI + auto-open on load 2026-06-21 12:45:49 +02:00
Ethanfel 743741afc6 feat(workflow): classify unresolved nodes into disabled vs missing 2026-06-21 12:38:48 +02:00
Ethanfel b5e7bf204d feat(workflow): detect unresolved node types on workflow load 2026-06-21 12:36:18 +02:00
Ethanfel 9127f8121d feat(trials): boot tick, usage reset hook, and trial routes 2026-06-21 12:28:36 +02:00
Ethanfel c53fdd8560 feat(trials): stop_trial and clear trials on reset 2026-06-21 12:27:37 +02:00
Ethanfel 4ebad1bd6c feat(trials): reset_trials_for zeroes counter on use 2026-06-21 12:27:05 +02:00
Ethanfel 69eef149eb feat(trials): tick_boot_days counts distinct boot-days 2026-06-21 12:26:31 +02:00
Ethanfel 7b5fb32b31 feat(trials): add trial_packages table, start_trial, get_trials 2026-06-21 12:25:52 +02:00
Ethanfel caaaaa3b24 docs: implementation plan for workflow tab + trial-enable
Bite-sized TDD plan (13 tasks): backend trial state + boot tick + routes,
a spike to verify Manager's enable payload, frontend detection/tab/actions/
expiry, and docs/version. Backend is fully test-driven; frontend is
manual-verify (no JS harness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:01:29 +02:00
Ethanfel 1a2bd2bcef docs: design for workflow tab + temporary trial-enable
Approved brainstorming design: a Workflow tab that splits a loaded
workflow's unresolved nodes into Missing (defer to Manager) and Disabled
(enable temporarily under a 7 distinct-boot-day rolling trial, or
permanently). Backend tracks trial state and counts boot-days; frontend
drives Manager enable/disable (Approach A).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:58:45 +02:00
17 changed files with 4277 additions and 71 deletions
+84 -3
View File
@@ -15,7 +15,10 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo
- **Smart aging** — items gradually move from "recently unused" to "safe to remove" over time - **Smart aging** — items gradually move from "recently unused" to "safe to remove" over time
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved - **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
- **Expandable detail** — click any package to see individual node-level stats - **Expandable detail** — click any package to see individual node-level stats
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time - **One-click disable** — disable unused packages straight from the dialog (per-package or in bulk), reversible at any time. Uses ComfyUI Manager when it manages the pack, and **falls back to a native disable** (moving the pack into `custom_nodes/.disabled/`) for hand-cloned packs, loose single-file nodes, or when Manager isn't installed. Natively-disabled packs get an **Enable** button in the *Uninstalled* tier to move them back — no Manager needed (restart to apply)
- **Whitelist** — click the ☆ star on any package to protect it: whitelisted packs move into their own pinned group, never show a Disable button, and are skipped by the 7-day trial auto-disable
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install permanently or on a trial) and *Disabled* (enable permanently or on a trial), with a rolling **7-day trial** that auto-disables packages left unused
- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
## Package Classification ## Package Classification
@@ -58,7 +61,7 @@ Restart ComfyUI. Tracking starts immediately and silently.
### UI ### UI
Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with two tabs: Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with three tabs:
**Nodes tab** **Nodes tab**
- Summary bar with counts for each classification tier - Summary bar with counts for each classification tier
@@ -79,6 +82,78 @@ package, plus a **Disable all** button per section. Disabling:
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
> **Manager compatibility:** works with both the standalone
> [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) custom node and
> ComfyUI core's built-in manager. For the built-in manager, launch ComfyUI with
> `--enable-manager-legacy-ui` (the extension detects the active manager and its
> API automatically). Without a reachable manager, all enable/disable/install
> actions are simply omitted.
### Workflow tab & temporary enable
Whenever you load a workflow, the extension scans for node types the running
ComfyUI can't resolve and, if any are found, opens the dialog on the **Workflow**
tab. Unresolved nodes are split into two groups:
- **Missing** — the owning package isn't installed. Each row offers:
- **Install 7d** — really install the package (via
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager)) and start a
*temporary trial*, so trying out someone else's workflow stays
non-committal — anything you don't actually use auto-disables.
- **Install** — install permanently (no trial).
Both take effect after a ComfyUI restart. If the install can't be resolved or
Manager refuses it (e.g. a blocked git URL), the buttons fall back to opening
Manager's Custom Nodes Manager (use its *Missing* filter).
- **Disabled** — the package is installed but currently disabled. Each row offers:
- **Enable 7d** — re-enable the package and start a *temporary trial*.
- **Enable** — re-enable permanently (no trial).
**The temporary trial** (started by either *Install 7d* or *Enable 7d*) is a
rolling budget of **7 distinct boot-days**. A
"boot-day" is counted at most once per calendar day, the first time ComfyUI
starts that day — so the clock measures days you actually run ComfyUI, not wall
time. **Any execution that uses the package resets the counter to zero.** If a
trial package goes its full budget of distinct boot-days without being used, it
is **auto-disabled on the next UI load** (handed to ComfyUI Manager exactly like
a manual disable) and the trial is cleared. As with any disable, a ComfyUI
restart is required to fully unload it.
Re-enabling and auto-disabling both go through ComfyUI Manager, so the whole
Workflow tab is inert when Manager is not installed (the backend still tracks
trial state, but no enable/disable actions are offered).
### Mirror search (disabled-pack nodes)
Sometimes you know the node you want exists in a package you've disabled, but you
don't want to dig through ComfyUI Manager to find it. The **mirror search**
palette searches across the `class_type` names of *every currently-disabled
package* and lets you re-enable the owning package right from the results.
- Open it with the **⌕** button in the top menu bar, or press
**`Ctrl/Cmd+Shift+D`** (ignored while typing in an input).
- Type to filter — results are ranked (node-name prefix first, then word-start,
substring, finally pack-name matches) and show the `class_type` and its pack.
- Hover a result (or use ↑/↓) to open a **preview panel** on the right with the
owning package's title, author, description, repo link, and the full list of
sibling nodes in that pack — the active node highlighted.
- **Click a node's name** (or *Draw this node*) to render an **imitation node
box** — the real input sockets, widget defaults, and output sockets, drawn from
a static parse of the disabled pack's source. Since the pack isn't loaded,
there's no live definition to render; the backend AST-parses the pack on disk
(read-only, never executed) to recover the schema. Works for most packs (~90%);
packs that build their node list dynamically fall back to a placeholder box.
- Each result offers **Enable 7d** (re-enable under a 7-day trial) and **Enable**
(re-enable permanently) — in the row and in the preview panel — the same enable
path as the Workflow tab.
- Enabling takes effect after a ComfyUI restart; enabled rows mark
*"✓ enabled · restart"*.
The catalog is built once per session by joining ComfyUI Manager's node→pack
mappings with the disabled packs (matched across dir name, registry id, and repo
URL), and cached; use the **↻** button to rebuild it. The palette is inert (with
a clear message) when ComfyUI Manager is absent or there are no disabled packages.
**Models tab** **Models tab**
- Summary bar with counts for each tier across all model types - Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …) - Sections per model type (checkpoints, vae, controlnet, …)
@@ -91,7 +166,11 @@ If ComfyUI Manager is not installed, the disable buttons are hidden and stats wo
| `/nodes-stats/packages` | GET | Per-package aggregated stats with classification | | `/nodes-stats/packages` | GET | Per-package aggregated stats with classification |
| `/nodes-stats/usage` | GET | Raw per-node usage data | | `/nodes-stats/usage` | GET | Raw per-node usage data |
| `/nodes-stats/models` | GET | Per-type model stats with classification | | `/nodes-stats/models` | GET | Per-type model stats with classification |
| `/nodes-stats/node-schema` | GET | Parsed input/output schema for one disabled-pack node — query `class_type`, `pack` (read-only AST parse) |
| `/nodes-stats/reset` | POST | Clear all tracked data | | `/nodes-stats/reset` | POST | Clear all tracked data |
| `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` |
| `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` |
| `/nodes-stats/trials/stop` | POST | End a trial (made permanent or disabled) — body `{"package": "<dir-name>"}` |
```bash ```bash
curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool
@@ -178,6 +257,7 @@ All data is stored in `<ComfyUI user dir>/nodes_stats/usage_stats.db` (survives
| `node_usage` | Per-node: class_type, package, execution count, first/last seen | | `node_usage` | Per-node: class_type, package, execution count, first/last seen |
| `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used | | `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used |
| `model_usage` | Per-model: filename, type, execution count, first/last seen | | `model_usage` | Per-model: filename, type, execution count, first/last seen |
| `trial_packages` | Per temporary-enable trial: package, enable date, unused-boot-day counter, budget |
Use `POST /nodes-stats/reset` to clear all data and start fresh. Use `POST /nodes-stats/reset` to clear all data and start fresh.
@@ -208,7 +288,8 @@ timeout so the kernel keeps the listing warm across restarts, e.g.
__init__.py Entry point: prompt handler, API routes __init__.py Entry point: prompt handler, API routes
mapper.py class_type → package mapping; model filename → type mapping mapper.py class_type → package mapping; model filename → type mapping
tracker.py SQLite persistence and stats aggregation tracker.py SQLite persistence and stats aggregation
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models tabs) node_introspect.py Read-only AST parse of disabled packs → node input/output schema
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs) + mirror search
tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
pyproject.toml Package metadata pyproject.toml Package metadata
tests/ Unit tests for tracker and mapper tests/ Unit tests for tracker and mapper
+175
View File
@@ -1,3 +1,4 @@
import asyncio
import logging import logging
import threading import threading
@@ -5,6 +6,8 @@ from aiohttp import web
from server import PromptServer from server import PromptServer
from .mapper import NodePackageMapper, ModelMapper from .mapper import NodePackageMapper, ModelMapper
from .node_introspect import find_disabled_pack_path, get_node_schema
from .pack_fs import disable_pack_native, enable_pack_native, list_disabled_packs
from .tracker import UsageTracker from .tracker import UsageTracker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,6 +47,13 @@ def _record_prompt(class_types, prompt):
tracker.record_usage(class_types, mapper) tracker.record_usage(class_types, mapper)
except Exception: except Exception:
logger.warning("nodes-stats: error recording node usage", exc_info=True) logger.warning("nodes-stats: error recording node usage", exc_info=True)
try:
packages = {mapper.get_package(ct) for ct in class_types}
packages.discard("__builtin__")
packages.discard("__unknown__")
tracker.reset_trials_for(packages)
except Exception:
logger.warning("nodes-stats: error resetting trials", exc_info=True)
try: try:
models = model_mapper.extract_models_from_prompt(prompt) models = model_mapper.extract_models_from_prompt(prompt)
if models: if models:
@@ -52,6 +62,13 @@ def _record_prompt(class_types, prompt):
logger.warning("nodes-stats: error recording model usage", exc_info=True) logger.warning("nodes-stats: error recording model usage", exc_info=True)
# Age temporary-enable trials once per process start (one "boot").
try:
tracker.tick_boot_days()
except Exception:
logger.warning("nodes-stats: error ticking trial boot days", exc_info=True)
PromptServer.instance.add_on_prompt_handler(on_prompt_handler) PromptServer.instance.add_on_prompt_handler(on_prompt_handler)
@@ -99,3 +116,161 @@ async def reset_stats(request):
except Exception: except Exception:
logger.error("nodes-stats: error resetting stats", exc_info=True) logger.error("nodes-stats: error resetting stats", exc_info=True)
return web.json_response({"error": "internal error"}, status=500) return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/node-schema")
async def get_node_schema_route(request):
"""Parse a disabled pack's source and return one node's input/output schema.
Read-only and never imports/executes the pack. Used by the mirror-search
palette to draw a faithful node box for a node that isn't loaded.
"""
try:
class_type = request.query.get("class_type")
pack = request.query.get("pack")
if not class_type or not pack:
return web.json_response({"error": "class_type and pack required"}, status=400)
def _resolve():
path = find_disabled_pack_path(pack)
if not path:
return {"parseable": False, "reason": "source_not_found"}
return get_node_schema(class_type, path)
# AST-parsing a whole pack can take tens of ms; keep it off the event loop.
schema = await asyncio.get_event_loop().run_in_executor(None, _resolve)
return web.json_response(schema)
except Exception:
logger.error("nodes-stats: error parsing node schema", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-disable")
async def native_disable(request):
"""Disable a pack by moving it into custom_nodes/.disabled/ (no Manager).
Fallback for packs ComfyUI Manager doesn't manage. A restart is required for
ComfyUI to unload the pack.
"""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, disable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native disable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-enable")
async def native_enable(request):
"""Re-enable a pack by moving it out of custom_nodes/.disabled/ (no Manager)."""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, enable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native enable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/disabled-packs")
async def get_disabled_packs(request):
"""List packs present in custom_nodes/.disabled/ (re-enable candidates)."""
try:
names = await asyncio.get_event_loop().run_in_executor(None, list_disabled_packs)
return web.json_response(sorted(names))
except Exception:
logger.error("nodes-stats: error listing disabled packs", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/whitelist")
async def get_whitelist(request):
try:
return web.json_response(sorted(tracker.get_whitelist()))
except Exception:
logger.error("nodes-stats: error getting whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/add")
async def whitelist_add(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.add_to_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error adding to whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/remove")
async def whitelist_remove(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.remove_from_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error removing from whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/trials")
async def get_trials(request):
try:
return web.json_response(tracker.get_trials())
except Exception:
logger.error("nodes-stats: error getting trials", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/start")
async def start_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.start_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error starting trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/stop")
async def stop_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.stop_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error stopping trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@@ -0,0 +1,130 @@
# Design: Mirror search for disabled-pack nodes
Date: 2026-06-21
Status: Approved
## Summary
A standalone "mirror search" palette that lets you find nodes belonging to
currently-**disabled** custom-node packages — without loading those packages
(which slows ComfyUI boot/runtime). Each result has Enable buttons; enabling
re-enables the owning package (temporarily or permanently) and takes effect
after a ComfyUI restart, after which the node appears in ComfyUI's native search.
This runs *alongside* ComfyUI's native node search rather than inside it:
ComfyUI's frontend is compiled/bundled and exposes no search-provider or
node-def injection hook, so the native search cannot be extended. A separate
palette is the only viable design (and matches the request — a "mirror" search).
## Constraints discovered (reconnaissance)
- Native search injection is **not feasible**: bundled frontend, no public hook
(`app.registerExtension` offers `nodeCreated`/`beforeRegisterNodeDef` only),
and disabled packs never enter `NODE_CLASS_MAPPINGS` / `/object_info`.
- `/customnode/getmappings?mode=local` returns a registry-wide map keyed by repo
URL: `{ <repo_url>: [ [class_type, ...], { title_aux } ] }`. It **does**
include the class_type names for every pack, including disabled ones.
- `/customnode/getlist?mode=local&skip_update=true` lists packs with `state`
(`disabled`/`enabled`/`not-installed`), `id`, `version`, `files`,
`repository`. There are 73 disabled packs in the reference install.
- Available metadata for an unloaded node: **class_type name + pack name/title
only**. No categories, descriptions, or input/output ports (those require the
pack to be loaded).
## Decisions (from brainstorming)
- **Trigger:** dedicated palette opened by a toolbar button + a keyboard
shortcut. Not a tab in the Node Stats dialog.
- **Enable actions:** both "Enable 7d" (rolling trial) and "Enable" (permanent),
reusing the trial-enable feature. Takes effect after restart.
- **Catalog construction:** frontend-only. Fetch getmappings + getlist on first
open, join by repo URL, cache in memory for the session, with a refresh
affordance. No backend changes.
- **Scope:** disabled packs only (not the full not-installed registry — that's
Manager's job).
## Architecture
All in `js/nodes_stats.js`; no backend changes.
### Catalog
- `ensureDisabledCatalog()` (cached in a module variable):
1. `fetchManagerInfo()` (existing; getlist) → packs with `state === 'disabled'`.
2. `fetch('/customnode/getmappings?mode=local')` → build `repoUrl -> [class_types]`.
3. Normalize URLs (lowercase, strip trailing `/` and `.git`) on both sides.
4. For each disabled pack, look up class_types by its `files[0]`/`repository`;
emit `{ class_type, pack: <dir name>, title, enableInfo }` per node.
- A `refresh()` clears the cache and rebuilds.
### Search/filter (pure functions, for clarity + manual testability)
- `scoreEntry(entry, queryLower)` → null if no match; else a rank where
class_type prefix < word-start < substring, pack-name match ranked lower.
- `filterCatalog(catalog, query, limit=50)` → sorted, capped list + total count.
### Palette UI
- `openMirrorSearch()`: ensure catalog; render a modal overlay (reusing the
dialog styling helpers) with a text input (autofocused) and a results list.
- Input `keyup` → re-render rows via `filterCatalog`.
- Row: `class_type` · `(pack)` · `[Enable 7d]` `[Enable]`.
- Footer: "<shown>/<total> from <N> disabled packs · enabling needs a restart" +
"↻ refresh".
- Empty/error/inert states handled explicitly.
### Trigger
- Toolbar button (like the existing Node Stats button), title "Search disabled
nodes".
- Keyboard shortcut: prefer ComfyUI's extension command/keybinding API if
available; else a guarded `document` `keydown` listener (default
`Ctrl/Cmd+Shift+D`), ignored when focus is in an input/textarea.
### Actions
- Reuse `handleEnable(pkg, temporary)` from the trial-enable feature:
- `[Enable 7d]``temporary=true` (Manager enable → `trials/start`).
- `[Enable]``temporary=false` (Manager enable → `trials/stop`).
- Reuse the restart banner / toast. After enable, mark the row "enabled ·
restart".
## Data flow
```
setup() -> add toolbar button + register hotkey
trigger -> openMirrorSearch()
-> ensureDisabledCatalog() (1st time: getmappings + getlist, join, cache)
-> render modal (input + results)
type -> filterCatalog() -> render rows (instant, in-memory)
Enable -> handleEnable(pkg, temp?) -> Manager enable -> trials/start|stop
-> restart banner/toast
refresh -> clear cache -> ensureDisabledCatalog() -> re-render
```
## Error handling
- ComfyUI Manager absent or getlist/getmappings fails → palette shows a clear
message ("ComfyUI Manager not available" / "couldn't load disabled-node
list"); the button stays but the palette is inert. No crash.
- Zero disabled packs → "No disabled packages — nothing to search."
- Enable failure → existing error toast; row left actionable.
- URL-join misses for a pack → that pack contributes no rows (logged to
console); never throws.
## Testing
- No backend changes → no new pytest.
- Pure helpers (`scoreEntry`, `filterCatalog`, URL normalization, catalog join)
written as small standalone functions; `node --check` for syntax.
- Manual verification: open palette via button + hotkey; search a known disabled
pack's node (e.g. an Inspire-Pack node); Enable 7d → getlist flips to enabled
+ `/nodes-stats/trials` shows it + restart banner; Enable (permanent) → enabled,
no trial row; refresh rebuilds; Manager-absent path shows the inert message.
## Files touched
- `js/nodes_stats.js` — catalog, filter, palette UI, trigger, reuse enable.
- `README.md`, `pyproject.toml` — docs + version bump.
## Out of scope (YAGNI)
- Injecting results into ComfyUI's native search (not feasible).
- Rich node metadata (titles/categories/ports) for unloaded nodes (unavailable).
- Auto-placing the node into the graph after restart.
- Searching not-installed registry packs (Manager already does this).
+395
View File
@@ -0,0 +1,395 @@
# Disabled-Node Mirror Search Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a standalone "mirror search" palette (toolbar button + hotkey) that searches nodes belonging to currently-disabled custom-node packages and offers Enable 7d / Enable on each result, reusing the trial-enable code.
**Architecture:** Frontend-only, all in `js/nodes_stats.js`. Build an in-memory catalog by joining ComfyUI Manager's `getmappings` (repo-URL → class_type names, registry-wide) with `getlist` (packs whose `state === 'disabled'`), cached per session. A separate modal palette filters the catalog live. Enable actions reuse a shared `enablePackage()` core (extracted from the existing `handleEnable`). No backend changes.
**Tech Stack:** Vanilla JS (ComfyUI frontend extension via `app.registerExtension`), ComfyUI Manager HTTP endpoints, the trial-enable feature already in this file.
**Design doc:** `docs/plans/2026-06-21-mirror-search-design.md`
**Testing note:** No JS test harness exists. "Verify" steps use `node --check` for syntax and explicit browser-console / in-app checks. Pure helpers are written standalone so they can be exercised from the console. After every JS edit run:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && echo OK && rm /tmp/c.mjs`
Reuse points already in `js/nodes_stats.js` (confirmed): `fetchManagerInfo()` (getlist → `{dir:{id,version,files,state}}`), `enablePayload()`, `runManagerEnable()`, `managerIsBusy()`, `handleEnable()`, `notify()`, `escapeHtml()`, `escapeAttr()`, `showRestartBanner()`, the toolbar-button mount in `setup()`.
---
### Task 1: Extract shared `enablePackage()` core (refactor, no behavior change)
**Why:** `handleEnable` is hard-wired to the Workflow tab's `_lastWorkflowScan` and `dialog`. The palette needs the same enable logic without that coupling. Extract the Manager-enable + trial-route + toast into `enablePackage(pkg, info, temporary)`; keep `handleEnable` as the Workflow-tab wrapper.
**Files:** Modify `js/nodes_stats.js` (around lines 717746).
**Step 1: Add the shared core** immediately above `handleEnable`:
```js
// Shared enable core used by the Workflow tab and the mirror search palette.
// Performs the Manager enable + trial bookkeeping + success toast.
// Returns true on success, false if Manager was busy. Throws on failure.
// Caller owns its own busy UI and restart affordance.
async function enablePackage(pkg, info, temporary) {
if (!info) throw new Error("no enable info for " + pkg);
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return false;
}
await runManagerEnable(enablePayload(pkg, info));
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
await fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
return true;
}
```
**Step 2: Replace the body of `handleEnable`** to delegate:
```js
async function handleEnable(pkg, temporary, dialog) {
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
const info = entry && entry.info;
if (!info) return;
setWorkflowButtonsBusy(dialog, true);
try {
if (await enablePackage(pkg, info, temporary)) {
entry.info.state = "enabled";
showRestartBanner(dialog);
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
```
**Step 3: Verify syntax** — run the `node --check` line above. Expected: `OK`.
**Step 4: Verify no behavior change (manual)** — hard-refresh ComfyUI, load a workflow with a disabled node, click Enable 7d in the Workflow tab → still enables + restart banner + `/nodes-stats/trials` shows it. (If you don't want to mutate state, just confirm the buttons still render and the console shows no errors on open.)
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "refactor: extract enablePackage core from handleEnable"
```
---
### Task 2: Catalog build — URL normalize + join + cache
**Files:** Modify `js/nodes_stats.js` (add near `fetchManagerInfo`).
**Step 1: Add pure helpers + cached loader:**
```js
// Normalize a repo URL for joining getmappings keys to getlist pack files.
function normalizeRepoUrl(url) {
return String(url || "").trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
}
// Join Manager's node->pack mappings with the disabled packs from getlist.
// mappings: { <repoUrl>: [ [class_type,...], {title_aux} ] } (from getmappings)
// managerInfo: { <dir>: {id,version,files,state,title?} } (from fetchManagerInfo)
// Returns [{ class_type, pack, title, info }] for disabled packs only.
function buildDisabledCatalog(mappings, managerInfo) {
const byUrl = {};
for (const [url, entry] of Object.entries(mappings || {})) {
const list = entry && entry[0];
if (Array.isArray(list)) byUrl[normalizeRepoUrl(url)] = list;
}
const catalog = [];
for (const [dir, info] of Object.entries(managerInfo || {})) {
if (!info || info.state !== "disabled") continue;
const urls = (info.files && info.files.length ? info.files : [info.repository]).filter(Boolean);
let nodes = null;
for (const u of urls) {
const hit = byUrl[normalizeRepoUrl(u)];
if (hit) { nodes = hit; break; }
}
if (!nodes) { console.debug("[Node Stats] no node map for disabled pack", dir); continue; }
const title = info.title || dir;
for (const ct of nodes) catalog.push({ class_type: ct, pack: dir, title, info });
}
return catalog;
}
let _disabledCatalog = null; // cached for the session
async function ensureDisabledCatalog(forceRefresh = false) {
if (_disabledCatalog && !forceRefresh) return _disabledCatalog;
const managerInfo = await fetchManagerInfo();
if (!managerInfo) return null; // Manager absent
let mappings = {};
try {
const r = await fetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog;
}
```
**Step 2: Verify syntax**`node --check` line. Expected `OK`.
**Step 3: Verify the join (browser console)** — hard-refresh ComfyUI, open devtools console:
```js
// paste: pull the two sources and join, then sanity-check
const mi = await (await fetch("/customnode/getlist?mode=local&skip_update=true")).json();
const mp = await (await fetch("/customnode/getmappings?mode=local")).json();
```
Then confirm in the app once Task 4 wires it; for now just confirm `getmappings` returns an object and `getlist.node_packs` has `state:'disabled'` entries. Expected: yes (≈73 disabled packs in this install).
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): build disabled-node catalog from getmappings x getlist"
```
---
### Task 3: Search filter (pure)
**Files:** Modify `js/nodes_stats.js`.
**Step 1: Add ranking + filter:**
```js
// Rank a catalog entry against a lowercased query. Lower = better; null = no match.
// class_type prefix (0) < class_type word-start (1) < class_type substring (2)
// < pack-name match (3). No match -> null.
function scoreEntry(entry, q) {
const name = entry.class_type.toLowerCase();
if (name.startsWith(q)) return 0;
if (name.split(/[\s_\-./]/).some((w) => w.startsWith(q))) return 1;
if (name.includes(q)) return 2;
if (entry.pack.toLowerCase().includes(q)) return 3;
return null;
}
// Filter + rank a catalog. Returns { rows, total } where rows is capped at limit.
function filterCatalog(catalog, query, limit = 50) {
const q = String(query || "").trim().toLowerCase();
if (!q) return { rows: [], total: 0 };
const scored = [];
for (const e of catalog) {
const s = scoreEntry(e, q);
if (s !== null) scored.push([s, e]);
}
scored.sort((a, b) => a[0] - b[0] || a[1].class_type.localeCompare(b[1].class_type));
return { rows: scored.slice(0, limit).map((x) => x[1]), total: scored.length };
}
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Verify logic (browser console, after Task 4 exposes catalog, or inline)** — confirm e.g. `filterCatalog([{class_type:"MaskComposite",pack:"masquerade"}], "mask").total === 1` and `scoreEntry({class_type:"MaskComposite",pack:"x"}, "mask") === 0`.
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): catalog ranking + filter helpers"
```
---
### Task 4: Mirror search palette UI
**Files:** Modify `js/nodes_stats.js`.
**Step 1: Add the palette open/render:**
```js
async function openMirrorSearch() {
const existing = document.getElementById("nodes-stats-mirror");
if (existing) { existing.querySelector("#ns-mirror-input")?.focus(); return; }
const overlay = document.createElement("div");
overlay.id = "nodes-stats-mirror";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:flex-start;justify-content:center;";
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") overlay.remove(); });
const box = document.createElement("div");
box.style.cssText =
"margin-top:10vh;background:#1e1e1e;color:#ddd;border:1px solid #444;border-radius:8px;width:90%;max-width:640px;max-height:70vh;display:flex;flex-direction:column;font-family:monospace;font-size:13px;overflow:hidden;";
box.innerHTML = `
<style>
#nodes-stats-mirror .ns-btn{font-family:monospace;font-size:11px;border:1px solid #555;background:#262626;color:#ddd;border-radius:4px;padding:3px 10px;cursor:pointer;white-space:nowrap;}
#nodes-stats-mirror .ns-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
#nodes-stats-mirror .ns-btn:disabled{opacity:0.5;cursor:default;}
#nodes-stats-mirror .ns-mrow:hover{background:#262626;}
</style>
<div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;">
<input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off"
style="flex:1;background:#111;border:1px solid #444;border-radius:4px;color:#fff;padding:8px 10px;font-family:monospace;font-size:14px;outline:none;">
<button id="ns-mirror-refresh" class="ns-btn" title="Rebuild catalog">↻</button>
</div>
<div id="ns-mirror-results" style="overflow-y:auto;padding:6px 0;"></div>
<div id="ns-mirror-footer" style="padding:8px 12px;border-top:1px solid #333;color:#666;font-size:11px;"></div>`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const input = box.querySelector("#ns-mirror-input");
const results = box.querySelector("#ns-mirror-results");
const footer = box.querySelector("#ns-mirror-footer");
footer.textContent = "loading disabled-node catalog…";
let catalog = await ensureDisabledCatalog();
if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; return; }
if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; return; }
const packCount = new Set(catalog.map((e) => e.pack)).size;
footer.textContent = `${catalog.length} nodes across ${packCount} disabled packs · enabling needs a restart`;
function render() {
const { rows, total } = filterCatalog(catalog, input.value);
if (!input.value.trim()) {
results.innerHTML = `<div style="padding:14px;color:#666;">Type to search ${catalog.length} nodes in ${packCount} disabled packs.</div>`;
return;
}
if (total === 0) { results.innerHTML = `<div style="padding:14px;color:#666;">No disabled nodes match “${escapeHtml(input.value)}”.</div>`; return; }
let html = "";
for (const e of rows) {
html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;">
<div style="flex:1;min-width:0;">
<div style="color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.class_type)}</div>
<div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div>
</div>
<button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button>
<button class="ns-btn ns-mirror-perm" data-pkg="${escapeAttr(e.pack)}">Enable</button>
</div>`;
}
if (total > rows.length) html += `<div style="padding:8px 12px;color:#666;">+${total - rows.length} more — refine your search.</div>`;
results.innerHTML = html;
results.querySelectorAll(".ns-mirror-temp").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
}
input.addEventListener("input", render);
box.querySelector("#ns-mirror-refresh").addEventListener("click", async () => {
footer.textContent = "refreshing…";
catalog = await ensureDisabledCatalog(true) || [];
footer.textContent = `${catalog.length} nodes across ${new Set(catalog.map((e)=>e.pack)).size} disabled packs · enabling needs a restart`;
render();
});
render();
input.focus();
}
// Enable from the palette. Marks all rows for the pack as enabled on success.
async function mirrorEnable(pkg, temporary, overlay) {
const entry = (_disabledCatalog || []).find((e) => e.pack === pkg);
const info = entry && entry.info;
if (!info) return;
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = true));
try {
if (await enablePackage(pkg, info, temporary)) {
(_disabledCatalog || []).forEach((e) => { if (e.pack === pkg) e.info.state = "enabled"; });
overlay.querySelectorAll(`.ns-mirror-temp[data-pkg="${cssEscape(pkg)}"], .ns-mirror-perm[data-pkg="${cssEscape(pkg)}"]`)
.forEach((b) => { b.replaceWith(Object.assign(document.createElement("span"), { textContent: "✓ enabled · restart", style: "color:#6a6;font-size:11px;" })); });
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = false));
}
}
```
> If `cssEscape` does not already exist in the file, add the small helper used elsewhere: `function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/["\\]/g,"\\$&");}` (check first — the disable feature may already define it).
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Verify (manual)** — temporarily call `openMirrorSearch()` from the console after hard-refresh. Search a known disabled pack node (e.g. an Inspire-Pack class_type). Expected: results list; clicking Enable 7d enables the pack (verify via `/nodes-stats/trials` and getlist state flip), rows turn into "✓ enabled · restart".
**Step 4: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): mirror search palette UI + enable actions"
```
---
### Task 5: Toolbar button + keyboard shortcut
**Files:** Modify `js/nodes_stats.js` — inside the existing `setup()` (where the Node Stats button is mounted).
**Step 1: Add a second toolbar button** after the existing Node Stats button mount:
```js
const searchBtn = document.createElement("button");
searchBtn.textContent = "⌕";
searchBtn.title = "Search disabled-pack nodes (Ctrl/Cmd+Shift+D)";
searchBtn.className = "comfyui-button comfyui-menu-mobile-collapse";
searchBtn.style.cssText = "display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;font-size:16px;";
searchBtn.onclick = () => openMirrorSearch();
if (app.menu?.settingsGroup?.element) app.menu.settingsGroup.element.before(searchBtn);
else document.querySelector(".comfy-menu")?.append(searchBtn);
```
**Step 2: Register the hotkey** (guarded `keydown`, ignores typing contexts) at the end of `setup()`:
```js
window.addEventListener("keydown", (e) => {
if (!(e.shiftKey && (e.ctrlKey || e.metaKey) && (e.key === "D" || e.key === "d"))) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
e.preventDefault();
openMirrorSearch();
});
```
> If `Ctrl/Cmd+Shift+D` conflicts with a ComfyUI binding in this build, change the key here (e.g. to `K`) and update both titles.
**Step 3: Verify syntax**`node --check`. Expected `OK`.
**Step 4: Verify (manual)** — hard-refresh ComfyUI; the ⌕ button appears in the top menu; clicking it and pressing Ctrl/Cmd+Shift+D both open the palette; Esc / click-outside closes it.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(search): toolbar button + hotkey to open mirror search"
```
---
### Task 6: Docs + version bump
**Files:** Modify `README.md`, `pyproject.toml`.
**Step 1:** Add a "Mirror search (disabled-pack nodes)" subsection to the README: what it does, how to open it (⌕ button / Ctrl/Cmd+Shift+D), that results come from disabled packs, that Enable 7d/Enable take effect after restart, and that it's inert without ComfyUI Manager. Add a feature bullet.
**Step 2:** Bump `version` in `pyproject.toml` to `1.4.0`.
**Step 3: Verify**`python -m pytest -q` (unchanged, still green) and the `node --check` line (`OK`).
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document mirror search; bump to 1.4.0"
```
---
## Done criteria
- ⌕ button + Ctrl/Cmd+Shift+D open a palette that searches nodes of disabled packs (joined from getmappings × getlist), cached per session with a refresh.
- Typing filters instantly (ranked); results show `class_type` + pack with Enable 7d / Enable.
- Enabling reuses `enablePackage` → Manager enable + trial start/stop + "restart to apply" toast; rows mark "✓ enabled · restart".
- Workflow-tab enable still works (shared core, no regression).
- Inert + clear message when ComfyUI Manager is absent or there are no disabled packs.
- `python -m pytest -q` green; `node --check` clean.
@@ -0,0 +1,149 @@
# Design: Workflow tab + temporary trial-enable for disabled packages
Date: 2026-06-21
Status: Approved
## Summary
When a loaded workflow references node types that aren't currently resolvable,
Node Stats shows a **Workflow** tab that splits them into:
- **Missing** — the owning package is not installed (or unknown). Handled by
ComfyUI Manager as usual (install).
- **Disabled** — the owning package is installed but disabled (in
`custom_nodes/.disabled`). Offers two actions:
- **Enable temporarily** — re-enable the package under a rolling 7-day trial.
- **Enable permanently** — normal re-enable, never auto-disabled.
A temporarily-enabled package auto-disables again if it goes **7 distinct
boot-days without being used** in an executed prompt. Any execution use resets
the counter back to 7 (rolling window), so a package stays enabled as long as it
keeps getting used.
## Decisions (from brainstorming)
- **Graduation model:** rolling. Each execution use resets the counter to the
full budget (7). 7 distinct boot-days unused → auto-disable. No permanent
"graduation"; a trial package stays trial-managed, kept alive by use.
- **"Used" means:** the package's node appears in an *executed/queued* prompt.
Reuses the existing usage tracking. Loading a workflow does not count.
- **Granularity:** the tab lists individual node types ("list by node"), but
every action resolves to and operates on the owning *package* (Manager works
per package). Trial state is tracked per package.
- **Surfacing:** on workflow load, if any node is disabled/missing, auto-open
the Node Stats dialog to the Workflow tab.
- **Disabled actions:** both "Enable temporarily" (trial) and "Enable
permanently".
- **Missing:** defer to ComfyUI Manager (install), as usual.
- **Orchestration (Approach A):** the Python backend only tracks trial state,
counts boot-days, resets on use, and flags expiry. The frontend performs all
ComfyUI-Manager enable/disable mutations (server is up, reuses the proven
disable code, no coupling to Manager internals). Day-counting stays accurate
across headless restarts; the actual auto-disable executes the next time the
web UI loads.
## Data model
New SQLite table `trial_packages` in the existing DB:
| column | meaning |
|---|---|
| `package` (PK) | directory name (matches our package keys) |
| `enabled_at` | ISO timestamp the trial started |
| `last_use_day` | `YYYY-MM-DD` of last execution use (init = enable day) |
| `last_boot_day` | `YYYY-MM-DD` of last boot-day counted |
| `unused_boot_days` | counter, 0..budget |
| `budget` | distinct boot-days allowed unused (default 7) |
### Lifecycle
- **Start trial** (after frontend temp-enable): upsert with
`unused_boot_days=0`, `last_boot_day=today`, `last_use_day=today`,
`enabled_at=now`. The enable day never counts toward expiry ("not the same
day").
- **Boot tick** (`tracker.tick_boot_days()`, called once at `__init__` import):
for each trial row, if `last_boot_day != today`: `unused_boot_days += 1`,
`last_boot_day = today`.
- **Expiry:** computed, not stored — `expired = unused_boot_days >= budget`.
- **Use reset** (`reset_trials_for(packages)`, called from `_record_prompt`
after `record_usage`): for each used package on trial, set
`unused_boot_days = 0`, `last_use_day = today`.
- **Stop trial** (`stop_trial(package)`): delete the row. Called after permanent
enable, after the frontend disables an expired pack, or if re-disabled.
Disabling an already-disabled package via Manager is a no-op, so a stale trial
row (e.g. package disabled out-of-band) resolves cleanly on next expiry pass.
## Backend (Python)
- `tracker.py`: add table to `SCHEMA`; methods `start_trial`, `stop_trial`,
`tick_boot_days`, `reset_trials_for(packages)`, `get_trials`.
- `__init__.py`:
- At import: `tracker.tick_boot_days()` wrapped in try/except (never blocks
extension load).
- In `_record_prompt`: after `record_usage`, map used class_types → packages
and call `tracker.reset_trials_for(packages)`.
- Routes:
- `GET /nodes-stats/trials` → `[{package, unused_boot_days, budget,
days_remaining, expired, enabled_at, last_use_day}, ...]`
- `POST /nodes-stats/trials/start` `{package}` → upsert trial
- `POST /nodes-stats/trials/stop` `{package}` → delete trial
The backend never calls ComfyUI Manager.
## Frontend (JS)
- **WF-load hook:** wrap `app.loadGraphData` (and/or graph-changed event);
collect node types present in the graph but not in
`LiteGraph.registered_node_types` (unresolved).
- **Classify** unresolved types using:
- `/customnode/getmappings?mode=local` → class_type → pack key
- `/customnode/getlist?mode=local&skip_update=true` → pack install state
- pack state `disabled` → Disabled bucket; `not-installed`/unmappable →
Missing bucket. (Reconcile the getmappings pack key against getlist entries
by id/cnr_id/aux_id — verify field names during implementation.)
- **Workflow tab**, auto-opened when ≥1 unresolved node:
- Disabled rows (by node): `[Enable 7d]` (temp → Manager enable, then
`trials/start`), `[Enable]` (permanent → Manager enable, then `trials/stop`
if previously on trial). Show "on trial — N day-boots left" when applicable.
- Missing rows (by node): `[Install]` → defer to Manager install.
- Actions resolve to the owning package and dedupe.
- **Expiry execution:** on app load / dialog open, `GET /nodes-stats/trials`;
for `expired` packs, disable via Manager (reuse existing disable code) →
`trials/stop` → toast "auto-disabled N unused trial package(s)".
- Enable/disable need a restart to apply in the running session → reuse the
existing restart banner.
## Error handling
- ComfyUI Manager absent → feature inert (no classification, no actions), same
as the existing disable feature.
- HTTP/DB failures → toast + log; never corrupt trial state. Failed expiry
disable keeps the row for a later session (retry).
- Package disabled out-of-band → expiry disable no-ops; row cleaned on stop.
## Testing
- `tests/test_trials.py` (pure logic, mocked dates like existing tests):
- start_trial initializes counters; enable day not counted
- tick_boot_days increments only on a new calendar day (same-day reboots = 1)
- reset_trials_for zeroes the counter
- expiry triggers at `unused_boot_days >= budget`
- start/stop/get round-trips
- Frontend verified manually (graph load → tab → enable/disable → expiry).
## Known implementation risk
The **enable** payload (`/manager/queue/install` + `skip_post_install=true`)
needs the correct `id`/`version` for disabled packs — the same class of detail
as the disable-payload bug already fixed. Verify empirically against a live
disabled package during implementation rather than assuming.
## Files touched
- `tracker.py` — trial table + methods
- `__init__.py` — boot tick, usage reset hook, 3 routes
- `js/nodes_stats.js` — WF-load hook, classification, Workflow tab, actions,
expiry execution
- `tests/test_trials.py` — backend unit tests
- `README.md`, `pyproject.toml` — docs + version bump
+751
View File
@@ -0,0 +1,751 @@
# Workflow Tab + Temporary Trial-Enable Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Node Stats "Workflow" tab that splits a loaded workflow's unresolved nodes into Missing (defer to ComfyUI Manager) and Disabled (re-enable temporarily under a 7 distinct-boot-day rolling trial, or permanently), auto-disabling unused trial packages.
**Architecture:** Approach A — the Python backend only tracks trial state in SQLite, counts boot-days (once per `__init__` import), resets a per-package counter when the existing usage tracker sees the package executed, and exposes trial state via three routes. The frontend (JS) detects unresolved graph nodes, classifies them via ComfyUI Manager's `getmappings`/`getlist`, renders the tab, and performs all Manager enable/disable mutations (reusing the existing disable code). The backend never calls Manager.
**Tech Stack:** Python 3.12 (stdlib `sqlite3`), aiohttp routes (ComfyUI `PromptServer`), vanilla JS (ComfyUI frontend extension), pytest.
**Design doc:** `docs/plans/2026-06-21-trial-enable-design.md`
**Reference for date-mocking in tests:** existing `tests/test_model_tracker.py` patches `tracker.datetime`.
---
## Phase 1 — Backend trial state (pure logic, TDD)
All Phase 1 tasks edit `tracker.py` and `tests/test_trials.py`. Run tests with the project's `python -m pytest`.
### Task 1: Trial table + `start_trial` / `get_trials`
**Files:**
- Modify: `tracker.py` (add to `SCHEMA`, add `DEFAULT_TRIAL_BUDGET`, methods)
- Create: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
# tests/test_trials.py
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
from tracker import UsageTracker, DEFAULT_TRIAL_BUDGET
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_start_trial_initializes(tracker):
tracker.start_trial("Some-Pack")
trials = tracker.get_trials()
assert len(trials) == 1
t = trials[0]
assert t["package"] == "Some-Pack"
assert t["unused_boot_days"] == 0
assert t["budget"] == DEFAULT_TRIAL_BUDGET
assert t["days_remaining"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is False
def test_start_trial_is_idempotent_resets(tracker):
tracker.start_trial("Some-Pack")
tracker.start_trial("Some-Pack")
assert len(tracker.get_trials()) == 1
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -v`
Expected: FAIL (cannot import `DEFAULT_TRIAL_BUDGET` / no `start_trial`).
**Step 3: Write minimal implementation**
In `tracker.py`, after the `SCHEMA` string add a table (append inside the existing `SCHEMA` triple-quoted block, before the closing `"""`):
```sql
CREATE TABLE IF NOT EXISTS trial_packages (
package TEXT PRIMARY KEY,
enabled_at TEXT NOT NULL,
last_use_day TEXT NOT NULL,
last_boot_day TEXT NOT NULL,
unused_boot_days INTEGER NOT NULL DEFAULT 0,
budget INTEGER NOT NULL DEFAULT 7
);
```
Add a module constant near `EXCLUDED_PACKAGES`:
```python
DEFAULT_TRIAL_BUDGET = 7
```
Add methods to `UsageTracker`:
```python
def start_trial(self, package, budget=DEFAULT_TRIAL_BUDGET):
"""Begin/restart a temporary-enable trial. The enable day is not counted."""
now = datetime.now(timezone.utc)
today = now.date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO trial_packages
(package, enabled_at, last_use_day, last_boot_day, unused_boot_days, budget)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(package) DO UPDATE SET
enabled_at = excluded.enabled_at,
last_use_day = excluded.last_use_day,
last_boot_day = excluded.last_boot_day,
unused_boot_days = 0,
budget = excluded.budget""",
(package, now.isoformat(), today, today, budget),
)
conn.commit()
finally:
conn.close()
def get_trials(self):
"""Return trial rows with computed days_remaining/expired."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT package, enabled_at, last_use_day, last_boot_day, "
"unused_boot_days, budget FROM trial_packages"
).fetchall()
finally:
conn.close()
result = []
for r in rows:
d = dict(r)
d["days_remaining"] = max(0, d["budget"] - d["unused_boot_days"])
d["expired"] = d["unused_boot_days"] >= d["budget"]
result.append(d)
return result
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (2 tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): add trial_packages table, start_trial, get_trials"
```
---
### Task 2: `tick_boot_days` (distinct-day counting)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def _ahead(days):
return datetime.now(timezone.utc) + timedelta(days=days)
def test_tick_increments_only_on_new_day(tracker):
tracker.start_trial("Pack") # enable day, counter 0
tracker.tick_boot_days() # same day -> no change
assert tracker.get_trials()[0]["unused_boot_days"] == 0
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days() # new day -> 1
tracker.tick_boot_days() # same (mocked) day -> still 1
assert tracker.get_trials()[0]["unused_boot_days"] == 1
def test_tick_reaches_expiry(tracker):
tracker.start_trial("Pack")
for d in range(1, DEFAULT_TRIAL_BUDGET + 1):
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(d)
tracker.tick_boot_days()
t = tracker.get_trials()[0]
assert t["unused_boot_days"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is True
assert t["days_remaining"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: FAIL (no `tick_boot_days`).
**Step 3: Write minimal implementation**
```python
def tick_boot_days(self):
"""Once per distinct calendar day, age every active trial by one boot-day."""
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""UPDATE trial_packages
SET unused_boot_days = unused_boot_days + 1,
last_boot_day = ?
WHERE last_boot_day != ?""",
(today, today),
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): tick_boot_days counts distinct boot-days"
```
---
### Task 3: `reset_trials_for` (usage resets counter)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_reset_zeroes_counter(tracker):
tracker.start_trial("Pack")
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days()
assert tracker.get_trials()[0]["unused_boot_days"] == 1
tracker.reset_trials_for({"Pack", "Not-On-Trial"})
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_reset_empty_is_noop(tracker):
tracker.start_trial("Pack")
tracker.reset_trials_for(set())
assert tracker.get_trials()[0]["unused_boot_days"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: FAIL (no `reset_trials_for`).
**Step 3: Write minimal implementation**
```python
def reset_trials_for(self, packages):
"""Reset the unused-day counter for any of these packages that are on trial."""
if not packages:
return
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.executemany(
"""UPDATE trial_packages
SET unused_boot_days = 0, last_use_day = ?
WHERE package = ?""",
[(today, p) for p in packages],
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): reset_trials_for zeroes counter on use"
```
---
### Task 4: `stop_trial` + extend `reset()`
**Files:**
- Modify: `tracker.py` (add `stop_trial`; add `DELETE FROM trial_packages` to `reset`)
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_stop_trial_removes_row(tracker):
tracker.start_trial("Pack")
tracker.stop_trial("Pack")
assert tracker.get_trials() == []
def test_reset_clears_trials(tracker):
tracker.start_trial("Pack")
tracker.reset()
assert tracker.get_trials() == []
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k "stop or clears" -v`
Expected: FAIL (no `stop_trial`; `reset` leaves the row).
**Step 3: Write minimal implementation**
```python
def stop_trial(self, package):
"""End a trial (package became permanent or was disabled)."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM trial_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
```
In `reset()`, add alongside the existing deletes:
```python
conn.execute("DELETE FROM trial_packages")
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (all trial tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): stop_trial and clear trials on reset"
```
---
## Phase 2 — Wire backend into the server
### Task 5: Boot tick, usage-reset hook, and routes
**Files:**
- Modify: `__init__.py`
**Step 1: Boot tick at import.** After `model_mapper = ModelMapper()` and before/around the prompt-handler registration, add:
```python
# Age temporary-enable trials once per process start (one "boot").
try:
tracker.tick_boot_days()
except Exception:
logger.warning("nodes-stats: error ticking trial boot days", exc_info=True)
```
**Step 2: Reset trials on use.** In `_record_prompt`, after `tracker.record_usage(class_types, mapper)` succeeds, add:
```python
try:
packages = {mapper.get_package(ct) for ct in class_types}
packages.discard("__builtin__")
packages.discard("__unknown__")
tracker.reset_trials_for(packages)
except Exception:
logger.warning("nodes-stats: error resetting trials", exc_info=True)
```
**Step 3: Add routes.** After the existing `reset_stats` route:
```python
@routes.get("/nodes-stats/trials")
async def get_trials(request):
try:
return web.json_response(tracker.get_trials())
except Exception:
logger.error("nodes-stats: error getting trials", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/start")
async def start_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.start_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error starting trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/stop")
async def stop_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.stop_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error stopping trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
```
**Step 4: Verify import doesn't break.**
Run: `python -c "import ast; ast.parse(open('__init__.py').read()); print('OK')"`
Expected: `OK`. Then `python -m pytest -q` → all green (existing + trial tests).
**Step 5: Commit**
```bash
git add __init__.py
git commit -m "feat(trials): boot tick, usage reset hook, and trial routes"
```
---
## Phase 3 — Verify Manager enable payload (spike)
### Task 6: Empirically confirm the enable payload for a disabled pack
**Why:** the disable bug taught us not to assume Manager's payload shape. Enable goes through `/manager/queue/install` with `skip_post_install=true`.
**Files:** none (investigation; document findings in this plan / a code comment).
**Step 1:** Pick a currently-disabled pack from the live server (port 8189 in this environment; adjust if changed):
Run:
```bash
curl -s "http://127.0.0.1:8189/customnode/getlist?mode=local&skip_update=true" \
| python3 -c "import sys,json; d=json.load(sys.stdin); [print(k, {x:v.get(x) for x in ('id','version','files','state')}) for k,v in d['node_packs'].items() if v.get('state')=='disabled'][:5]"
```
Expected: rows with `state='disabled'` and their `id`/`version`/`files`.
**Step 2:** Enable one via the install queue mirroring Manager's UI, then verify it flips to `enabled`:
```bash
# Use the id/version/files from Step 1 for ONE pack:
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/reset" -H 'Content-Type: application/json'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/install" -H 'Content-Type: application/json' \
-d '{"id":"<ID>","version":"<VERSION>","files":<FILES_JSON>,"channel":"default","mode":"cache","skip_post_install":true,"selected_version":"<VERSION>","ui_id":"<ID>"}'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/start" -H 'Content-Type: application/json'
# poll status, then re-check getlist state for that pack == 'enabled'
```
Expected: pack state becomes `enabled` (restart still required to load it).
**Step 3:** Record the exact minimal working payload as a comment to reuse in the frontend (`enablePayload`). Likely `{id, version, files?, channel, mode, skip_post_install:true, selected_version, ui_id}`. Note any field that turned out load-bearing.
**Step 4:** (optional) revert the test enable via the disable endpoint so state is unchanged.
**No commit** (investigation only). Carry findings into Task 9.
---
## Phase 4 — Frontend (manual verification; no JS test harness)
All Phase 4 tasks edit `js/nodes_stats.js`. After each, hard-refresh the browser (Ctrl+Shift+R) and verify in ComfyUI. Sanity-check syntax after each edit:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && rm /tmp/c.mjs`.
### Task 7: Detect unresolved node types on workflow load
**Step 1:** Add a helper that returns the set of node types in the current graph not registered in LiteGraph:
```js
function unresolvedNodeTypes() {
const types = new Set();
const nodes = app.graph?._nodes || [];
for (const n of nodes) {
const t = n.type;
if (t && !LiteGraph.registered_node_types[t]) types.add(t);
}
return [...types];
}
```
**Step 2:** Hook workflow load by wrapping `app.loadGraphData` in `setup()`:
```js
const origLoad = app.loadGraphData?.bind(app);
if (origLoad) {
app.loadGraphData = function (...args) {
const r = origLoad(...args);
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
return r;
};
}
```
**Step 3:** Stub `onWorkflowLoaded` to log for now:
```js
async function onWorkflowLoaded() {
const unresolved = unresolvedNodeTypes();
if (unresolved.length) console.log("[Node Stats] unresolved:", unresolved);
}
```
**Verify:** load a workflow containing a disabled pack's node → console lists the type(s). **Commit.**
### Task 8: Classify unresolved types into Disabled vs Missing
**Step 1:** Fetch and build the class_type → pack map and pack states. Add:
```js
async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // existing: getlist -> {dir: {id,version,files,state}}
]);
if (mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
// class_type -> packKey (getmappings value is [ [class_types...], {meta} ])
const typeToPack = {};
for (const [packKey, entry] of Object.entries(mappings)) {
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
}
// index managerInfo by id/cnr_id/aux_id as well as dir-name key
const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
byAnyKey[dir] = info;
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[k] = { ...info, _dir: dir };
}
const disabled = [], missing = [];
for (const ct of types) {
const packKey = typeToPack[ct];
const info = packKey ? byAnyKey[packKey] : null;
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir || packKey, info });
else missing.push({ type: ct, pkg: packKey || null });
}
return { disabled, missing };
}
```
> Note (from Task 6): confirm `managerInfo` entries actually expose `cnr_id`/`aux_id`; if not, extend `fetchManagerInfo` to keep them so the getmappings key reconciles. `fetchManagerInfo` currently keeps `{id, version, files, state}` — add `cnr_id`/`aux_id` if present in getlist.
**Verify:** in console, call the classifier on `unresolvedNodeTypes()` for a workflow with a disabled pack → it lands in `disabled`. **Commit.**
### Task 9: Workflow tab UI + auto-open
**Step 1:** Add a third tab button `#ns-tab-workflow` next to Nodes/Models in `showStatsDialog`, a `#ns-content-workflow` container, and extend `switchTab` to handle `"workflow"`.
**Step 2:** Build the tab content from a classification result:
```js
function buildWorkflowTabContent({ disabled, missing }, trials) {
const trialByPkg = Object.fromEntries((trials || []).map(t => [t.package, t]));
let html = "";
if (!disabled.length && !missing.length) {
return `<p style="color:#666;">No missing or disabled nodes in the current workflow.</p>`;
}
if (disabled.length) {
html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const d of disabled) {
const t = trialByPkg[d.pkg];
const note = t ? `<span style="color:#6a6;font-size:11px;">on trial · ${t.days_remaining}d left</span>` : "";
html += `<tr class="ns-row-consider_removing" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(d.type)}</td>
<td style="padding:6px 8px;color:#888;">${escapeHtml(d.pkg)} ${note}</td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
<button class="ns-btn ns-enable-temp-btn" data-pkg="${escapeAttr(d.pkg)}">Enable 7d</button>
<button class="ns-btn ns-enable-perm-btn" data-pkg="${escapeAttr(d.pkg)}" style="margin-left:6px;">Enable</button>
</td></tr>`;
}
html += `</tbody></table>`;
}
if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const m of missing) {
html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td>
<td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"}
</td></tr>`;
}
html += `</tbody></table>`;
}
return html;
}
```
**Step 3:** Store the latest classification in module scope so `showStatsDialog` can render the tab, and have `onWorkflowLoaded` open the dialog on the Workflow tab when there's ≥1 item:
```js
let _lastWorkflowScan = { disabled: [], missing: [] };
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // showStatsDialog gains an optional initial-tab arg
}
}
```
**Step 4:** Give `showStatsDialog(initialTab = "nodes")` an optional arg; after building, call `switchTab(initialTab)`; render `buildWorkflowTabContent(_lastWorkflowScan, trials)` where `trials = await fetch('/nodes-stats/trials')`.
**Verify:** load a workflow with a disabled node → dialog auto-opens to Workflow tab listing it. **Commit.**
### Task 10: Enable temporary / permanent actions
**Step 1:** Add `enablePayload(dirName, info)` using the shape confirmed in Task 6, e.g.:
```js
function enablePayload(dirName, info) {
return {
id: info.id || dirName, version: info.version, files: info.files,
channel: "default", mode: "cache", skip_post_install: true,
selected_version: info.version, ui_id: dirName,
};
}
```
**Step 2:** Add `runManagerEnable(payload)` mirroring `runManagerDisable` but POSTing to `/manager/queue/install`, then `start`, then `waitForQueue()`.
**Step 3:** Wire the buttons (after the dialog is built, alongside `wireDisableButtons`):
```js
dialog.querySelectorAll(".ns-enable-temp-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
```
**Step 4:** Implement `handleEnable`:
```js
async function handleEnable(pkg, temporary, dialog) {
const info = (_lastWorkflowScan.disabled.find(d => d.pkg === pkg) || {}).info;
if (!info) return;
setDisableButtonsBusy(dialog, true);
try {
await runManagerEnable(enablePayload(pkg, info));
if (temporary) await fetch("/nodes-stats/trials/start", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
else await fetch("/nodes-stats/trials/stop", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
showRestartBanner(dialog);
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setDisableButtonsBusy(dialog, false);
}
}
```
**Verify:** click Enable 7d on a disabled node → pack flips to enabled in getlist, `/nodes-stats/trials` shows it, restart banner appears. Click Enable (permanent) on another → enabled, no trial row. **Commit.**
### Task 11: Missing → install via Manager
**Step 1:** Wire `.ns-install-btn` to install the owning pack via Manager. Resolve the pack's getlist entry (it will be `state:'not-installed'` — fetch a fresh getlist or reuse classification info), then POST `/manager/queue/install` with `selected_version:'latest'`, `skip_post_install:false`, start, wait, restart banner.
```js
async function handleInstall(pkg, dialog) {
// fetch the not-installed entry's id/version/files from getlist by pkg key
// POST /manager/queue/install {id, version, files, channel:'default', mode:'cache',
// selected_version:'latest', ui_id:pkg}; then start + waitForQueue + restart banner
}
```
> Keep this minimal — "handled by Manager like always." If resolving the install entry proves fiddly, fall back to a button that opens ComfyUI Manager's own Install-Missing UI instead of replicating install. Decide during implementation; document the choice.
**Verify:** click Install on a missing node → Manager installs it (or opens its installer). **Commit.**
### Task 12: Execute expiry on load
**Step 1:** Add `processExpiredTrials()` that fetches `/nodes-stats/trials`, and for each `expired` pack: build a disable payload (reuse `disablePayload` with a fresh `fetchManagerInfo` lookup), `runManagerDisable`, then POST `/nodes-stats/trials/stop`. Collect successes for a single toast.
```js
async function processExpiredTrials() {
let trials = [];
try { const r = await fetch("/nodes-stats/trials"); if (r.ok) trials = await r.json(); } catch { return; }
const expired = trials.filter(t => t.expired);
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") { await stopTrial(t.package); done.push(t.package); continue; }
try {
await runManagerDisable([disablePayload(t.package, info)]);
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep row for next session */ }
}
if (done.length) notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
}
```
**Step 2:** Call `processExpiredTrials()` once from `setup()` (after a short delay so the app is ready), guarded so it runs only when Manager is present.
**Verify:** with a trial row forced to `unused_boot_days >= budget` (set via DB or repeated tick), reloading ComfyUI auto-disables that pack and clears the trial. **Commit.**
---
## Phase 5 — Docs & version
### Task 13: README + version bump
**Files:** `README.md`, `pyproject.toml`
**Step 1:** Add a "Workflow tab & temporary enable" subsection to the README (what Missing vs Disabled mean, the 7 distinct-boot-day rolling trial, that use resets it, that auto-disable applies on next UI load and needs a restart). Add a feature bullet. Document the three `/nodes-stats/trials*` endpoints in the API table.
**Step 2:** Bump `version` in `pyproject.toml` to `1.3.0`.
**Step 3:** Run `python -m pytest -q` (all green) and JS `node --check`.
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document workflow tab + trial-enable; bump to 1.3.0"
```
---
## Done criteria
- `python -m pytest -q` green (existing + `tests/test_trials.py`).
- Loading a workflow with a disabled node auto-opens the Workflow tab; Enable 7d re-enables + records a trial; Enable makes it permanent.
- A trial package unused for 7 distinct boot-days auto-disables on next UI load; any execution use resets the counter.
- Missing nodes route to ComfyUI Manager.
- Feature is inert when ComfyUI Manager is absent.
@@ -0,0 +1,133 @@
# Design: Trial-install (7-day) for missing nodes on workflow load
Date: 2026-06-22
Status: Approved
## Summary
Extend the existing 7-day rolling trial from *disabled* packages to *missing*
(not-installed) ones. When a loaded workflow references node types whose owning
pack isn't installed, the Workflow tab's **Missing** section gains:
- **Install 7d** — really install the pack via ComfyUI Manager, then register a
7-day trial. If it isn't used (executed) within 7 distinct boot-days, it
auto-disables (moves to `.disabled`, reversible) via the existing expiry path.
- **Install** — really install the pack permanently (no trial).
This makes trying out someone else's workflow non-committal: pull in what it
needs, and anything you don't actually use gets parked in `.disabled` instead of
permanently bloating the install (which slows boot — the whole point of this
extension).
## Decisions (from clarification)
- **Expiry action:** auto-**disable** (not uninstall). This is free: the trial
table is keyed by package and `processExpiredTrials()` already disables any
expired trial pack and clears its row. No new expiry code.
- **Missing-row actions:** both **Install 7d** and **Install** (permanent),
symmetric with the Disabled section's Enable 7d / Enable. Both perform real
installs from our UI; the current "open ComfyUI Manager" behavior becomes the
failure fallback.
## Current state it builds on (v1.6.0, verified)
- `tracker.py`: `trial_packages` table + `start_trial` / `tick_boot_days` /
`reset_trials_for` / `stop_trial` / `get_trials`. Boot tick + usage reset +
routes (`/nodes-stats/trials[/start|/stop]`) wired in `__init__.py`.
- `js/nodes_stats.js`:
- `classifyUnresolved()` (≈624) → `{ disabled[], missing[] }`; missing entries
are `{ type, pkg: <getmappings packKey> }` where packKey is a dir name,
registry id, or repo/gist URL.
- `handleInstall(pkg, dialog)` (≈1155) currently only opens Manager's Custom
Nodes Manager.
- `runManagerEnable(payload)` (≈701) = reset → POST `/manager/queue/install`
→ start → `waitForQueue` (install and enable share this endpoint; only the
payload differs).
- `processExpiredTrials()` (≈1192) disables expired trial packs via Manager,
then `stopTrial`.
- `fetchManagerInfo()` (≈483) returns installed/disabled packs only — it
**skips** `state==="not-installed"`, so registry data for missing packs is
not in hand and must be fetched separately.
## Install resolution (the only real new logic)
Not-installed `getlist` entries expose enough to install (verified live):
- CNR pack: `id` = cnr_id, `version` = semver, `files` = `[git url]`.
- Git-only pack: no `id`, `version` = `"unknown"`, `files` = `[git url]`.
Plan:
1. **`resolveInstallTarget(packKey)`** — fetch
`/customnode/getlist?mode=local&skip_update=true`, index `not-installed`
entries by every identifier (key, `id`, `files`, `repository`) with the same
normalize used in `classifyUnresolved` (lowercase, strip trailing `/`,
`.git`), and return the matching entry (or null).
2. **`installPayload(entry)`** — mirror Manager's installNodes:
`{ id: entry.id || <key>, version: entry.version, files: entry.files,
channel:"default", mode:"cache", selected_version: entry.version==="unknown"
? "unknown" : "latest", skip_post_install:false, ui_id:<key> }`.
3. Install via the existing `runManagerEnable(payload)` (same queue endpoint).
4. **`findInstalledDir(entry)`** — re-fetch getlist; find the now-installed entry
matching `entry` by id/files/repo; its **key is the directory name** (needed
to key the trial). Fallback: repo basename of `files[0]` (strip `.git`).
5. For Install 7d: `POST /nodes-stats/trials/start { package: <dir name> }`.
6. Show the restart banner: "Installed X — restart to load it" (+ " for a 7-day
trial").
## Components (all in `js/nodes_stats.js`)
- `resolveInstallTarget(packKey)`, `installPayload(entry)`, `findInstalledDir(entry)`
— small, mostly-pure helpers.
- `handleTrialInstall(pkg, dialog, temporary)` — orchestrates resolve → install →
dir discovery → (trial start if temporary) → restart banner; on any failure
falls back to the existing open-Manager behavior of `handleInstall`.
- Missing rows render `[Install 7d]` + `[Install]`; wire them in
`wireWorkflowButtons`. Reuse `setWorkflowButtonsBusy`, `notify`,
`showRestartBanner`, `managerIsBusy`.
## Data flow
```
Workflow tab (missing row)
Install 7d -> resolveInstallTarget(packKey) -> installPayload
-> runManagerEnable (POST install, start, wait)
-> findInstalledDir -> trials/start{dir}
-> restart banner "installed for 7-day trial"
Install -> same, minus trials/start
later boots -> tracker.tick_boot_days (distinct days)
use in prompt-> tracker.reset_trials_for (counter -> 0)
expiry -> processExpiredTrials (UI load) -> Manager disable + stopTrial
```
## Error handling
- ComfyUI Manager absent → Missing actions inert (as today).
- `resolveInstallTarget` miss, install HTTP error, or git-url install blocked by
Manager security level → toast + fall back to `handleInstall`'s open-Manager
guidance. No crash.
- `findInstalledDir` returns nothing (install didn't land) → don't register a
trial; toast "installed; couldn't register trial — enable/disable manually".
- `managerIsBusy()` → ask the user to retry (same guard as enable/disable).
## Testing
- Backend unchanged → no new pytest; existing `tests/` stays green.
- Pure helpers (`resolveInstallTarget` matching, `installPayload`,
`findInstalledDir` matching) written standalone; `node --check` for syntax.
- Manual: load a workflow needing a not-installed CNR pack → Install 7d installs
it, `/nodes-stats/trials` shows the resulting dir, restart banner appears;
Install (permanent) installs without a trial row; a git-url-only/blocked pack
falls back to opening Manager; Manager-absent path inert.
## Files touched
- `js/nodes_stats.js` — resolution helpers, `handleTrialInstall`, Missing-row
buttons + wiring.
- `README.md`, `pyproject.toml` — docs + version bump (1.6.0 → 1.7.0).
## Out of scope (YAGNI)
- Auto-uninstall on expiry (chose disable; reversible + free).
- Bulk "install all missing 7d" (start per-row; revisit if wanted).
- Replicating Manager's full dependency/security UX (fall back to Manager).
+246
View File
@@ -0,0 +1,246 @@
# 7-Day Trial-Install for Missing Nodes — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Let the Workflow tab really install a workflow's missing (not-installed) packs — either permanently or on the existing 7-day rolling trial that auto-disables unused packs.
**Architecture:** Frontend-only (`js/nodes_stats.js`). Resolve a missing pack's install spec from its not-installed `getlist` entry, install via ComfyUI Manager's queue (reusing `runManagerEnable`, which posts to `/manager/queue/install`), discover the resulting directory name by re-reading `getlist`, and register a trial via the existing `/nodes-stats/trials/start`. Expiry (auto-disable) is already handled by `processExpiredTrials()` — no new expiry code. On any failure, fall back to the current open-Manager behavior.
**Tech Stack:** Vanilla JS ComfyUI extension; ComfyUI Manager HTTP endpoints; the trial machinery already shipped in v1.6.0.
**Design doc:** `docs/plans/2026-06-22-trial-install-design.md`
**Testing note:** No JS test harness. After every edit run:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && echo OK && rm /tmp/c.mjs`
Behavior is verified manually in ComfyUI (hard-refresh after each change).
**Confirmed reuse points (v1.6.0, real line numbers):** `classifyUnresolved` (≈624, builds `missing[]`), `buildWorkflowTabContent` Missing section (≈410-422), `wireWorkflowButtons` (≈698), `handleInstall` (≈1155, open-Manager fallback), `runManagerEnable` (≈701, install-queue helper), `processExpiredTrials` (≈1192), `setWorkflowButtonsBusy` (≈1172), `managerIsBusy`, `showRestartBanner`, `notify`, `escapeHtml`, `escapeAttr`.
---
### Task 1: Shared `normKey` + install-resolution helpers
**Files:** Modify `js/nodes_stats.js` (add helpers near `classifyUnresolved`, ~line 620).
**Step 1: Add a module-level `normKey`** (and point `classifyUnresolved`'s local `norm` at it for DRY):
```js
// Normalize an identifier (dir name, registry id, or repo URL) for matching.
function normKey(s) {
return String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
}
```
In `classifyUnresolved`, replace the local
`const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();`
with `const norm = normKey;`.
**Step 2: Add the resolver, payload builder, and dir finder:**
```js
// Find the not-installed getlist entry for a missing pack key (from getmappings).
// Returns { key, id?, version, files, repository, ... } or null.
async function resolveInstallTarget(packKey) {
let packs;
try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (!r.ok) return null;
packs = (await r.json()).node_packs;
} catch { return null; }
if (!packs) return null;
const want = normKey(packKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state !== "not-installed") continue;
const ids = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (ids.includes(want)) return { key, ...v };
}
return null;
}
// Build the /manager/queue/install payload, mirroring Manager's installNodes.
function installPayload(entry, packKey) {
const unknown = !entry.version || entry.version === "unknown";
const id = entry.id || packKey;
return {
id, version: entry.version || "unknown", files: entry.files,
channel: "default", mode: "cache",
selected_version: unknown ? "unknown" : "latest",
skip_post_install: false, ui_id: id,
};
}
// After install, find the now-installed directory name (the trial key) by
// re-reading getlist and matching the entry; fall back to the repo basename.
async function findInstalledDir(entry) {
let packs = null;
try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (r.ok) packs = (await r.json()).node_packs;
} catch { /* fall through to basename */ }
if (packs) {
const want = [entry.id, entry.repository, ...(entry.files || [])].filter(Boolean).map(normKey);
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state === "not-installed") continue;
const cand = [key, v.id, v.repository, ...(v.files || [])].filter(Boolean).map(normKey);
if (cand.some((c) => want.includes(c))) return key; // key = directory name
}
}
const url = (entry.files && entry.files[0]) || entry.repository || "";
return url.replace(/\/+$/, "").replace(/\.git$/i, "").split("/").pop() || null;
}
```
**Step 3: Verify syntax** — run the `node --check` line. Expected `OK`.
**Step 4: Verify resolver (browser console, after hard-refresh)** — pick a known not-installed pack key from `await (await fetch('/customnode/getlist?mode=local&skip_update=true')).json()` and confirm `resolveInstallTarget(key)` returns it. Expected: the entry with `files`/`version`.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): install-target resolution helpers for missing packs"
```
---
### Task 2: `handleTrialInstall` orchestrator
**Files:** Modify `js/nodes_stats.js` (add next to `handleInstall`, ~line 1155).
**Step 1: Add the orchestrator** (keep `handleInstall` as the fallback):
```js
// Real install of a missing pack, optionally on a 7-day trial. Resolves the
// install spec, installs via Manager, discovers the resulting directory, and
// (for a trial) registers it. Falls back to opening Manager on any failure.
async function handleTrialInstall(pkg, dialog, temporary) {
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return;
}
const target = await resolveInstallTarget(pkg);
if (!target) { await handleInstall(pkg, dialog); return; }
setWorkflowButtonsBusy(dialog, true);
try {
await runManagerEnable(installPayload(target, pkg)); // shared /manager/queue/install flow
const dir = temporary ? await findInstalledDir(target) : null;
if (temporary && dir) {
await fetch("/nodes-stats/trials/start", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: dir }),
});
}
showRestartBanner(dialog);
if (temporary && !dir) {
notify(`Installed ${pkg}, but couldn't register the trial — manage it manually. Restart to load.`, "warn");
} else {
notify(`Installed ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to load it.`, "success");
}
} catch (e) {
notify("Install failed: " + e.message + " — opening ComfyUI Manager.", "warn");
await handleInstall(pkg, dialog);
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): handleTrialInstall orchestrator with Manager fallback"
```
---
### Task 3: Missing rows — `Install 7d` + `Install` buttons
**Files:** Modify `js/nodes_stats.js``buildWorkflowTabContent` Missing section (~410-422).
**Step 1: Update the subtitle and the action cell.** Replace the section header subtitle "Not installed — install via ComfyUI Manager" with "Not installed — install, optionally on a 7-day trial", and replace the single-button cell:
```js
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
${m.pkg
? `<button class="ns-btn ns-install-temp-btn" data-pkg="${escapeAttr(m.pkg)}">Install 7d</button>
<button class="ns-btn ns-install-perm-btn" data-pkg="${escapeAttr(m.pkg)}" style="margin-left:6px;">Install</button>`
: "&mdash;"}
</td>
```
**Step 2: Verify syntax**`node --check`. Expected `OK`.
**Step 3: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): Missing rows offer Install 7d + Install"
```
---
### Task 4: Wire the new buttons + busy state
**Files:** Modify `js/nodes_stats.js``wireWorkflowButtons` (~698) and `setWorkflowButtonsBusy` (~1172).
**Step 1:** In `wireWorkflowButtons`, replace the `.ns-install-btn` wiring with:
```js
dialog.querySelectorAll(".ns-install-temp-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, true); }));
dialog.querySelectorAll(".ns-install-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); }));
```
**Step 2:** In `setWorkflowButtonsBusy`, update the selector to the new classes:
```js
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-temp-btn, .ns-install-perm-btn").forEach((b) => {
b.disabled = busy;
});
```
**Step 3: Verify syntax**`node --check`. Expected `OK`.
**Step 4: Verify (manual)** — hard-refresh; load a workflow that needs a not-installed **CNR** pack (small one, e.g. a simple utility pack). Click **Install 7d** → it installs, restart banner appears, and `await (await fetch('/nodes-stats/trials')).json()` lists the installed dir. Click **Install** on another → installs, no trial row. Try a pack that fails / git-url-blocked → falls back to opening Manager with a toast.
**Step 5: Commit**
```bash
git add js/nodes_stats.js
git commit -m "feat(install): wire Install 7d / Install buttons"
```
---
### Task 5: Docs + version bump
**Files:** Modify `README.md`, `pyproject.toml`.
**Step 1:** In the README's Workflow-tab section, document that Missing nodes can now be installed permanently or on a 7-day trial (auto-disables if unused, takes effect after restart, falls back to ComfyUI Manager on failure). Update any feature bullet.
**Step 2:** Bump `version` in `pyproject.toml` from `1.6.0` to `1.7.0`.
**Step 3: Verify**`python -m pytest -q` (unchanged, green) and the `node --check` line (`OK`).
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document 7-day trial-install for missing nodes; bump to 1.7.0"
```
---
## Done criteria
- Workflow tab Missing rows show **Install 7d** and **Install**.
- Install 7d really installs the pack (CNR via registry; git-url where Manager allows) and registers a trial keyed by the installed directory; Install does the same without a trial.
- Unused trial-installed packs auto-disable on a later UI load via the existing `processExpiredTrials` (no new expiry code); using one resets its 7-day counter.
- Failures (unresolvable spec, HTTP error, git-url blocked, Manager absent) fall back to opening ComfyUI Manager — no crash.
- `python -m pytest -q` green; `node --check` clean.
```
+1193 -65
View File
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
"""Static (no-execution) introspection of disabled custom-node packages.
The mirror-search palette previews nodes that belong to *disabled* packs. Those
packs aren't imported by ComfyUI, so their INPUT_TYPES / RETURN_TYPES are not in
/object_info. To draw a faithful node box we parse the pack's Python source with
``ast`` — we never import or execute it (importing a disabled pack could have
side effects, pull heavy deps, or fail). This yields real inputs/outputs for the
~75% of packs that declare a literal ``NODE_CLASS_MAPPINGS``; packs that build
their mappings dynamically simply report ``parseable: False`` and the frontend
falls back to a placeholder box.
"""
import ast
import logging
import os
import warnings
logger = logging.getLogger(__name__)
# Input "types" that ComfyUI renders as in-node widgets rather than sockets.
_WIDGET_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "BOOL"}
# Cache parsed pack indexes for the session, keyed by source path. Disabled
# packs don't change while ComfyUI runs, so we never invalidate.
_INDEX_CACHE = {}
def _const_str(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _parse_file(path):
try:
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
src = fh.read()
# Third-party sources often contain unescaped regex strings; ast.parse
# emits SyntaxWarning for those. Suppress to keep server logs clean.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return ast.parse(src)
except Exception:
return None
def _iter_py_files(root, limit=500):
"""Yield up to ``limit`` .py files under a pack dir (or the file itself)."""
if os.path.isfile(root):
if root.endswith(".py"):
yield root
return
count = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if not d.startswith(".") and d not in ("__pycache__", "node_modules", "js", "web", "dist")
]
for fn in filenames:
if fn.endswith(".py"):
yield os.path.join(dirpath, fn)
count += 1
if count >= limit:
return
def _string_tuple(node):
"""A RETURN_TYPES / RETURN_NAMES value -> list of strings ("*" for non-literal)."""
if isinstance(node, (ast.Tuple, ast.List)):
out = []
for e in node.elts:
s = _const_str(e)
out.append(s if s is not None else "*")
return out
s = _const_str(node)
return [s] if s is not None else None
def _extract_input_def(name, val):
"""One INPUT_TYPES entry -> {name, type, widget, default, options}."""
d = {"name": name, "type": "*", "widget": False, "default": None, "options": None}
type_node, opts_node = None, None
if isinstance(val, (ast.Tuple, ast.List)) and val.elts:
type_node = val.elts[0]
if len(val.elts) > 1:
opts_node = val.elts[1]
else:
type_node = val
s = _const_str(type_node)
if s is not None:
d["type"] = s
if s.upper() in _WIDGET_TYPES:
d["widget"] = True
elif isinstance(type_node, (ast.List, ast.Tuple)):
# Inline combo box: ["a", "b", ...]
opts = [_const_str(e) for e in type_node.elts]
opts = [o for o in opts if o is not None]
d["type"] = "COMBO"
d["widget"] = True
d["options"] = opts or None
elif isinstance(type_node, ast.Call):
# Dynamic list, e.g. folder_paths.get_filename_list(...) -> dropdown widget.
d["type"] = "COMBO"
d["widget"] = True
# else: a Name/Attribute (custom or wildcard socket type) -> keep "*", socket.
if isinstance(opts_node, ast.Dict):
for ok, ov in zip(opts_node.keys, opts_node.values):
if _const_str(ok) == "default":
try:
d["default"] = ast.literal_eval(ov)
except Exception:
d["default"] = _const_str(ov)
return d
def _extract_input_types(fn):
"""The INPUT_TYPES classmethod -> {"required": [...], "optional": [...]} or None."""
ret = None
for n in ast.walk(fn):
if isinstance(n, ast.Return) and isinstance(n.value, ast.Dict):
ret = n.value
break
if ret is None:
return None
result = {"required": [], "optional": []}
for cat_key, cat_val in zip(ret.keys, ret.values):
cat = _const_str(cat_key)
if cat not in ("required", "optional") or not isinstance(cat_val, ast.Dict):
continue
for nk, nv in zip(cat_val.keys, cat_val.values):
name = _const_str(nk)
if name is not None:
result[cat].append(_extract_input_def(name, nv))
return result
def _extract_class(cls):
info = {"input_types": None, "return_types": None, "return_names": None,
"category": None, "output_node": False}
for b in cls.body:
if isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef)) and b.name == "INPUT_TYPES":
info["input_types"] = _extract_input_types(b)
elif isinstance(b, ast.Assign):
for t in b.targets:
tn = getattr(t, "id", None)
if tn == "RETURN_TYPES":
info["return_types"] = _string_tuple(b.value)
elif tn == "RETURN_NAMES":
info["return_names"] = _string_tuple(b.value)
elif tn == "CATEGORY":
info["category"] = _const_str(b.value)
elif tn == "OUTPUT_NODE" and isinstance(b.value, ast.Constant):
info["output_node"] = bool(b.value.value)
return info
def _merge_mapping(out, dictnode):
if not isinstance(dictnode, ast.Dict):
return
for k, v in zip(dictnode.keys, dictnode.values):
key = _const_str(k)
if key is None:
continue
if isinstance(v, ast.Name):
out[key] = v.id
elif isinstance(v, ast.Call) and isinstance(v.func, ast.Name):
out[key] = v.func.id
def _merge_display(out, dictnode):
if not isinstance(dictnode, ast.Dict):
return
for k, v in zip(dictnode.keys, dictnode.values):
key, val = _const_str(k), _const_str(v)
if key is not None and val is not None:
out[key] = val
def build_pack_index(pack_path):
"""Parse a pack -> (classes, mappings, display).
classes: { ClassName: {input_types, return_types, return_names, category, output_node} }
mappings: { node_key: ClassName } (from literal NODE_CLASS_MAPPINGS / .update)
display: { node_key: "Pretty Name" }
"""
cached = _INDEX_CACHE.get(pack_path)
if cached is not None:
return cached
classes, mappings, display = {}, {}, {}
for f in _iter_py_files(pack_path):
tree = _parse_file(f)
if tree is None:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
classes.setdefault(node.name, _extract_class(node))
elif isinstance(node, ast.Assign):
for t in node.targets:
name = getattr(t, "id", None)
if name == "NODE_CLASS_MAPPINGS":
_merge_mapping(mappings, node.value)
elif name == "NODE_DISPLAY_NAME_MAPPINGS":
_merge_display(display, node.value)
elif isinstance(node, ast.Call):
fn = node.func
if (isinstance(fn, ast.Attribute) and fn.attr == "update"
and isinstance(fn.value, ast.Name) and node.args
and isinstance(node.args[0], ast.Dict)):
if fn.value.id == "NODE_CLASS_MAPPINGS":
_merge_mapping(mappings, node.args[0])
elif fn.value.id == "NODE_DISPLAY_NAME_MAPPINGS":
_merge_display(display, node.args[0])
result = (classes, mappings, display)
_INDEX_CACHE[pack_path] = result
return result
def get_node_schema(class_type, pack_path):
"""Return a render-ready schema for one node, or {parseable: False, reason}."""
classes, mappings, display = build_pack_index(pack_path)
cls_name = mappings.get(class_type)
if cls_name is None and class_type in classes:
cls_name = class_type # fall back: class_type IS the class name
if cls_name is None or cls_name not in classes:
return {"parseable": False, "reason": "dynamic_mapping" if mappings else "no_mapping"}
info = classes[cls_name]
inputs = []
it = info["input_types"]
if it:
for cat in ("required", "optional"):
for d in it[cat]:
inputs.append({**d, "required": cat == "required"})
rt = info["return_types"] or []
rn = info["return_names"] or []
outputs = []
for i, t in enumerate(rt):
nm = rn[i] if i < len(rn) and rn[i] else t
outputs.append({"name": nm or t, "type": t})
return {
"parseable": True,
"class_type": class_type,
"display_name": display.get(class_type) or class_type,
"category": info["category"],
"output_node": info["output_node"],
"inputs": inputs,
"outputs": outputs,
}
def find_disabled_pack_path(pack_name):
"""Locate a disabled pack's source under any custom_nodes/.disabled/ dir.
Matches case-insensitively and ignores any ``@version`` suffix that ComfyUI
Manager appends on disk (e.g. ``ComfyMath@nightly`` for pack ``comfymath``).
Returns an absolute path (dir or .py file) or None. Rejects path-y input.
"""
if not pack_name or any(c in pack_name for c in ("/", "\\")) or ".." in pack_name:
return None
try:
import folder_paths
roots = folder_paths.get_folder_paths("custom_nodes")
except Exception:
roots = []
target = pack_name.lower()
for root in roots:
ddir = os.path.join(root, ".disabled")
if not os.path.isdir(ddir):
continue
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target in (stem.lower(), base.lower()):
return os.path.join(ddir, e)
return None
+138
View File
@@ -0,0 +1,138 @@
"""Native enable/disable of custom-node packages, independent of ComfyUI Manager.
Disabling a pack is just a filesystem convention that ComfyUI core honors: a
directory (or ``*.py`` file) moved into ``custom_nodes/.disabled/`` is skipped at
boot, because core ignores any entry named ``.disabled`` / starting with a dot.
Manager uses the same convention. We only *move* files here — never import,
delete, or re-clone — so the operation is reversible and safe.
This is the fallback the extension uses when ComfyUI Manager is absent or does
not recognize a pack (a hand-cloned repo, an oddly-installed pack, or a loose
single-file node). A restart is required for ComfyUI to pick up the change,
exactly as with a Manager disable.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
# find_disabled_pack_path already knows how to locate a pack under .disabled/,
# including Manager's @version suffix. Import it in a way that works both as a
# package (runtime) and as a top-level module (tests add the root to sys.path).
try:
from .node_introspect import find_disabled_pack_path
except ImportError: # pragma: no cover - exercised via top-level import in tests
from node_introspect import find_disabled_pack_path
def _custom_node_roots():
import folder_paths
return [os.path.normpath(r) for r in folder_paths.get_folder_paths("custom_nodes")]
def _valid_name(pack_name):
"""Reject path-y input; pack names are plain directory/file names."""
return bool(pack_name) and not any(c in pack_name for c in ("/", "\\")) and ".." not in pack_name
def list_disabled_packs():
"""Return clean names of packs sitting in any custom_nodes/.disabled/ dir.
Strips a Manager ``@version`` suffix and a ``.py`` extension so the names
match package names as reported in the stats (and accepted by
``enable_pack_native``).
"""
names = set()
for root in _custom_node_roots():
ddir = os.path.join(root, ".disabled")
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if stem:
names.add(stem)
return names
def find_active_pack_path(pack_name):
"""Locate an installed (active) pack directly under a custom_nodes root.
Returns an absolute path to the pack directory or single ``.py`` file, or
None. Matches case-insensitively and tolerates a Manager ``@version`` suffix.
Only real packs (a directory, or a ``.py`` file) qualify.
"""
if not _valid_name(pack_name):
return None
target = pack_name.lower()
for root in _custom_node_roots():
try:
entries = os.listdir(root)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target not in (stem.lower(), base.lower(), e.lower()):
continue
full = os.path.join(root, e)
if os.path.isdir(full) or (os.path.isfile(full) and full.endswith(".py")):
return os.path.normpath(full)
return None
def disable_pack_native(pack_name):
"""Move an active pack into ``custom_nodes/.disabled/``.
Returns ``(ok, message)``. Never raises for expected conditions (pack not
found, collision, permission error); those come back as ``(False, reason)``.
"""
src = find_active_pack_path(pack_name)
if not src:
return False, "pack not found on disk"
root = os.path.dirname(src)
ddir = os.path.join(root, ".disabled")
dest = os.path.join(ddir, os.path.basename(src))
if os.path.exists(dest):
return False, "a disabled copy already exists at " + dest
try:
os.makedirs(ddir, exist_ok=True)
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native disable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "disabled"
def enable_pack_native(pack_name):
"""Move a pack from ``custom_nodes/.disabled/`` back to its root.
Returns ``(ok, message)``. Drops any Manager ``@version`` suffix from the
destination so the pack lands as a clean, importable directory name — the
same shape Manager itself restores on enable (``ComfyMath@nightly`` ->
``ComfyMath``). A single ``.py`` file keeps its extension.
"""
src = find_disabled_pack_path(pack_name)
if not src:
return False, "disabled pack not found"
ddir = os.path.dirname(src) # .../custom_nodes/.disabled
root = os.path.dirname(ddir) # .../custom_nodes
dest_name = os.path.basename(src).split("@", 1)[0]
dest = os.path.join(root, dest_name)
if os.path.exists(dest):
return False, "an active copy already exists at " + dest
try:
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native enable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "enabled"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-nodes-stats" name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages" description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.2.1" version = "1.8.0"
license = "MIT" license = "MIT"
[project.urls] [project.urls]
+131
View File
@@ -0,0 +1,131 @@
import os
import sys
from unittest.mock import MagicMock
import node_introspect as ni
_SAMPLE = '''
import folder_paths
class MyCoolNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model": ("MODEL",),
"strength": ("FLOAT", {"default": 1.5, "min": 0.0}),
"mode": (["fast", "slow"], {"default": "slow"}),
"ckpt": (folder_paths.get_filename_list("checkpoints"),),
},
"optional": {
"mask": ("MASK",),
},
}
RETURN_TYPES = ("IMAGE", "LATENT")
RETURN_NAMES = ("out_image", "out_latent")
CATEGORY = "testing/cool"
FUNCTION = "run"
def run(self, image, model, strength, mode, ckpt, mask=None):
return (image, None)
class DynamicNode:
@classmethod
def INPUT_TYPES(cls):
d = {"required": {}}
return d
RETURN_TYPES = ("STRING",)
NODE_CLASS_MAPPINGS = {"My Cool Node": MyCoolNode}
NODE_CLASS_MAPPINGS.update({"Dynamic": DynamicNode})
NODE_DISPLAY_NAME_MAPPINGS = {"My Cool Node": "My Cool Node ✨"}
'''
def _write_pack(tmp_path, body=_SAMPLE, name="nodes.py"):
ni._INDEX_CACHE.clear()
f = tmp_path / name
f.write_text(body)
return str(tmp_path)
def test_inputs_sockets_widgets_and_defaults(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("My Cool Node", pack)
assert s["parseable"] is True
assert s["display_name"] == "My Cool Node ✨"
assert s["category"] == "testing/cool"
by = {i["name"]: i for i in s["inputs"]}
# custom types are sockets
assert by["image"]["type"] == "IMAGE" and by["image"]["widget"] is False
assert by["model"]["type"] == "MODEL" and by["model"]["widget"] is False
# primitives + combos are widgets, with defaults
assert by["strength"]["widget"] is True and by["strength"]["default"] == 1.5
assert by["mode"]["type"] == "COMBO" and by["mode"]["options"] == ["fast", "slow"]
assert by["mode"]["default"] == "slow"
# folder_paths.get_filename_list(...) -> dynamic combo widget, options unknown
assert by["ckpt"]["type"] == "COMBO" and by["ckpt"]["widget"] is True
assert by["ckpt"]["options"] is None
# optional inputs are flagged
assert by["mask"]["required"] is False and by["image"]["required"] is True
def test_outputs_use_return_names(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("My Cool Node", pack)
assert [(o["name"], o["type"]) for o in s["outputs"]] == [
("out_image", "IMAGE"),
("out_latent", "LATENT"),
]
def test_mapping_update_call_is_merged(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("Dynamic", pack)
assert s["parseable"] is True
assert s["outputs"] == [{"name": "STRING", "type": "STRING"}]
assert s["inputs"] == []
def test_unknown_class_type_not_parseable(tmp_path):
pack = _write_pack(tmp_path)
s = ni.get_node_schema("Nope", pack)
assert s["parseable"] is False
assert s["reason"] == "dynamic_mapping"
def test_no_mapping_reason(tmp_path):
pack = _write_pack(tmp_path, body="class A:\n RETURN_TYPES = ('X',)\n")
s = ni.get_node_schema("A", pack)
# class_type falls back to the class name even without NODE_CLASS_MAPPINGS
assert s["parseable"] is True
s2 = ni.get_node_schema("Missing", pack)
assert s2["parseable"] is False and s2["reason"] == "no_mapping"
def test_find_disabled_pack_path_strips_version_and_case(tmp_path, monkeypatch):
cn = tmp_path / "custom_nodes"
disabled = cn / ".disabled"
disabled.mkdir(parents=True)
(disabled / "ComfyMath@nightly").mkdir()
fp = MagicMock()
fp.get_folder_paths.return_value = [str(cn)]
monkeypatch.setitem(sys.modules, "folder_paths", fp)
found = ni.find_disabled_pack_path("comfymath")
assert found == os.path.join(str(disabled), "ComfyMath@nightly")
assert ni.find_disabled_pack_path("not-there") is None
def test_find_disabled_pack_path_rejects_traversal(tmp_path):
assert ni.find_disabled_pack_path("../evil") is None
assert ni.find_disabled_pack_path("a/b") is None
assert ni.find_disabled_pack_path("") is None
+150
View File
@@ -0,0 +1,150 @@
import os
import pytest
import pack_fs
@pytest.fixture
def custom_nodes(tmp_path):
"""A custom_nodes root wired into the mocked folder_paths."""
import folder_paths
root = tmp_path / "custom_nodes"
root.mkdir()
folder_paths.get_folder_paths.return_value = [str(root)]
return root
def _make_pack(root, name):
pack = root / name
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
return pack
# --- find_active_pack_path ------------------------------------------------
def test_find_active_dir(custom_nodes):
_make_pack(custom_nodes, "MyPack")
found = pack_fs.find_active_pack_path("MyPack")
assert found == os.path.normpath(str(custom_nodes / "MyPack"))
def test_find_active_case_insensitive(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.find_active_pack_path("mypack") is not None
def test_find_active_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
found = pack_fs.find_active_pack_path("loose_node")
assert found == os.path.normpath(str(custom_nodes / "loose_node.py"))
def test_find_active_ignores_disabled_dir(custom_nodes):
(custom_nodes / ".disabled").mkdir()
_make_pack(custom_nodes / ".disabled", "MyPack")
assert pack_fs.find_active_pack_path("MyPack") is None
def test_find_active_rejects_path_traversal(custom_nodes):
assert pack_fs.find_active_pack_path("../evil") is None
assert pack_fs.find_active_pack_path("a/b") is None
assert pack_fs.find_active_pack_path("") is None
# --- disable_pack_native --------------------------------------------------
def test_disable_moves_dir_into_disabled(custom_nodes):
_make_pack(custom_nodes, "MyPack")
ok, msg = pack_fs.disable_pack_native("MyPack")
assert ok, msg
assert not (custom_nodes / "MyPack").exists()
assert (custom_nodes / ".disabled" / "MyPack" / "__init__.py").exists()
def test_disable_moves_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
ok, msg = pack_fs.disable_pack_native("loose_node")
assert ok, msg
assert not (custom_nodes / "loose_node.py").exists()
assert (custom_nodes / ".disabled" / "loose_node.py").exists()
def test_disable_missing_pack_fails(custom_nodes):
ok, msg = pack_fs.disable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_disable_collision_fails(custom_nodes):
_make_pack(custom_nodes, "MyPack")
(custom_nodes / ".disabled").mkdir()
(custom_nodes / ".disabled" / "MyPack").mkdir() # pre-existing disabled copy
ok, msg = pack_fs.disable_pack_native("MyPack")
assert not ok
assert "already exists" in msg
# original left untouched
assert (custom_nodes / "MyPack").exists()
# --- enable_pack_native ---------------------------------------------------
def test_enable_moves_back(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
ok, msg = pack_fs.enable_pack_native("MyPack")
assert ok, msg
assert (custom_nodes / "MyPack" / "__init__.py").exists()
assert not (custom_nodes / ".disabled" / "MyPack").exists()
def test_enable_missing_fails(custom_nodes):
ok, msg = pack_fs.enable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_enable_strips_version_suffix(custom_nodes):
# A Manager-disabled pack on disk carries an @version suffix; enabling should
# restore it as a clean, importable directory name.
ddir = custom_nodes / ".disabled"
ddir.mkdir()
pack = ddir / "ComfyMath@nightly"
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
ok, msg = pack_fs.enable_pack_native("ComfyMath")
assert ok, msg
assert (custom_nodes / "ComfyMath" / "__init__.py").exists()
assert not (custom_nodes / "ComfyMath@nightly").exists()
assert not (ddir / "ComfyMath@nightly").exists()
def test_disable_then_enable_roundtrip_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
assert pack_fs.disable_pack_native("loose_node")[0]
assert pack_fs.enable_pack_native("loose_node")[0]
assert (custom_nodes / "loose_node.py").exists()
# --- list_disabled_packs --------------------------------------------------
def test_list_disabled_empty(custom_nodes):
assert pack_fs.list_disabled_packs() == set()
def test_list_disabled_strips_version_and_ext(custom_nodes):
ddir = custom_nodes / ".disabled"
ddir.mkdir()
(ddir / "ComfyMath@nightly").mkdir() # Manager-style @version suffix
(ddir / "PlainPack").mkdir()
(ddir / "loose_node.py").write_text("x = 1\n") # single-file pack
assert pack_fs.list_disabled_packs() == {"ComfyMath", "PlainPack", "loose_node"}
def test_list_disabled_reflects_a_native_disable(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
assert "MyPack" in pack_fs.list_disabled_packs()
+83
View File
@@ -0,0 +1,83 @@
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
from tracker import UsageTracker, DEFAULT_TRIAL_BUDGET
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_start_trial_initializes(tracker):
tracker.start_trial("Some-Pack")
trials = tracker.get_trials()
assert len(trials) == 1
t = trials[0]
assert t["package"] == "Some-Pack"
assert t["unused_boot_days"] == 0
assert t["budget"] == DEFAULT_TRIAL_BUDGET
assert t["days_remaining"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is False
def test_start_trial_is_idempotent_resets(tracker):
tracker.start_trial("Some-Pack")
tracker.start_trial("Some-Pack")
assert len(tracker.get_trials()) == 1
def _ahead(days):
return datetime.now(timezone.utc) + timedelta(days=days)
def test_tick_increments_only_on_new_day(tracker):
tracker.start_trial("Pack") # enable day, counter 0
tracker.tick_boot_days() # same day -> no change
assert tracker.get_trials()[0]["unused_boot_days"] == 0
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days() # new day -> 1
tracker.tick_boot_days() # same (mocked) day -> still 1
assert tracker.get_trials()[0]["unused_boot_days"] == 1
def test_tick_reaches_expiry(tracker):
tracker.start_trial("Pack")
for d in range(1, DEFAULT_TRIAL_BUDGET + 1):
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(d)
tracker.tick_boot_days()
t = tracker.get_trials()[0]
assert t["unused_boot_days"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is True
assert t["days_remaining"] == 0
def test_reset_zeroes_counter(tracker):
tracker.start_trial("Pack")
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days()
assert tracker.get_trials()[0]["unused_boot_days"] == 1
tracker.reset_trials_for({"Pack", "Not-On-Trial"})
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_reset_empty_is_noop(tracker):
tracker.start_trial("Pack")
tracker.reset_trials_for(set())
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_stop_trial_removes_row(tracker):
tracker.start_trial("Pack")
tracker.stop_trial("Pack")
assert tracker.get_trials() == []
def test_reset_clears_trials(tracker):
tracker.start_trial("Pack")
tracker.reset()
assert tracker.get_trials() == []
+72
View File
@@ -0,0 +1,72 @@
import pytest
from tracker import UsageTracker
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_whitelist_starts_empty(tracker):
assert tracker.get_whitelist() == set()
def test_add_and_get(tracker):
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_add_is_idempotent(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_remove(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.remove_from_whitelist("My-Pack")
assert tracker.get_whitelist() == set()
def test_remove_absent_is_noop(tracker):
tracker.remove_from_whitelist("Nope") # must not raise
assert tracker.get_whitelist() == set()
def test_reset_clears_whitelist(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.reset()
assert tracker.get_whitelist() == set()
class _Mapper:
"""Minimal stand-in for NodePackageMapper with a fixed mapping."""
def __init__(self, mapping):
self.mapping = mapping
def get_package(self, ct):
return self.mapping.get(ct, "__unknown__")
def get_all_packages(self):
return set(self.mapping.values()) - {"__builtin__"}
def test_package_stats_flags_whitelisted(tracker):
mapper = _Mapper({"NodeA": "Pack-A", "NodeB": "Pack-B"})
tracker.record_usage(["NodeA", "NodeB"], mapper)
tracker.add_to_whitelist("Pack-A")
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
assert stats["Pack-B"]["whitelisted"] is False
def test_package_stats_whitelist_is_case_insensitive(tracker):
mapper = _Mapper({"NodeA": "Pack-A"})
tracker.record_usage(["NodeA"], mapper)
tracker.add_to_whitelist("pack-a") # different case than the package name
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
+157
View File
@@ -48,6 +48,20 @@ CREATE TABLE IF NOT EXISTS model_usage (
last_seen TEXT NOT NULL last_seen TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS trial_packages (
package TEXT PRIMARY KEY,
enabled_at TEXT NOT NULL,
last_use_day TEXT NOT NULL,
last_boot_day TEXT NOT NULL,
unused_boot_days INTEGER NOT NULL DEFAULT 0,
budget INTEGER NOT NULL DEFAULT 7
);
CREATE TABLE IF NOT EXISTS whitelist_packages (
package TEXT PRIMARY KEY,
added_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package); CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package);
CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp); CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type); CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type);
@@ -62,6 +76,9 @@ EXCLUDED_PACKAGES = {
} }
DEFAULT_TRIAL_BUDGET = 7
def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status): def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
"""Classify an ISO timestamp into a removal tier. """Classify an ISO timestamp into a removal tier.
@@ -274,6 +291,13 @@ class UsageTracker:
tracking_start, one_month_ago, two_months_ago, "unused_new" tracking_start, one_month_ago, two_months_ago, "unused_new"
) )
# Flag whitelisted packages so the UI can pull them into their own group
# and suppress disable actions. Whitelist entries are kept verbatim; match
# case-insensitively since directory names vary by how users clone/symlink.
whitelist = {w.lower() for w in self.get_whitelist()}
for entry in packages.values():
entry["whitelisted"] = entry["package"].lower() in whitelist
result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES] result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES]
result.sort(key=lambda p: p["total_executions"]) result.sort(key=lambda p: p["total_executions"])
return result return result
@@ -375,6 +399,137 @@ class UsageTracker:
finally: finally:
conn.close() conn.close()
def start_trial(self, package, budget=DEFAULT_TRIAL_BUDGET):
"""Begin/restart a temporary-enable trial. The enable day is not counted."""
now = datetime.now(timezone.utc)
today = now.date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO trial_packages
(package, enabled_at, last_use_day, last_boot_day, unused_boot_days, budget)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(package) DO UPDATE SET
enabled_at = excluded.enabled_at,
last_use_day = excluded.last_use_day,
last_boot_day = excluded.last_boot_day,
unused_boot_days = 0,
budget = excluded.budget""",
(package, now.isoformat(), today, today, budget),
)
conn.commit()
finally:
conn.close()
def get_trials(self):
"""Return trial rows with computed days_remaining/expired."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT package, enabled_at, last_use_day, last_boot_day, "
"unused_boot_days, budget FROM trial_packages"
).fetchall()
finally:
conn.close()
result = []
for r in rows:
d = dict(r)
d["days_remaining"] = max(0, d["budget"] - d["unused_boot_days"])
d["expired"] = d["unused_boot_days"] >= d["budget"]
result.append(d)
return result
def tick_boot_days(self):
"""Once per distinct calendar day, age every active trial by one boot-day."""
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""UPDATE trial_packages
SET unused_boot_days = unused_boot_days + 1,
last_boot_day = ?
WHERE last_boot_day != ?""",
(today, today),
)
conn.commit()
finally:
conn.close()
def reset_trials_for(self, packages):
"""Reset the unused-day counter for any of these packages that are on trial."""
if not packages:
return
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.executemany(
"""UPDATE trial_packages
SET unused_boot_days = 0, last_use_day = ?
WHERE package = ?""",
[(today, p) for p in packages],
)
conn.commit()
finally:
conn.close()
def stop_trial(self, package):
"""End a trial (package became permanent or was disabled)."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM trial_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
def add_to_whitelist(self, package):
"""Protect a package: keep it out of disable actions and its own group."""
now = datetime.now(timezone.utc).isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO whitelist_packages (package, added_at) VALUES (?, ?)
ON CONFLICT(package) DO NOTHING""",
(package, now),
)
conn.commit()
finally:
conn.close()
def remove_from_whitelist(self, package):
"""Remove a package from the whitelist."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM whitelist_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
def get_whitelist(self):
"""Return the set of whitelisted package names."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
rows = conn.execute("SELECT package FROM whitelist_packages").fetchall()
return {r[0] for r in rows}
finally:
conn.close()
def reset(self): def reset(self):
"""Clear all tracked data.""" """Clear all tracked data."""
with self._lock: with self._lock:
@@ -384,6 +539,8 @@ class UsageTracker:
conn.execute("DELETE FROM node_usage") conn.execute("DELETE FROM node_usage")
conn.execute("DELETE FROM prompt_log") conn.execute("DELETE FROM prompt_log")
conn.execute("DELETE FROM model_usage") conn.execute("DELETE FROM model_usage")
conn.execute("DELETE FROM trial_packages")
conn.execute("DELETE FROM whitelist_packages")
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()