Compare commits
15 Commits
472ce00dc4
...
62e4b9df8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 62e4b9df8c | |||
| 7cd67cdda1 | |||
| c6601bc86f | |||
| c7b06f5906 | |||
| 427a509e4c | |||
| fed626685b | |||
| 743741afc6 | |||
| b5e7bf204d | |||
| 9127f8121d | |||
| c53fdd8560 | |||
| 4ebad1bd6c | |||
| 69eef149eb | |||
| 7b5fb32b31 | |||
| caaaaa3b24 | |||
| 1a2bd2bcef |
@@ -16,6 +16,7 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo
|
|||||||
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
|
- **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
|
||||||
- **Expandable detail** — click any package to see individual node-level stats
|
- **Expandable detail** — click any package to see individual node-level stats
|
||||||
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time
|
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time
|
||||||
|
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused
|
||||||
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
|
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
|
||||||
|
|
||||||
## Package Classification
|
## Package Classification
|
||||||
@@ -58,7 +59,7 @@ Restart ComfyUI. Tracking starts immediately and silently.
|
|||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with two tabs:
|
Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A dialog opens with three tabs:
|
||||||
|
|
||||||
**Nodes tab**
|
**Nodes tab**
|
||||||
- Summary bar with counts for each classification tier
|
- Summary bar with counts for each classification tier
|
||||||
@@ -79,6 +80,33 @@ package, plus a **Disable all** button per section. Disabling:
|
|||||||
|
|
||||||
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
|
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
|
||||||
|
|
||||||
|
### 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. Install is handled by
|
||||||
|
[ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) like always: the
|
||||||
|
**Install** button opens 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** 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).
|
||||||
|
|
||||||
**Models tab**
|
**Models tab**
|
||||||
- Summary bar with counts for each tier across all model types
|
- Summary bar with counts for each tier across all model types
|
||||||
- Sections per model type (checkpoints, vae, controlnet, …)
|
- Sections per model type (checkpoints, vae, controlnet, …)
|
||||||
@@ -92,6 +120,9 @@ If ComfyUI Manager is not installed, the disable buttons are hidden and stats wo
|
|||||||
| `/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/reset` | POST | Clear all tracked data |
|
| `/nodes-stats/reset` | POST | Clear all tracked data |
|
||||||
|
| `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` |
|
||||||
|
| `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` |
|
||||||
|
| `/nodes-stats/trials/stop` | POST | End a trial (made permanent or disabled) — body `{"package": "<dir-name>"}` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool
|
curl http://localhost:8188/nodes-stats/packages | python3 -m json.tool
|
||||||
@@ -178,6 +209,7 @@ All data is stored in `<ComfyUI user dir>/nodes_stats/usage_stats.db` (survives
|
|||||||
| `node_usage` | Per-node: class_type, package, execution count, first/last seen |
|
| `node_usage` | Per-node: class_type, package, execution count, first/last seen |
|
||||||
| `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used |
|
| `prompt_log` | Per-prompt: timestamp, JSON array of all class_types used |
|
||||||
| `model_usage` | Per-model: filename, type, execution count, first/last seen |
|
| `model_usage` | Per-model: filename, type, execution count, first/last seen |
|
||||||
|
| `trial_packages` | Per temporary-enable trial: package, enable date, unused-boot-day counter, budget |
|
||||||
|
|
||||||
Use `POST /nodes-stats/reset` to clear all data and start fresh.
|
Use `POST /nodes-stats/reset` to clear all data and start fresh.
|
||||||
|
|
||||||
@@ -208,7 +240,7 @@ timeout so the kernel keeps the listing warm across restarts, e.g.
|
|||||||
__init__.py Entry point: prompt handler, API routes
|
__init__.py Entry point: prompt handler, API routes
|
||||||
mapper.py class_type → package mapping; model filename → type mapping
|
mapper.py class_type → package mapping; model filename → type mapping
|
||||||
tracker.py SQLite persistence and stats aggregation
|
tracker.py SQLite persistence and stats aggregation
|
||||||
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models tabs)
|
js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs)
|
||||||
tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
|
tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
|
||||||
pyproject.toml Package metadata
|
pyproject.toml Package metadata
|
||||||
tests/ Unit tests for tracker and mapper
|
tests/ Unit tests for tracker and mapper
|
||||||
|
|||||||
+51
@@ -44,6 +44,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 +59,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 +113,40 @@ 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/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,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.
|
||||||
+334
-16
@@ -44,22 +44,61 @@ app.registerExtension({
|
|||||||
menu.append(btn);
|
menu.append(btn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect missing/disabled nodes whenever a workflow is loaded.
|
||||||
|
const origLoad = app.loadGraphData?.bind(app);
|
||||||
|
if (origLoad) {
|
||||||
|
app.loadGraphData = function (...args) {
|
||||||
|
const r = origLoad(...args);
|
||||||
|
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the app has settled, auto-disable trial packages that went unused for
|
||||||
|
// their full budget of distinct boot-days. Inert when ComfyUI Manager is absent.
|
||||||
|
setTimeout(() => { processExpiredTrials().catch(() => {}); }, 3000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function showStatsDialog() {
|
// Return the set of node types present in the current graph that LiteGraph
|
||||||
let data, modelData, managerInfo;
|
// doesn't have registered — i.e. nodes from missing or disabled packages.
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
|
||||||
|
let _lastWorkflowScan = { disabled: [], missing: [] };
|
||||||
|
|
||||||
|
async function onWorkflowLoaded() {
|
||||||
|
const types = unresolvedNodeTypes();
|
||||||
|
_lastWorkflowScan = await classifyUnresolved(types);
|
||||||
|
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
|
||||||
|
showStatsDialog("workflow"); // auto-open on the Workflow tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showStatsDialog(initialTab = "nodes") {
|
||||||
|
let data, modelData, managerInfo, trials = [];
|
||||||
try {
|
try {
|
||||||
const [pkgResp, modelResp, mgr] = await Promise.all([
|
const [pkgResp, modelResp, mgr, trialsResp] = await Promise.all([
|
||||||
fetch("/nodes-stats/packages"),
|
fetch("/nodes-stats/packages"),
|
||||||
fetch("/nodes-stats/models"),
|
fetch("/nodes-stats/models"),
|
||||||
fetchManagerInfo(),
|
fetchManagerInfo(),
|
||||||
|
fetch("/nodes-stats/trials").catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
|
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
|
||||||
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
|
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
|
||||||
data = await pkgResp.json();
|
data = await pkgResp.json();
|
||||||
modelData = await modelResp.json();
|
modelData = await modelResp.json();
|
||||||
managerInfo = mgr;
|
managerInfo = mgr;
|
||||||
|
if (trialsResp && trialsResp.ok) { try { trials = await trialsResp.json(); } catch { trials = []; } }
|
||||||
if (!Array.isArray(data) || !Array.isArray(modelData)) {
|
if (!Array.isArray(data) || !Array.isArray(modelData)) {
|
||||||
alert("Failed to load stats: unexpected response format");
|
alert("Failed to load stats: unexpected response format");
|
||||||
return;
|
return;
|
||||||
@@ -105,6 +144,10 @@ async function showStatsDialog() {
|
|||||||
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
|
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
|
||||||
Models
|
Models
|
||||||
</button>
|
</button>
|
||||||
|
<button id="ns-tab-workflow"
|
||||||
|
style="background:none;border:none;border-bottom:2px solid transparent;color:#888;padding:8px 18px;cursor:pointer;font-family:monospace;font-size:13px;">
|
||||||
|
Workflow
|
||||||
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Nodes tab content
|
// Nodes tab content
|
||||||
@@ -117,25 +160,29 @@ async function showStatsDialog() {
|
|||||||
html += buildModelsTabContent(modelData);
|
html += buildModelsTabContent(modelData);
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Workflow tab content (missing / disabled nodes in the loaded workflow)
|
||||||
|
html += `<div id="ns-content-workflow" style="display:none;">`;
|
||||||
|
html += buildWorkflowTabContent(_lastWorkflowScan, trials);
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
dialog.innerHTML = html;
|
dialog.innerHTML = html;
|
||||||
overlay.appendChild(dialog);
|
overlay.appendChild(dialog);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
// Tab switch — local function, no window pollution
|
// Tab switch — local function, no window pollution
|
||||||
|
const TABS = ["nodes", "models", "workflow"];
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
dialog.querySelector("#ns-content-nodes").style.display = tab === "nodes" ? "" : "none";
|
for (const t of TABS) {
|
||||||
dialog.querySelector("#ns-content-models").style.display = tab === "models" ? "" : "none";
|
dialog.querySelector(`#ns-content-${t}`).style.display = t === tab ? "" : "none";
|
||||||
const nodeBtn = dialog.querySelector("#ns-tab-nodes");
|
const b = dialog.querySelector(`#ns-tab-${t}`);
|
||||||
const modelBtn = dialog.querySelector("#ns-tab-models");
|
b.style.borderBottomColor = t === tab ? "#4a4" : "transparent";
|
||||||
nodeBtn.style.borderBottomColor = tab === "nodes" ? "#4a4" : "transparent";
|
b.style.color = t === tab ? "#4a4" : "#888";
|
||||||
nodeBtn.style.color = tab === "nodes" ? "#4a4" : "#888";
|
b.style.fontWeight = t === tab ? "bold" : "normal";
|
||||||
nodeBtn.style.fontWeight = tab === "nodes" ? "bold" : "normal";
|
}
|
||||||
modelBtn.style.borderBottomColor = tab === "models" ? "#4a4" : "transparent";
|
}
|
||||||
modelBtn.style.color = tab === "models" ? "#4a4" : "#888";
|
for (const t of TABS) {
|
||||||
modelBtn.style.fontWeight = tab === "models" ? "bold" : "normal";
|
dialog.querySelector(`#ns-tab-${t}`).addEventListener("click", () => switchTab(t));
|
||||||
}
|
}
|
||||||
dialog.querySelector("#ns-tab-nodes").addEventListener("click", () => switchTab("nodes"));
|
|
||||||
dialog.querySelector("#ns-tab-models").addEventListener("click", () => switchTab("models"));
|
|
||||||
|
|
||||||
dialog.querySelector("#nodes-stats-close").addEventListener("click", () => overlay.remove());
|
dialog.querySelector("#nodes-stats-close").addEventListener("click", () => overlay.remove());
|
||||||
|
|
||||||
@@ -154,6 +201,9 @@ async function showStatsDialog() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wireDisableButtons(dialog, managerInfo);
|
wireDisableButtons(dialog, managerInfo);
|
||||||
|
wireWorkflowButtons(dialog);
|
||||||
|
|
||||||
|
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
|
||||||
|
|
||||||
// Easter egg: click "used" badge 5 times to show podium
|
// Easter egg: click "used" badge 5 times to show podium
|
||||||
let eggClicks = 0;
|
let eggClicks = 0;
|
||||||
@@ -315,6 +365,47 @@ function buildModelTable(models) {
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the Workflow tab from a classification result. `disabled` entries get
|
||||||
|
// re-enable actions (temporary trial or permanent); `missing` entries get an
|
||||||
|
// Install button that defers to ComfyUI Manager.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function sectionHeader(title, subtitle, color) {
|
function sectionHeader(title, subtitle, color) {
|
||||||
let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${escapeHtml(title)}`;
|
let html = `<h3 style="color:${color};margin:16px 0 8px;font-size:14px;">${escapeHtml(title)}`;
|
||||||
if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${escapeHtml(subtitle)}</span>`;
|
if (subtitle) html += ` <span style="color:#666;font-size:12px;font-weight:normal;">— ${escapeHtml(subtitle)}</span>`;
|
||||||
@@ -400,7 +491,12 @@ async function fetchManagerInfo() {
|
|||||||
for (const [key, v] of Object.entries(packs)) {
|
for (const [key, v] of Object.entries(packs)) {
|
||||||
if (!v || v.state === "not-installed") continue;
|
if (!v || v.state === "not-installed") continue;
|
||||||
// For installed packs the key is the directory name — matches our package names.
|
// For installed packs the key is the directory name — matches our package names.
|
||||||
info[key] = { id: v.id || key, version: v.version, files: v.files, state: v.state };
|
// cnr_id/aux_id are kept so getmappings keys (which may be a registry id or
|
||||||
|
// repo URL rather than the dir name) can be reconciled in classifyUnresolved.
|
||||||
|
info[key] = {
|
||||||
|
id: v.id || key, version: v.version, files: v.files, state: v.state,
|
||||||
|
cnr_id: v.cnr_id, aux_id: v.aux_id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -408,6 +504,50 @@ async function fetchManagerInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split unresolved node types into packages that are installed-but-disabled
|
||||||
|
// (re-enable to use) vs not installed (install via Manager). Reconciles
|
||||||
|
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
|
||||||
|
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(), // getlist -> {dir: {id, cnr_id, aux_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} ];
|
||||||
|
// packKey is a directory name OR a repo/gist URL depending on the pack.
|
||||||
|
const typeToPack = {};
|
||||||
|
for (const [packKey, entry] of Object.entries(mappings)) {
|
||||||
|
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index installed/disabled packs by every identifier they expose (dir name,
|
||||||
|
// id, cnr_id, aux_id, and each repo URL) so a getmappings key in any of those
|
||||||
|
// forms resolves. URLs are normalized (drop trailing slash / .git, lowercase).
|
||||||
|
const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
||||||
|
const byAnyKey = {};
|
||||||
|
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
|
||||||
|
const rec = { ...info, _dir: dir };
|
||||||
|
byAnyKey[norm(dir)] = rec;
|
||||||
|
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[norm(k)] = rec;
|
||||||
|
for (const f of (info.files || [])) if (f) byAnyKey[norm(f)] = rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = [], missing = [];
|
||||||
|
for (const ct of types) {
|
||||||
|
const packKey = typeToPack[ct];
|
||||||
|
const info = packKey ? byAnyKey[norm(packKey)] : null;
|
||||||
|
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir, info });
|
||||||
|
else missing.push({ type: ct, pkg: packKey || null });
|
||||||
|
}
|
||||||
|
return { disabled, missing };
|
||||||
|
}
|
||||||
|
|
||||||
// Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring
|
// Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring
|
||||||
// Manager's own frontend: id = directory name, version = install state
|
// Manager's own frontend: id = directory name, version = install state
|
||||||
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
|
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
|
||||||
@@ -439,6 +579,17 @@ function wireDisableButtons(dialog, managerInfo) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire the Workflow tab's enable/install buttons. Handlers are filled in by the
|
||||||
|
// enable (Task 10) and install (Task 11) steps.
|
||||||
|
function wireWorkflowButtons(dialog) {
|
||||||
|
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); }));
|
||||||
|
dialog.querySelectorAll(".ns-install-btn").forEach((b) =>
|
||||||
|
b.addEventListener("click", (e) => { e.stopPropagation(); handleInstall(b.dataset.pkg, dialog); }));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDisable(pkgNames, dialog, managerInfo) {
|
async function handleDisable(pkgNames, dialog, managerInfo) {
|
||||||
// Only act on packages Manager still reports as active (guards against
|
// Only act on packages Manager still reports as active (guards against
|
||||||
// double-clicks and stale buttons after a partial batch).
|
// double-clicks and stale buttons after a partial batch).
|
||||||
@@ -509,6 +660,173 @@ async function runManagerDisable(payloads) {
|
|||||||
await waitForQueue();
|
await waitForQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-enable a disabled pack via ComfyUI Manager (confirmed against the live
|
||||||
|
// server and ComfyUI-Manager's manager_server.py / manager_core.py). Two routes
|
||||||
|
// through /manager/queue/install, both ending in unified_enable (a dir move out
|
||||||
|
// of .disabled — never a re-clone):
|
||||||
|
// • version != "unknown" (nightly/semver): skip_post_install takes the fast
|
||||||
|
// path, unified_enable(id) is called and the route returns before reading
|
||||||
|
// channel/mode/files. Load-bearing: id, version, skip_post_install.
|
||||||
|
// • version == "unknown": queues an install task; install_by_id sees the pack
|
||||||
|
// is_disabled and calls unified_enable. Needs files (repo URL), channel, mode.
|
||||||
|
// selected_version always mirrors version, so the "invalid request" arm (version
|
||||||
|
// set but selected_version=="unknown") is never hit. One payload covers both.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether ComfyUI Manager is mid-operation. Used to avoid resetting its queue
|
||||||
|
// out from under an in-progress install/disable (the manual disable flow guards
|
||||||
|
// the same way before calling runManagerDisable).
|
||||||
|
async function managerIsBusy() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/manager/queue/status");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
const st = await r.json();
|
||||||
|
return !!(st && st.is_processing);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runManagerEnable(payload) {
|
||||||
|
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
|
||||||
|
|
||||||
|
const r = await fetch("/manager/queue/install", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`enable request failed (HTTP ${r.status})`);
|
||||||
|
|
||||||
|
const start = await fetch("/manager/queue/start", { method: "POST", headers: { "Content-Type": "application/json" } });
|
||||||
|
if (!start.ok && start.status !== 201) throw new Error(`queue start failed (HTTP ${start.status})`);
|
||||||
|
|
||||||
|
await waitForQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable a disabled package, optionally under a temporary trial. A permanent
|
||||||
|
// enable clears any existing trial row so the package is never auto-disabled.
|
||||||
|
async function handleEnable(pkg, temporary, dialog) {
|
||||||
|
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
|
||||||
|
const info = entry && entry.info;
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
if (await managerIsBusy()) {
|
||||||
|
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkflowButtonsBusy(dialog, true);
|
||||||
|
try {
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
if (entry.info) entry.info.state = "enabled";
|
||||||
|
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 {
|
||||||
|
setWorkflowButtonsBusy(dialog, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
|
||||||
|
// as handled by Manager like always, and Manager already surfaces missing nodes
|
||||||
|
// on workflow load. We intentionally do NOT replicate install: a not-installed
|
||||||
|
// pack's exact spec can't be resolved reliably client-side (mode=local getlist
|
||||||
|
// exposes no cnr_id and an ambiguous version field, so cnr@latest vs git@unknown
|
||||||
|
// can't be chosen without risking "cannot resolve install target"). Instead open
|
||||||
|
// Manager's Custom Nodes Manager (which has a built-in Missing filter); if that
|
||||||
|
// command isn't available in this ComfyUI build, guide the user to it.
|
||||||
|
async function handleInstall(pkg, dialog) {
|
||||||
|
let opened = false;
|
||||||
|
try {
|
||||||
|
const cmd = app?.extensionManager?.command;
|
||||||
|
if (cmd && typeof cmd.execute === "function") {
|
||||||
|
await cmd.execute("Comfy.Manager.CustomNodesManager.ToggleVisibility");
|
||||||
|
opened = true;
|
||||||
|
}
|
||||||
|
} catch { /* fall through to guidance */ }
|
||||||
|
notify(
|
||||||
|
opened
|
||||||
|
? `Opened ComfyUI Manager — choose the "Missing" filter to install ${pkg}.`
|
||||||
|
: `Install ${pkg} via ComfyUI Manager → "Install Missing Custom Nodes".`,
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWorkflowButtonsBusy(dialog, busy) {
|
||||||
|
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-btn").forEach((b) => {
|
||||||
|
b.disabled = busy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTrial(pkg) {
|
||||||
|
try {
|
||||||
|
await fetch("/nodes-stats/trials/stop", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ package: pkg }),
|
||||||
|
});
|
||||||
|
} catch { /* best-effort; row ages out next session */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// On UI load, disable any trial package whose 7 distinct boot-days elapsed with
|
||||||
|
// no use (the backend marks it expired). The disable goes through ComfyUI
|
||||||
|
// Manager exactly like a manual disable; the trial row is then cleared. Inert
|
||||||
|
// when Manager is absent. A package already disabled on disk just clears its row.
|
||||||
|
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; // Manager unavailable — leave rows for a later session
|
||||||
|
|
||||||
|
// Don't reset Manager's queue out from under an in-progress operation
|
||||||
|
// (e.g. startup install work); the expired rows persist and retry next session.
|
||||||
|
if (await managerIsBusy()) 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 the row; retry next session */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done.length) {
|
||||||
|
notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForQueue(timeoutMs = 60000) {
|
async function waitForQueue(timeoutMs = 60000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|||||||
+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.2.1"
|
version = "1.3.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -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() == []
|
||||||
+106
@@ -48,6 +48,15 @@ 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 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 +71,9 @@ EXCLUDED_PACKAGES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TRIAL_BUDGET = 7
|
||||||
|
|
||||||
|
|
||||||
def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
|
def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
|
||||||
"""Classify an ISO timestamp into a removal tier.
|
"""Classify an ISO timestamp into a removal tier.
|
||||||
|
|
||||||
@@ -375,6 +387,99 @@ 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 reset(self):
|
def reset(self):
|
||||||
"""Clear all tracked data."""
|
"""Clear all tracked data."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -384,6 +489,7 @@ 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.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user