From caaaaa3b24bc166feb2e312aac78eac639e2beb5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 12:01:29 +0200 Subject: [PATCH] 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 --- docs/plans/2026-06-21-trial-enable.md | 751 ++++++++++++++++++++++++++ 1 file changed, 751 insertions(+) create mode 100644 docs/plans/2026-06-21-trial-enable.md diff --git a/docs/plans/2026-06-21-trial-enable.md b/docs/plans/2026-06-21-trial-enable.md new file mode 100644 index 0000000..ae1a70e --- /dev/null +++ b/docs/plans/2026-06-21-trial-enable.md @@ -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":"","version":"","files":,"channel":"default","mode":"cache","skip_post_install":true,"selected_version":"","ui_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 `

No missing or disabled nodes in the current workflow.

`; + } + if (disabled.length) { + html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90"); + html += ``; + for (const d of disabled) { + const t = trialByPkg[d.pkg]; + const note = t ? `on trial · ${t.days_remaining}d left` : ""; + html += ` + + + `; + } + html += `
${escapeHtml(d.type)}${escapeHtml(d.pkg)} ${note} + + +
`; + } + if (missing.length) { + html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44"); + html += ``; + for (const m of missing) { + html += ` + + + `; + } + html += `
${escapeHtml(m.type)}${m.pkg ? escapeHtml(m.pkg) : "unknown"} + ${m.pkg ? `` : "—"} +
`; + } + 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.