Files
Comfyui-Nodes-Stats/docs/plans/2026-06-21-trial-enable.md
Ethanfel caaaaa3b24 docs: implementation plan for workflow tab + trial-enable
Bite-sized TDD plan (13 tasks): backend trial state + boot tick + routes,
a spike to verify Manager's enable payload, frontend detection/tab/actions/
expiry, and docs/version. Backend is fully test-driven; frontend is
manual-verify (no JS harness).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:01:29 +02:00

752 lines
27 KiB
Markdown

# Workflow Tab + Temporary Trial-Enable Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a Node Stats "Workflow" tab that splits a loaded workflow's unresolved nodes into Missing (defer to ComfyUI Manager) and Disabled (re-enable temporarily under a 7 distinct-boot-day rolling trial, or permanently), auto-disabling unused trial packages.
**Architecture:** Approach A — the Python backend only tracks trial state in SQLite, counts boot-days (once per `__init__` import), resets a per-package counter when the existing usage tracker sees the package executed, and exposes trial state via three routes. The frontend (JS) detects unresolved graph nodes, classifies them via ComfyUI Manager's `getmappings`/`getlist`, renders the tab, and performs all Manager enable/disable mutations (reusing the existing disable code). The backend never calls Manager.
**Tech Stack:** Python 3.12 (stdlib `sqlite3`), aiohttp routes (ComfyUI `PromptServer`), vanilla JS (ComfyUI frontend extension), pytest.
**Design doc:** `docs/plans/2026-06-21-trial-enable-design.md`
**Reference for date-mocking in tests:** existing `tests/test_model_tracker.py` patches `tracker.datetime`.
---
## Phase 1 — Backend trial state (pure logic, TDD)
All Phase 1 tasks edit `tracker.py` and `tests/test_trials.py`. Run tests with the project's `python -m pytest`.
### Task 1: Trial table + `start_trial` / `get_trials`
**Files:**
- Modify: `tracker.py` (add to `SCHEMA`, add `DEFAULT_TRIAL_BUDGET`, methods)
- Create: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
# tests/test_trials.py
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
from tracker import UsageTracker, DEFAULT_TRIAL_BUDGET
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_start_trial_initializes(tracker):
tracker.start_trial("Some-Pack")
trials = tracker.get_trials()
assert len(trials) == 1
t = trials[0]
assert t["package"] == "Some-Pack"
assert t["unused_boot_days"] == 0
assert t["budget"] == DEFAULT_TRIAL_BUDGET
assert t["days_remaining"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is False
def test_start_trial_is_idempotent_resets(tracker):
tracker.start_trial("Some-Pack")
tracker.start_trial("Some-Pack")
assert len(tracker.get_trials()) == 1
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -v`
Expected: FAIL (cannot import `DEFAULT_TRIAL_BUDGET` / no `start_trial`).
**Step 3: Write minimal implementation**
In `tracker.py`, after the `SCHEMA` string add a table (append inside the existing `SCHEMA` triple-quoted block, before the closing `"""`):
```sql
CREATE TABLE IF NOT EXISTS trial_packages (
package TEXT PRIMARY KEY,
enabled_at TEXT NOT NULL,
last_use_day TEXT NOT NULL,
last_boot_day TEXT NOT NULL,
unused_boot_days INTEGER NOT NULL DEFAULT 0,
budget INTEGER NOT NULL DEFAULT 7
);
```
Add a module constant near `EXCLUDED_PACKAGES`:
```python
DEFAULT_TRIAL_BUDGET = 7
```
Add methods to `UsageTracker`:
```python
def start_trial(self, package, budget=DEFAULT_TRIAL_BUDGET):
"""Begin/restart a temporary-enable trial. The enable day is not counted."""
now = datetime.now(timezone.utc)
today = now.date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO trial_packages
(package, enabled_at, last_use_day, last_boot_day, unused_boot_days, budget)
VALUES (?, ?, ?, ?, 0, ?)
ON CONFLICT(package) DO UPDATE SET
enabled_at = excluded.enabled_at,
last_use_day = excluded.last_use_day,
last_boot_day = excluded.last_boot_day,
unused_boot_days = 0,
budget = excluded.budget""",
(package, now.isoformat(), today, today, budget),
)
conn.commit()
finally:
conn.close()
def get_trials(self):
"""Return trial rows with computed days_remaining/expired."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT package, enabled_at, last_use_day, last_boot_day, "
"unused_boot_days, budget FROM trial_packages"
).fetchall()
finally:
conn.close()
result = []
for r in rows:
d = dict(r)
d["days_remaining"] = max(0, d["budget"] - d["unused_boot_days"])
d["expired"] = d["unused_boot_days"] >= d["budget"]
result.append(d)
return result
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (2 tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): add trial_packages table, start_trial, get_trials"
```
---
### Task 2: `tick_boot_days` (distinct-day counting)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def _ahead(days):
return datetime.now(timezone.utc) + timedelta(days=days)
def test_tick_increments_only_on_new_day(tracker):
tracker.start_trial("Pack") # enable day, counter 0
tracker.tick_boot_days() # same day -> no change
assert tracker.get_trials()[0]["unused_boot_days"] == 0
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days() # new day -> 1
tracker.tick_boot_days() # same (mocked) day -> still 1
assert tracker.get_trials()[0]["unused_boot_days"] == 1
def test_tick_reaches_expiry(tracker):
tracker.start_trial("Pack")
for d in range(1, DEFAULT_TRIAL_BUDGET + 1):
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(d)
tracker.tick_boot_days()
t = tracker.get_trials()[0]
assert t["unused_boot_days"] == DEFAULT_TRIAL_BUDGET
assert t["expired"] is True
assert t["days_remaining"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: FAIL (no `tick_boot_days`).
**Step 3: Write minimal implementation**
```python
def tick_boot_days(self):
"""Once per distinct calendar day, age every active trial by one boot-day."""
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""UPDATE trial_packages
SET unused_boot_days = unused_boot_days + 1,
last_boot_day = ?
WHERE last_boot_day != ?""",
(today, today),
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k tick -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): tick_boot_days counts distinct boot-days"
```
---
### Task 3: `reset_trials_for` (usage resets counter)
**Files:**
- Modify: `tracker.py`
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_reset_zeroes_counter(tracker):
tracker.start_trial("Pack")
with patch("tracker.datetime") as m:
m.now.return_value = _ahead(1)
tracker.tick_boot_days()
assert tracker.get_trials()[0]["unused_boot_days"] == 1
tracker.reset_trials_for({"Pack", "Not-On-Trial"})
assert tracker.get_trials()[0]["unused_boot_days"] == 0
def test_reset_empty_is_noop(tracker):
tracker.start_trial("Pack")
tracker.reset_trials_for(set())
assert tracker.get_trials()[0]["unused_boot_days"] == 0
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: FAIL (no `reset_trials_for`).
**Step 3: Write minimal implementation**
```python
def reset_trials_for(self, packages):
"""Reset the unused-day counter for any of these packages that are on trial."""
if not packages:
return
today = datetime.now(timezone.utc).date().isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.executemany(
"""UPDATE trial_packages
SET unused_boot_days = 0, last_use_day = ?
WHERE package = ?""",
[(today, p) for p in packages],
)
conn.commit()
finally:
conn.close()
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -k reset -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): reset_trials_for zeroes counter on use"
```
---
### Task 4: `stop_trial` + extend `reset()`
**Files:**
- Modify: `tracker.py` (add `stop_trial`; add `DELETE FROM trial_packages` to `reset`)
- Test: `tests/test_trials.py`
**Step 1: Write the failing test**
```python
def test_stop_trial_removes_row(tracker):
tracker.start_trial("Pack")
tracker.stop_trial("Pack")
assert tracker.get_trials() == []
def test_reset_clears_trials(tracker):
tracker.start_trial("Pack")
tracker.reset()
assert tracker.get_trials() == []
```
**Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_trials.py -k "stop or clears" -v`
Expected: FAIL (no `stop_trial`; `reset` leaves the row).
**Step 3: Write minimal implementation**
```python
def stop_trial(self, package):
"""End a trial (package became permanent or was disabled)."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM trial_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
```
In `reset()`, add alongside the existing deletes:
```python
conn.execute("DELETE FROM trial_packages")
```
**Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_trials.py -v`
Expected: PASS (all trial tests).
**Step 5: Commit**
```bash
git add tracker.py tests/test_trials.py
git commit -m "feat(trials): stop_trial and clear trials on reset"
```
---
## Phase 2 — Wire backend into the server
### Task 5: Boot tick, usage-reset hook, and routes
**Files:**
- Modify: `__init__.py`
**Step 1: Boot tick at import.** After `model_mapper = ModelMapper()` and before/around the prompt-handler registration, add:
```python
# Age temporary-enable trials once per process start (one "boot").
try:
tracker.tick_boot_days()
except Exception:
logger.warning("nodes-stats: error ticking trial boot days", exc_info=True)
```
**Step 2: Reset trials on use.** In `_record_prompt`, after `tracker.record_usage(class_types, mapper)` succeeds, add:
```python
try:
packages = {mapper.get_package(ct) for ct in class_types}
packages.discard("__builtin__")
packages.discard("__unknown__")
tracker.reset_trials_for(packages)
except Exception:
logger.warning("nodes-stats: error resetting trials", exc_info=True)
```
**Step 3: Add routes.** After the existing `reset_stats` route:
```python
@routes.get("/nodes-stats/trials")
async def get_trials(request):
try:
return web.json_response(tracker.get_trials())
except Exception:
logger.error("nodes-stats: error getting trials", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/start")
async def start_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.start_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error starting trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/trials/stop")
async def stop_trial(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.stop_trial(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error stopping trial", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
```
**Step 4: Verify import doesn't break.**
Run: `python -c "import ast; ast.parse(open('__init__.py').read()); print('OK')"`
Expected: `OK`. Then `python -m pytest -q` → all green (existing + trial tests).
**Step 5: Commit**
```bash
git add __init__.py
git commit -m "feat(trials): boot tick, usage reset hook, and trial routes"
```
---
## Phase 3 — Verify Manager enable payload (spike)
### Task 6: Empirically confirm the enable payload for a disabled pack
**Why:** the disable bug taught us not to assume Manager's payload shape. Enable goes through `/manager/queue/install` with `skip_post_install=true`.
**Files:** none (investigation; document findings in this plan / a code comment).
**Step 1:** Pick a currently-disabled pack from the live server (port 8189 in this environment; adjust if changed):
Run:
```bash
curl -s "http://127.0.0.1:8189/customnode/getlist?mode=local&skip_update=true" \
| python3 -c "import sys,json; d=json.load(sys.stdin); [print(k, {x:v.get(x) for x in ('id','version','files','state')}) for k,v in d['node_packs'].items() if v.get('state')=='disabled'][:5]"
```
Expected: rows with `state='disabled'` and their `id`/`version`/`files`.
**Step 2:** Enable one via the install queue mirroring Manager's UI, then verify it flips to `enabled`:
```bash
# Use the id/version/files from Step 1 for ONE pack:
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/reset" -H 'Content-Type: application/json'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/install" -H 'Content-Type: application/json' \
-d '{"id":"<ID>","version":"<VERSION>","files":<FILES_JSON>,"channel":"default","mode":"cache","skip_post_install":true,"selected_version":"<VERSION>","ui_id":"<ID>"}'
curl -s -XPOST "http://127.0.0.1:8189/manager/queue/start" -H 'Content-Type: application/json'
# poll status, then re-check getlist state for that pack == 'enabled'
```
Expected: pack state becomes `enabled` (restart still required to load it).
**Step 3:** Record the exact minimal working payload as a comment to reuse in the frontend (`enablePayload`). Likely `{id, version, files?, channel, mode, skip_post_install:true, selected_version, ui_id}`. Note any field that turned out load-bearing.
**Step 4:** (optional) revert the test enable via the disable endpoint so state is unchanged.
**No commit** (investigation only). Carry findings into Task 9.
---
## Phase 4 — Frontend (manual verification; no JS test harness)
All Phase 4 tasks edit `js/nodes_stats.js`. After each, hard-refresh the browser (Ctrl+Shift+R) and verify in ComfyUI. Sanity-check syntax after each edit:
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && rm /tmp/c.mjs`.
### Task 7: Detect unresolved node types on workflow load
**Step 1:** Add a helper that returns the set of node types in the current graph not registered in LiteGraph:
```js
function unresolvedNodeTypes() {
const types = new Set();
const nodes = app.graph?._nodes || [];
for (const n of nodes) {
const t = n.type;
if (t && !LiteGraph.registered_node_types[t]) types.add(t);
}
return [...types];
}
```
**Step 2:** Hook workflow load by wrapping `app.loadGraphData` in `setup()`:
```js
const origLoad = app.loadGraphData?.bind(app);
if (origLoad) {
app.loadGraphData = function (...args) {
const r = origLoad(...args);
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
return r;
};
}
```
**Step 3:** Stub `onWorkflowLoaded` to log for now:
```js
async function onWorkflowLoaded() {
const unresolved = unresolvedNodeTypes();
if (unresolved.length) console.log("[Node Stats] unresolved:", unresolved);
}
```
**Verify:** load a workflow containing a disabled pack's node → console lists the type(s). **Commit.**
### Task 8: Classify unresolved types into Disabled vs Missing
**Step 1:** Fetch and build the class_type → pack map and pack states. Add:
```js
async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // existing: getlist -> {dir: {id,version,files,state}}
]);
if (mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
// class_type -> packKey (getmappings value is [ [class_types...], {meta} ])
const typeToPack = {};
for (const [packKey, entry] of Object.entries(mappings)) {
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
}
// index managerInfo by id/cnr_id/aux_id as well as dir-name key
const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
byAnyKey[dir] = info;
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[k] = { ...info, _dir: dir };
}
const disabled = [], missing = [];
for (const ct of types) {
const packKey = typeToPack[ct];
const info = packKey ? byAnyKey[packKey] : null;
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir || packKey, info });
else missing.push({ type: ct, pkg: packKey || null });
}
return { disabled, missing };
}
```
> Note (from Task 6): confirm `managerInfo` entries actually expose `cnr_id`/`aux_id`; if not, extend `fetchManagerInfo` to keep them so the getmappings key reconciles. `fetchManagerInfo` currently keeps `{id, version, files, state}` — add `cnr_id`/`aux_id` if present in getlist.
**Verify:** in console, call the classifier on `unresolvedNodeTypes()` for a workflow with a disabled pack → it lands in `disabled`. **Commit.**
### Task 9: Workflow tab UI + auto-open
**Step 1:** Add a third tab button `#ns-tab-workflow` next to Nodes/Models in `showStatsDialog`, a `#ns-content-workflow` container, and extend `switchTab` to handle `"workflow"`.
**Step 2:** Build the tab content from a classification result:
```js
function buildWorkflowTabContent({ disabled, missing }, trials) {
const trialByPkg = Object.fromEntries((trials || []).map(t => [t.package, t]));
let html = "";
if (!disabled.length && !missing.length) {
return `<p style="color:#666;">No missing or disabled nodes in the current workflow.</p>`;
}
if (disabled.length) {
html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const d of disabled) {
const t = trialByPkg[d.pkg];
const note = t ? `<span style="color:#6a6;font-size:11px;">on trial · ${t.days_remaining}d left</span>` : "";
html += `<tr class="ns-row-consider_removing" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(d.type)}</td>
<td style="padding:6px 8px;color:#888;">${escapeHtml(d.pkg)} ${note}</td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">
<button class="ns-btn ns-enable-temp-btn" data-pkg="${escapeAttr(d.pkg)}">Enable 7d</button>
<button class="ns-btn ns-enable-perm-btn" data-pkg="${escapeAttr(d.pkg)}" style="margin-left:6px;">Enable</button>
</td></tr>`;
}
html += `</tbody></table>`;
}
if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44");
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"><tbody>`;
for (const m of missing) {
html += `<tr class="ns-row-safe_to_remove" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.type)}</td>
<td style="padding:6px 8px;color:#888;">${m.pkg ? escapeHtml(m.pkg) : "unknown"}</td>
<td style="padding:6px 8px;text-align:right;">
${m.pkg ? `<button class="ns-btn ns-install-btn" data-pkg="${escapeAttr(m.pkg)}">Install</button>` : "&mdash;"}
</td></tr>`;
}
html += `</tbody></table>`;
}
return html;
}
```
**Step 3:** Store the latest classification in module scope so `showStatsDialog` can render the tab, and have `onWorkflowLoaded` open the dialog on the Workflow tab when there's ≥1 item:
```js
let _lastWorkflowScan = { disabled: [], missing: [] };
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // showStatsDialog gains an optional initial-tab arg
}
}
```
**Step 4:** Give `showStatsDialog(initialTab = "nodes")` an optional arg; after building, call `switchTab(initialTab)`; render `buildWorkflowTabContent(_lastWorkflowScan, trials)` where `trials = await fetch('/nodes-stats/trials')`.
**Verify:** load a workflow with a disabled node → dialog auto-opens to Workflow tab listing it. **Commit.**
### Task 10: Enable temporary / permanent actions
**Step 1:** Add `enablePayload(dirName, info)` using the shape confirmed in Task 6, e.g.:
```js
function enablePayload(dirName, info) {
return {
id: info.id || dirName, version: info.version, files: info.files,
channel: "default", mode: "cache", skip_post_install: true,
selected_version: info.version, ui_id: dirName,
};
}
```
**Step 2:** Add `runManagerEnable(payload)` mirroring `runManagerDisable` but POSTing to `/manager/queue/install`, then `start`, then `waitForQueue()`.
**Step 3:** Wire the buttons (after the dialog is built, alongside `wireDisableButtons`):
```js
dialog.querySelectorAll(".ns-enable-temp-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach(b =>
b.addEventListener("click", e => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
```
**Step 4:** Implement `handleEnable`:
```js
async function handleEnable(pkg, temporary, dialog) {
const info = (_lastWorkflowScan.disabled.find(d => d.pkg === pkg) || {}).info;
if (!info) return;
setDisableButtonsBusy(dialog, true);
try {
await runManagerEnable(enablePayload(pkg, info));
if (temporary) await fetch("/nodes-stats/trials/start", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
else await fetch("/nodes-stats/trials/stop", { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ package: pkg }) });
showRestartBanner(dialog);
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setDisableButtonsBusy(dialog, false);
}
}
```
**Verify:** click Enable 7d on a disabled node → pack flips to enabled in getlist, `/nodes-stats/trials` shows it, restart banner appears. Click Enable (permanent) on another → enabled, no trial row. **Commit.**
### Task 11: Missing → install via Manager
**Step 1:** Wire `.ns-install-btn` to install the owning pack via Manager. Resolve the pack's getlist entry (it will be `state:'not-installed'` — fetch a fresh getlist or reuse classification info), then POST `/manager/queue/install` with `selected_version:'latest'`, `skip_post_install:false`, start, wait, restart banner.
```js
async function handleInstall(pkg, dialog) {
// fetch the not-installed entry's id/version/files from getlist by pkg key
// POST /manager/queue/install {id, version, files, channel:'default', mode:'cache',
// selected_version:'latest', ui_id:pkg}; then start + waitForQueue + restart banner
}
```
> Keep this minimal — "handled by Manager like always." If resolving the install entry proves fiddly, fall back to a button that opens ComfyUI Manager's own Install-Missing UI instead of replicating install. Decide during implementation; document the choice.
**Verify:** click Install on a missing node → Manager installs it (or opens its installer). **Commit.**
### Task 12: Execute expiry on load
**Step 1:** Add `processExpiredTrials()` that fetches `/nodes-stats/trials`, and for each `expired` pack: build a disable payload (reuse `disablePayload` with a fresh `fetchManagerInfo` lookup), `runManagerDisable`, then POST `/nodes-stats/trials/stop`. Collect successes for a single toast.
```js
async function processExpiredTrials() {
let trials = [];
try { const r = await fetch("/nodes-stats/trials"); if (r.ok) trials = await r.json(); } catch { return; }
const expired = trials.filter(t => t.expired);
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") { await stopTrial(t.package); done.push(t.package); continue; }
try {
await runManagerDisable([disablePayload(t.package, info)]);
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep row for next session */ }
}
if (done.length) notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
}
```
**Step 2:** Call `processExpiredTrials()` once from `setup()` (after a short delay so the app is ready), guarded so it runs only when Manager is present.
**Verify:** with a trial row forced to `unused_boot_days >= budget` (set via DB or repeated tick), reloading ComfyUI auto-disables that pack and clears the trial. **Commit.**
---
## Phase 5 — Docs & version
### Task 13: README + version bump
**Files:** `README.md`, `pyproject.toml`
**Step 1:** Add a "Workflow tab & temporary enable" subsection to the README (what Missing vs Disabled mean, the 7 distinct-boot-day rolling trial, that use resets it, that auto-disable applies on next UI load and needs a restart). Add a feature bullet. Document the three `/nodes-stats/trials*` endpoints in the API table.
**Step 2:** Bump `version` in `pyproject.toml` to `1.3.0`.
**Step 3:** Run `python -m pytest -q` (all green) and JS `node --check`.
**Step 4: Commit**
```bash
git add README.md pyproject.toml
git commit -m "docs: document workflow tab + trial-enable; bump to 1.3.0"
```
---
## Done criteria
- `python -m pytest -q` green (existing + `tests/test_trials.py`).
- Loading a workflow with a disabled node auto-opens the Workflow tab; Enable 7d re-enables + records a trial; Enable makes it permanent.
- A trial package unused for 7 distinct boot-days auto-disables on next UI load; any execution use resets the counter.
- Missing nodes route to ComfyUI Manager.
- Feature is inert when ComfyUI Manager is absent.