Compare commits
41 Commits
4ccc772bb9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d433ba371 | |||
| 7ca7d95ef3 | |||
| 2692bb1752 | |||
| e043ec865c | |||
| 4ffaddef7c | |||
| 19b96de322 | |||
| 406a4bf9ff | |||
| 0707a76768 | |||
| a67b628140 | |||
| 73185ad638 | |||
| cc1369a38b | |||
| acaa9f0168 | |||
| 77c159a918 | |||
| 5860b232d4 | |||
| d8f94ca371 | |||
| a8fb5ae8b4 | |||
| 0dfa14384d | |||
| 8c35ac6f09 | |||
| b6635c9f3e | |||
| fb3a785027 | |||
| 8cb0e32739 | |||
| aa2f1f62bf | |||
| 62e4b9df8c | |||
| 7cd67cdda1 | |||
| c6601bc86f | |||
| c7b06f5906 | |||
| 427a509e4c | |||
| fed626685b | |||
| 743741afc6 | |||
| b5e7bf204d | |||
| 9127f8121d | |||
| c53fdd8560 | |||
| 4ebad1bd6c | |||
| 69eef149eb | |||
| 7b5fb32b31 | |||
| caaaaa3b24 | |||
| 1a2bd2bcef | |||
| 472ce00dc4 | |||
| 5df56bc643 | |||
| 0842c574d6 | |||
| ba7f503e7d |
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Test / tooling caches
|
||||||
|
.pytest_cache/
|
||||||
|
.benchmarks/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
*.db
|
||||||
@@ -15,6 +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 (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
|
||||||
@@ -57,12 +61,98 @@ 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
|
||||||
- Sections for each tier, sorted from most actionable to least
|
- Sections for each tier, sorted from most actionable to least
|
||||||
- Expandable rows — click any package to see per-node execution counts and timestamps
|
- Expandable rows — click any package to see per-node execution counts and timestamps
|
||||||
|
- **Disable** buttons on the "Safe to Remove" and "Consider Removing" tiers (see below)
|
||||||
|
|
||||||
|
### Disabling unused packages
|
||||||
|
|
||||||
|
When [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) is installed, the
|
||||||
|
"Safe to Remove" and "Consider Removing" sections show a **Disable** button on each
|
||||||
|
package, plus a **Disable all** button per section. Disabling:
|
||||||
|
|
||||||
|
- Hands off to ComfyUI Manager, which moves the package into `custom_nodes/.disabled/`
|
||||||
|
- Is fully reversible — re-enable any package from ComfyUI Manager whenever you like
|
||||||
|
- Requires a ComfyUI restart to unload the package from the running session (a banner
|
||||||
|
with a **Restart ComfyUI** button appears after disabling)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -76,7 +166,11 @@ Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A
|
|||||||
| `/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
|
||||||
@@ -163,16 +257,40 @@ 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.
|
||||||
|
|
||||||
|
## Slow ComfyUI boot? Diagnose the model-folder scan
|
||||||
|
|
||||||
|
If ComfyUI is slow to start at the "scanning model folders" / "Building node
|
||||||
|
definitions" stage, the cause is almost always model folders on a slow (often
|
||||||
|
network) filesystem: ComfyUI walks every registered model folder on each boot,
|
||||||
|
and that cache is in-memory only (lost on restart).
|
||||||
|
|
||||||
|
`tools/diagnose_model_scan.py` measures this the same way ComfyUI does and ranks
|
||||||
|
the folders by scan cost, flags network mounts and their `actimeo`/`cache`
|
||||||
|
options, and points at the worst offender. It is read-only.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/ComfyUI
|
||||||
|
python tools/diagnose_model_scan.py # 30s cap per folder
|
||||||
|
python tools/diagnose_model_scan.py --timeout 600 --warm # full timing + warm pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical fix for network-mounted model folders (CIFS): raise the attribute-cache
|
||||||
|
timeout so the kernel keeps the listing warm across restarts, e.g.
|
||||||
|
`actimeo=3600,acdirmax=3600,acregmax=3600,cache=loose`.
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
__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
|
||||||
pyproject.toml Package metadata
|
pyproject.toml Package metadata
|
||||||
tests/ Unit tests for tracker and mapper
|
tests/ Unit tests for tracker and mapper
|
||||||
```
|
```
|
||||||
|
|||||||
+175
@@ -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).
|
||||||
@@ -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 717–746).
|
||||||
|
|
||||||
|
**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
|
||||||
@@ -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>` : "—"}
|
||||||
|
</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).
|
||||||
@@ -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>`
|
||||||
|
: "—"}
|
||||||
|
</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.
|
||||||
|
```
|
||||||
+1520
-107
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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.1.0"
|
version = "1.8.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from tracker import _classify_age
|
||||||
|
|
||||||
|
|
||||||
|
def _thresholds():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return (
|
||||||
|
(now - timedelta(days=30)).isoformat(),
|
||||||
|
(now - timedelta(days=60)).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(days):
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_used():
|
||||||
|
one, two = _thresholds()
|
||||||
|
assert _classify_age(_ago(5), one, two, "used") == "used"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_unused_new():
|
||||||
|
one, two = _thresholds()
|
||||||
|
assert _classify_age(_ago(5), one, two, "unused_new") == "unused_new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_consider_removing_window():
|
||||||
|
one, two = _thresholds()
|
||||||
|
assert _classify_age(_ago(40), one, two, "used") == "consider_removing"
|
||||||
|
assert _classify_age(_ago(40), one, two, "unused_new") == "consider_removing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_to_remove_window():
|
||||||
|
one, two = _thresholds()
|
||||||
|
assert _classify_age(_ago(70), one, two, "used") == "safe_to_remove"
|
||||||
|
assert _classify_age(_ago(70), one, two, "unused_new") == "safe_to_remove"
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_timestamp_is_recent():
|
||||||
|
one, two = _thresholds()
|
||||||
|
# No history yet -> treated as recent, never a removal candidate
|
||||||
|
assert _classify_age(None, one, two, "unused_new") == "unused_new"
|
||||||
|
assert _classify_age(None, one, two, "used") == "used"
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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() == []
|
||||||
@@ -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
|
||||||
Executable
+356
@@ -0,0 +1,356 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Diagnose ComfyUI's boot-time model-folder scan (the "scanning model folders" step).
|
||||||
|
|
||||||
|
ComfyUI builds its node definitions on every boot. Model-loader nodes enumerate
|
||||||
|
their dropdowns via ``folder_paths.get_filename_list()``, which does a recursive
|
||||||
|
walk (``os.walk`` + ``getmtime`` per directory) of every registered model folder.
|
||||||
|
That cache is in-memory only, so it happens cold on every restart — and when the
|
||||||
|
model folders live on a network share (CIFS/NFS), each directory stat is a round
|
||||||
|
trip and the walk can take minutes.
|
||||||
|
|
||||||
|
This script measures that cost the same way ComfyUI does, per folder, and shows:
|
||||||
|
- which model folders are slow (ranked),
|
||||||
|
- which sit on network filesystems and with what mount options (actimeo/cache),
|
||||||
|
- file/dir counts, throughput, and a warm-cache comparison.
|
||||||
|
|
||||||
|
It is READ-ONLY and changes nothing. Run it from your ComfyUI root, or pass
|
||||||
|
``--comfy-root``:
|
||||||
|
|
||||||
|
python tools/diagnose_model_scan.py
|
||||||
|
python tools/diagnose_model_scan.py --comfy-root /media/p5/Comfyui --timeout 30 --warm
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
NETWORK_FS = {"cifs", "smb3", "smbfs", "nfs", "nfs4", "fuse.sshfs", "fuse.glusterfs"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Environment discovery
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def find_comfy_root(explicit):
|
||||||
|
"""Locate the ComfyUI root (the dir containing folder_paths.py)."""
|
||||||
|
candidates = []
|
||||||
|
if explicit:
|
||||||
|
candidates.append(explicit)
|
||||||
|
candidates.append(os.getcwd())
|
||||||
|
# Walk up from cwd.
|
||||||
|
d = os.getcwd()
|
||||||
|
for _ in range(6):
|
||||||
|
candidates.append(d)
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.isfile(os.path.join(c, "folder_paths.py")):
|
||||||
|
return os.path.abspath(c)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_folder_paths(root, extra_configs):
|
||||||
|
"""Import ComfyUI's folder_paths and apply extra_model_paths.yaml.
|
||||||
|
|
||||||
|
Returns (folder_paths_module_or_None, list_of_notes).
|
||||||
|
"""
|
||||||
|
notes = []
|
||||||
|
if root and root not in sys.path:
|
||||||
|
sys.path.insert(0, root)
|
||||||
|
# folder_paths imports comfy.cli_args, which parses sys.argv at import time.
|
||||||
|
# Hide our own flags so it sees ComfyUI defaults instead of erroring out.
|
||||||
|
saved_argv = sys.argv
|
||||||
|
sys.argv = [saved_argv[0]]
|
||||||
|
try:
|
||||||
|
import folder_paths # type: ignore
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
notes.append(f"could not import folder_paths ({e}); falling back to <root>/models/*")
|
||||||
|
return None, notes
|
||||||
|
finally:
|
||||||
|
sys.argv = saved_argv
|
||||||
|
|
||||||
|
# Mirror main.py: load extra_model_paths.yaml + any explicit configs.
|
||||||
|
yaml_paths = []
|
||||||
|
default_yaml = os.path.join(root, "extra_model_paths.yaml")
|
||||||
|
if os.path.isfile(default_yaml):
|
||||||
|
yaml_paths.append(default_yaml)
|
||||||
|
yaml_paths.extend(extra_configs or [])
|
||||||
|
|
||||||
|
if yaml_paths:
|
||||||
|
try:
|
||||||
|
from utils.extra_config import load_extra_path_config # type: ignore
|
||||||
|
for yp in yaml_paths:
|
||||||
|
load_extra_path_config(yp)
|
||||||
|
notes.append(f"loaded extra paths: {yp}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
notes.append(f"could not load extra_model_paths.yaml ({e}); NAS folders may be missing")
|
||||||
|
|
||||||
|
return folder_paths, notes
|
||||||
|
|
||||||
|
|
||||||
|
def folder_targets(folder_paths, root):
|
||||||
|
"""Return [(folder_type, [paths], extensions_set_or_None), ...]."""
|
||||||
|
if folder_paths is not None and getattr(folder_paths, "folder_names_and_paths", None):
|
||||||
|
out = []
|
||||||
|
for ftype, value in folder_paths.folder_names_and_paths.items():
|
||||||
|
paths, exts = value[0], value[1]
|
||||||
|
exts = {e.lower() for e in exts} if exts else None
|
||||||
|
out.append((ftype, list(paths), exts))
|
||||||
|
return out
|
||||||
|
# Fallback: scan <root>/models/* with no extension filter.
|
||||||
|
models = os.path.join(root or os.getcwd(), "models")
|
||||||
|
out = []
|
||||||
|
if os.path.isdir(models):
|
||||||
|
for name in sorted(os.listdir(models)):
|
||||||
|
p = os.path.join(models, name)
|
||||||
|
if os.path.isdir(p):
|
||||||
|
out.append((name, [p], None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mount analysis
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def parse_mounts():
|
||||||
|
"""Return list of (mountpoint, fstype, options) longest-first."""
|
||||||
|
mounts = []
|
||||||
|
try:
|
||||||
|
with open("/proc/mounts", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
# device, mountpoint, fstype, options
|
||||||
|
mp = parts[1].replace("\\040", " ")
|
||||||
|
mounts.append((mp, parts[2], parts[3]))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
mounts.sort(key=lambda m: len(m[0]), reverse=True)
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
def mount_for(real_path, mounts):
|
||||||
|
for mp, fstype, opts in mounts:
|
||||||
|
if real_path == mp or real_path.startswith(mp.rstrip("/") + "/"):
|
||||||
|
return mp, fstype, opts
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def opt_value(opts, key):
|
||||||
|
for o in opts.split(","):
|
||||||
|
if o == key:
|
||||||
|
return ""
|
||||||
|
if o.startswith(key + "="):
|
||||||
|
return o.split("=", 1)[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Timed walk (mirrors folder_paths.recursive_search cost)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def timed_walk(path, exts, timeout):
|
||||||
|
"""Walk like recursive_search: readdir + getmtime per directory.
|
||||||
|
|
||||||
|
Returns dict with files, matched, dirs, elapsed, timed_out, missing.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return {"missing": True}
|
||||||
|
start = time.perf_counter()
|
||||||
|
files = matched = dirs = 0
|
||||||
|
timed_out = False
|
||||||
|
try:
|
||||||
|
os.path.getmtime(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for dirpath, subdirs, filenames in os.walk(path, followlinks=True, topdown=True):
|
||||||
|
subdirs[:] = [d for d in subdirs if d != ".git"]
|
||||||
|
dirs += 1
|
||||||
|
for fn in filenames:
|
||||||
|
files += 1
|
||||||
|
if exts is None or os.path.splitext(fn)[1].lower() in exts:
|
||||||
|
matched += 1
|
||||||
|
# recursive_search stats every subdirectory — replicate that round-trip cost.
|
||||||
|
for d in subdirs:
|
||||||
|
try:
|
||||||
|
os.path.getmtime(os.path.join(dirpath, d))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if time.perf_counter() - start > timeout:
|
||||||
|
timed_out = True
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"missing": False,
|
||||||
|
"files": files,
|
||||||
|
"matched": matched,
|
||||||
|
"dirs": dirs,
|
||||||
|
"elapsed": time.perf_counter() - start,
|
||||||
|
"timed_out": timed_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Reporting
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def human(n):
|
||||||
|
return f"{n:,}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description="Diagnose ComfyUI model-folder scan cost.")
|
||||||
|
ap.add_argument("--comfy-root", help="Path to ComfyUI root (default: auto-detect).")
|
||||||
|
ap.add_argument("--extra-model-paths-config", action="append", default=[],
|
||||||
|
help="Additional extra_model_paths.yaml to load (repeatable).")
|
||||||
|
ap.add_argument("--timeout", type=float, default=30.0,
|
||||||
|
help="Per-folder walk timeout in seconds (default: 30).")
|
||||||
|
ap.add_argument("--warm", action="store_true",
|
||||||
|
help="Do a second pass per folder to show warm-cache speedup.")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
root = find_comfy_root(args.comfy_root)
|
||||||
|
print("ComfyUI model-scan diagnostic")
|
||||||
|
print("=" * 60)
|
||||||
|
if not root:
|
||||||
|
print("! Could not find ComfyUI root (no folder_paths.py).")
|
||||||
|
print(" Run from the ComfyUI directory or pass --comfy-root.")
|
||||||
|
return 2
|
||||||
|
print(f"ComfyUI root : {root}")
|
||||||
|
|
||||||
|
folder_paths, notes = load_folder_paths(root, args.extra_model_paths_config)
|
||||||
|
for n in notes:
|
||||||
|
print(f" - {n}")
|
||||||
|
if folder_paths is not None:
|
||||||
|
cache = getattr(folder_paths, "filename_list_cache", {})
|
||||||
|
print(f"filename_list_cache : {len(cache)} entries (in this process; "
|
||||||
|
f"empty here is normal — it's per-process and wiped on every boot)")
|
||||||
|
|
||||||
|
targets = folder_targets(folder_paths, root)
|
||||||
|
print(f"Model folder types : {len(targets)}")
|
||||||
|
print(f"Per-folder timeout : {args.timeout:g}s")
|
||||||
|
print()
|
||||||
|
|
||||||
|
mounts = parse_mounts()
|
||||||
|
|
||||||
|
# Memoize by realpath so shared dirs aren't walked twice.
|
||||||
|
walk_cache = {}
|
||||||
|
rows = []
|
||||||
|
used_mounts = {}
|
||||||
|
|
||||||
|
for ftype, paths, exts in targets:
|
||||||
|
for p in paths:
|
||||||
|
rp = os.path.realpath(p)
|
||||||
|
m = mount_for(rp, mounts)
|
||||||
|
if m:
|
||||||
|
used_mounts[m[0]] = m
|
||||||
|
if rp in walk_cache:
|
||||||
|
res = walk_cache[rp]
|
||||||
|
else:
|
||||||
|
res = timed_walk(rp, exts, args.timeout)
|
||||||
|
if args.warm and not res.get("missing") and not res.get("timed_out"):
|
||||||
|
res["warm"] = timed_walk(rp, exts, args.timeout)["elapsed"]
|
||||||
|
walk_cache[rp] = res
|
||||||
|
rows.append((ftype, p, rp, m, res))
|
||||||
|
|
||||||
|
# Rank slowest first (timed-out treated as the worst).
|
||||||
|
def sortkey(row):
|
||||||
|
res = row[4]
|
||||||
|
if res.get("missing"):
|
||||||
|
return -1.0
|
||||||
|
return (args.timeout + 1) if res.get("timed_out") else res["elapsed"]
|
||||||
|
|
||||||
|
rows.sort(key=sortkey, reverse=True)
|
||||||
|
|
||||||
|
print("Per-folder scan cost (slowest first)")
|
||||||
|
print("-" * 60)
|
||||||
|
hdr = f"{'TIME':>9} {'NET':>3} {'FILES':>8} {'DIRS':>6} TYPE / PATH"
|
||||||
|
print(hdr)
|
||||||
|
total = 0.0
|
||||||
|
any_timeout = False
|
||||||
|
for ftype, p, rp, m, res in rows:
|
||||||
|
if res.get("missing"):
|
||||||
|
print(f"{'missing':>9} {'-':>3} {'-':>8} {'-':>6} {ftype} {p}")
|
||||||
|
continue
|
||||||
|
net = "yes" if (m and m[1] in NETWORK_FS) else "no"
|
||||||
|
t = res["elapsed"]
|
||||||
|
total += t
|
||||||
|
tstr = f">{args.timeout:g}s" if res["timed_out"] else f"{t:.2f}s"
|
||||||
|
if res["timed_out"]:
|
||||||
|
any_timeout = True
|
||||||
|
warm = f" (warm {res['warm']:.2f}s)" if "warm" in res else ""
|
||||||
|
loc = rp if rp == p else f"{p} -> {rp}"
|
||||||
|
print(f"{tstr:>9} {net:>3} {human(res['matched']):>8} {human(res['dirs']):>6} {ftype}{warm}")
|
||||||
|
print(f"{'':>9} {'':>3} {'':>8} {'':>6} {loc}")
|
||||||
|
print("-" * 60)
|
||||||
|
approx = "+ (timed-out folders not fully counted)" if any_timeout else ""
|
||||||
|
print(f"Measured walk total: {total:.1f}s {approx}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Mount summary.
|
||||||
|
if used_mounts:
|
||||||
|
print("Mounts hosting model folders")
|
||||||
|
print("-" * 60)
|
||||||
|
for mp, (mp2, fstype, opts) in sorted(used_mounts.items()):
|
||||||
|
net = fstype in NETWORK_FS
|
||||||
|
tag = "NETWORK" if net else "local"
|
||||||
|
line = f" {mp} [{fstype}, {tag}]"
|
||||||
|
if net:
|
||||||
|
actimeo = opt_value(opts, "actimeo")
|
||||||
|
acdir = opt_value(opts, "acdirmax")
|
||||||
|
cache = opt_value(opts, "cache")
|
||||||
|
bits = []
|
||||||
|
bits.append(f"actimeo={actimeo if actimeo is not None else '1 (default)'}")
|
||||||
|
if acdir is not None:
|
||||||
|
bits.append(f"acdirmax={acdir}")
|
||||||
|
bits.append(f"cache={cache if cache is not None else 'default'}")
|
||||||
|
line += " " + ", ".join(bits)
|
||||||
|
print(line)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Findings + recommendations.
|
||||||
|
print("Findings & recommendations")
|
||||||
|
print("-" * 60)
|
||||||
|
slow_net = []
|
||||||
|
for mp, (mp2, fstype, opts) in used_mounts.items():
|
||||||
|
if fstype in NETWORK_FS:
|
||||||
|
actimeo = opt_value(opts, "actimeo")
|
||||||
|
low = actimeo is None or _as_float(actimeo) is not None and _as_float(actimeo) <= 5
|
||||||
|
slow_net.append((mp, actimeo, low))
|
||||||
|
|
||||||
|
if not slow_net:
|
||||||
|
print(" + No model folders on network filesystems. Boot scan cost is local I/O;")
|
||||||
|
print(" if it's still slow, reduce the number of files/custom-node model types.")
|
||||||
|
else:
|
||||||
|
print(f" ! {len(slow_net)} model mount(s) are on the network — these dominate boot.")
|
||||||
|
if any(low for _, _, low in slow_net):
|
||||||
|
print(" ! Low/absent actimeo: the metadata cache expires every ~1s, so a multi-")
|
||||||
|
print(" minute walk constantly re-fetches attributes it just read.")
|
||||||
|
print(" Fix: raise it on the model/lora mounts, e.g.")
|
||||||
|
print(" actimeo=3600,acdirmax=3600,acregmax=3600,cache=loose")
|
||||||
|
print(" The CIFS cache lives in the kernel, so once warm, ComfyUI *restarts*")
|
||||||
|
print(" within the window reuse it and boot fast.")
|
||||||
|
timed = [(ftype, rp, res) for ftype, p, rp, m, res in rows
|
||||||
|
if not res.get("missing") and res.get("timed_out")]
|
||||||
|
if timed:
|
||||||
|
print(f" ! Primary suspects — folders that didn't finish within {args.timeout:g}s:")
|
||||||
|
for ftype, rp, res in timed:
|
||||||
|
print(f" {ftype}: {rp} ({human(res['dirs'])}+ dirs, {human(res['matched'])}+ files)")
|
||||||
|
print(" Re-run with a larger --timeout to measure their full cost.")
|
||||||
|
print(" + filename_list_cache is per-process and lost on restart; a persistent")
|
||||||
|
print(" snapshot served at boot (trust-cache + background refresh) eliminates the")
|
||||||
|
print(" network walk entirely.")
|
||||||
|
print(" + Fewer custom-node packages = fewer model folder types to scan + smaller")
|
||||||
|
print(" /object_info build.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(s):
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+189
-28
@@ -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,29 @@ EXCLUDED_PACKAGES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TRIAL_BUDGET = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
|
||||||
|
"""Classify an ISO timestamp into a removal tier.
|
||||||
|
|
||||||
|
Shared by node-package and model classification so both age the same way.
|
||||||
|
|
||||||
|
timestamp: ISO string of the relevant activity — last_seen for items that
|
||||||
|
have been used, or the tracking start time for never-used items. A
|
||||||
|
None timestamp is treated as recent (not enough history to judge).
|
||||||
|
recent_status: status to return when the timestamp is recent — "used" for
|
||||||
|
items with recorded usage, "unused_new" for never-used items.
|
||||||
|
"""
|
||||||
|
if timestamp is None:
|
||||||
|
return recent_status
|
||||||
|
if timestamp < two_months_ago:
|
||||||
|
return "safe_to_remove"
|
||||||
|
if timestamp < one_month_ago:
|
||||||
|
return "consider_removing"
|
||||||
|
return recent_status
|
||||||
|
|
||||||
|
|
||||||
class UsageTracker:
|
class UsageTracker:
|
||||||
def __init__(self, db_path=DB_PATH):
|
def __init__(self, db_path=DB_PATH):
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
@@ -245,22 +282,21 @@ class UsageTracker:
|
|||||||
entry["status"] = "uninstalled"
|
entry["status"] = "uninstalled"
|
||||||
elif entry["total_executions"] > 0:
|
elif entry["total_executions"] > 0:
|
||||||
# Used packages: classify by last_seen recency
|
# Used packages: classify by last_seen recency
|
||||||
if entry["last_seen"] < two_months_ago:
|
entry["status"] = _classify_age(
|
||||||
entry["status"] = "safe_to_remove"
|
entry["last_seen"], one_month_ago, two_months_ago, "used"
|
||||||
elif entry["last_seen"] < one_month_ago:
|
)
|
||||||
entry["status"] = "consider_removing"
|
|
||||||
else:
|
|
||||||
entry["status"] = "used"
|
|
||||||
else:
|
else:
|
||||||
# Never-used packages: classify by how long we've been tracking
|
# Never-used packages: classify by how long we've been tracking
|
||||||
if tracking_start is None:
|
entry["status"] = _classify_age(
|
||||||
entry["status"] = "unused_new"
|
tracking_start, one_month_ago, two_months_ago, "unused_new"
|
||||||
elif tracking_start < two_months_ago:
|
)
|
||||||
entry["status"] = "safe_to_remove"
|
|
||||||
elif tracking_start < one_month_ago:
|
# Flag whitelisted packages so the UI can pull them into their own group
|
||||||
entry["status"] = "consider_removing"
|
# and suppress disable actions. Whitelist entries are kept verbatim; match
|
||||||
else:
|
# case-insensitively since directory names vary by how users clone/symlink.
|
||||||
entry["status"] = "unused_new"
|
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"])
|
||||||
@@ -296,12 +332,9 @@ class UsageTracker:
|
|||||||
if model_name in db_models:
|
if model_name in db_models:
|
||||||
row = db_models[model_name]
|
row = db_models[model_name]
|
||||||
last_seen = row["last_seen"]
|
last_seen = row["last_seen"]
|
||||||
if last_seen < two_months_ago:
|
status = _classify_age(
|
||||||
status = "safe_to_remove"
|
last_seen, one_month_ago, two_months_ago, "used"
|
||||||
elif last_seen < one_month_ago:
|
)
|
||||||
status = "consider_removing"
|
|
||||||
else:
|
|
||||||
status = "used"
|
|
||||||
entry = {
|
entry = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"model_type": model_type,
|
"model_type": model_type,
|
||||||
@@ -312,14 +345,9 @@ class UsageTracker:
|
|||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
if tracking_start is None:
|
status = _classify_age(
|
||||||
status = "unused_new"
|
tracking_start, one_month_ago, two_months_ago, "unused_new"
|
||||||
elif tracking_start < two_months_ago:
|
)
|
||||||
status = "safe_to_remove"
|
|
||||||
elif tracking_start < one_month_ago:
|
|
||||||
status = "consider_removing"
|
|
||||||
else:
|
|
||||||
status = "unused_new"
|
|
||||||
entry = {
|
entry = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"model_type": model_type,
|
"model_type": model_type,
|
||||||
@@ -371,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:
|
||||||
@@ -380,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user