Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b55bc630 | ||
|
|
4c9cb7f5ea | ||
|
|
56d1072e58 | ||
|
|
e71d0ca6b6 | ||
|
|
6f442bf423 | ||
|
|
bf2fa3f50e | ||
|
|
2e9322d5c4 | ||
|
|
a1377cec36 | ||
|
|
fa98d0644d |
@@ -0,0 +1,21 @@
|
||||
# Left out of the Comfy Registry package (comfy node publish). Git clones still
|
||||
# have everything. tests/test_package_contents.py holds these rules to what
|
||||
# ComfyUI needs at run time; keep each one anchored to the root with a leading /.
|
||||
|
||||
# Development folders
|
||||
/tests/
|
||||
/tools/
|
||||
/docs/
|
||||
/.github/
|
||||
|
||||
# Screenshots used only by README-legacy.md on GitHub
|
||||
/images/
|
||||
|
||||
# Development files
|
||||
/TESTING.md
|
||||
/Makefile
|
||||
/requirements-dev.txt
|
||||
/.coveragerc
|
||||
/.flake8
|
||||
/.pre-commit-config.yaml
|
||||
/.gitignore
|
||||
@@ -17,6 +17,11 @@ updates:
|
||||
prefix: "deps(pip)"
|
||||
include: "scope"
|
||||
rebase-strategy: "auto"
|
||||
# These floors follow ComfyUI's requirements.txt, never the newest release.
|
||||
ignore:
|
||||
- dependency-name: "Pillow"
|
||||
- dependency-name: "aiohttp"
|
||||
- dependency-name: "numpy"
|
||||
# Optional: group non-breaking updates to reduce PR noise
|
||||
groups:
|
||||
python-minor-and-patch:
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
|
||||
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
timeout-minutes: 24
|
||||
steps:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Same image as the shards: the raw files name sources by the container's workspace path.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
|
||||
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
options: --user 1001
|
||||
timeout-minutes: 20
|
||||
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
|
||||
|
||||
@@ -12,10 +12,10 @@ Prompt Manager saves your prompts as you generate, links each image back to the
|
||||
|
||||
## What's new: rebuilt from the ground up
|
||||
|
||||
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages. The old pages still work if you want them.
|
||||
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages.
|
||||
- **Classic and Nodes 2.0.** The nodes work in both ComfyUI renderers. CI starts a real ComfyUI and checks both.
|
||||
- **Tested end to end.** Unit tests, browser tests in Chromium, Firefox, WebKit and touch emulation, and tests against a real ComfyUI.
|
||||
- **Mac, Linux and Windows.** The Python suite runs on all three, on Python 3.12, 3.13 and 3.14.
|
||||
- **Linux and Windows.** The Python suite runs on both, on Python 3.12, 3.13 and 3.14. macOS should work but is not tested.
|
||||
|
||||
## See it
|
||||
|
||||
@@ -212,9 +212,9 @@ Set `PROMPT_MANAGER_DATA_DIR` to an absolute folder to keep the library somewher
|
||||
- **Older images:** a one-time notice offers a metadata scan that links them to prompts and negatives. Scans don't count as new runs.
|
||||
- **Going back isn't supported.** Workflows are now stored once, and an older version shows new records without their workflow or prompt graph. Back up first in System > Backup & export. See [the old README's changelog](README-legacy.md#changelog) for the manual way back.
|
||||
|
||||
## The old pages still work
|
||||
## The old pages will be removed
|
||||
|
||||
They stay at their URLs, and System's Overview section links to them:
|
||||
They still work at their URLs for now and will be removed in a future release. Everything they do is moving to the workspace.
|
||||
|
||||
| Page | URL |
|
||||
| --- | --- |
|
||||
|
||||
+19
-8
@@ -77,7 +77,7 @@ PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
|
||||
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/3
|
||||
```
|
||||
|
||||
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
|
||||
@@ -111,8 +111,11 @@ distributions, browser system dependencies may need manual installation. For a
|
||||
quick browser run, use `test:workflows -- --project chromium`.
|
||||
|
||||
CI runs the browser jobs inside the pinned image
|
||||
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
|
||||
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble@sha256:<digest>`
|
||||
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
|
||||
The digest is part of the pin, because Microsoft re-pushes tags: `v1.64.1-noble`
|
||||
moved from Node 24 to Node 26 on 2026-11-01 (#609). To bump it, change the tag and
|
||||
digest together and check the new image's Node against the Node 24 guard.
|
||||
Concept comparisons measure geometry, and geometry follows the fonts a browser
|
||||
falls back to: the concept names Inter, which neither the image nor a stock
|
||||
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
|
||||
@@ -190,8 +193,8 @@ waiting for the `frontend coverage` jobs.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
|
||||
`frontend coverage (merge)` to succeed on every event. The four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
|
||||
`frontend coverage (merge)` to succeed on every event. The six
|
||||
`page workflows (firefox|webkit, shard N/3)` jobs run only in the merge queue,
|
||||
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
|
||||
`viewerjs` requires them to be skipped on a pull request and to succeed
|
||||
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
@@ -665,6 +668,10 @@ Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` an
|
||||
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
|
||||
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
|
||||
|
||||
## Registry package
|
||||
|
||||
`comfy node publish` zips the tracked files minus `.comfyignore`, so a new development file or folder at the root ships until it is listed there. `tests/test_package_contents.py` keeps each rule anchored to the root, checks that development paths are left out and that nothing ComfyUI needs at run time is. Git clones are unaffected.
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -2728,7 +2735,7 @@ the grid) and D43 (sorted chips, checked against the concept with its chips sort
|
||||
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
||||
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
||||
"Current prompt library" link is removed from the shelf; the legacy Library stays at
|
||||
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
|
||||
`/prompt_manager/admin` by direct URL (#525, #620)
|
||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||
|
||||
@@ -3236,7 +3243,7 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
|
||||
|
||||
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
|
||||
the legacy metadata page, which stays at its URL
|
||||
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
|
||||
(`/prompt_manager/gallery.html`) at its URL only (#525, #620). The page is the concept's `#metadata`
|
||||
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
|
||||
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
|
||||
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
|
||||
@@ -4658,6 +4665,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
|
||||
tags preview checks library tags only …` (workspace-system-tagging.spec) and
|
||||
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
|
||||
- **D205** (Prompt library, #620, beyond the concept): the sort menu appends Recently used, Most used and Name Z–A
|
||||
after Oldest first. These are the old Dashboard's sorts, kept before its removal because the community asked for
|
||||
them. A prompt never run sorts last in both use orders.
|
||||
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
|
||||
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
|
||||
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
|
||||
@@ -7485,7 +7495,7 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
|
||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||
longer hands off to the legacy tag manager. That page stays at
|
||||
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
|
||||
`/prompt_manager/admin#/tags` at its URL only (#525, #620); tag search and sort, the untagged
|
||||
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
|
||||
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
|
||||
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
||||
@@ -7617,7 +7627,8 @@ The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.ht
|
||||
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
|
||||
ruling; pinned by `legacy-system-links.spec.mjs`).
|
||||
|
||||
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||
- **D165 retired** (#620): the old pages show their removal notice since 3.5.1, so System no longer links them and the load failure below offers a reload and ComfyUI. The original ruling:
|
||||
**D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
|
||||
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
|
||||
`mapOverviewConcept` adds it to the page tree.
|
||||
|
||||
@@ -291,13 +291,6 @@ class AnnotationOperations:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
|
||||
|
||||
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
|
||||
return {
|
||||
"sha256": sha256,
|
||||
"tags": self._byte_tags(conn, byte_id),
|
||||
"tag_revision": revision,
|
||||
}
|
||||
|
||||
def read_image_tags(self, locator: str, sha256: str) -> dict:
|
||||
"""The stored set of these exact bytes, or an empty set at revision 0.
|
||||
|
||||
|
||||
+2
-22
@@ -16,18 +16,17 @@ check, and none of them carries a server path.
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
from ..utils.hashing import register_prompt_functions
|
||||
from .models import normalize_image_path
|
||||
from .sqlite_uri import readonly_uri
|
||||
except ImportError:
|
||||
from utils.hashing import register_prompt_functions
|
||||
from database.models import normalize_image_path
|
||||
from database.sqlite_uri import readonly_uri
|
||||
|
||||
logger = logging.getLogger("prompt_manager.database.health")
|
||||
|
||||
@@ -89,25 +88,6 @@ def _counted(lines):
|
||||
return _result(count, detail)
|
||||
|
||||
|
||||
def readonly_uri(db_path, *, windows=None):
|
||||
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
|
||||
|
||||
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
|
||||
``file://server/share/x.db``, a remote authority SQLite refuses to open.
|
||||
With an empty authority (``file:////server/share/x.db``) SQLite hands
|
||||
Windows the ``//server/share`` path, which it opens as the UNC path.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = os.name == "nt"
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not windows:
|
||||
return PurePosixPath(db_path).as_uri() + "?mode=ro"
|
||||
path = PureWindowsPath(db_path)
|
||||
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
|
||||
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
|
||||
return path.as_uri() + "?mode=ro"
|
||||
|
||||
|
||||
def _integrity(conn, _roots):
|
||||
rows = [
|
||||
row[0] for row in conn.execute(f"PRAGMA integrity_check({INTEGRITY_LIMIT})")
|
||||
|
||||
@@ -97,6 +97,9 @@ _ORDERS = {
|
||||
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
|
||||
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
|
||||
"name": (_NAME, "ASC"),
|
||||
"name-desc": (_NAME, "DESC"),
|
||||
"most-used": ("COALESCE(p.run_count,0)", "DESC"),
|
||||
"last-used": ("COALESCE(julianday(p.last_used_at),0)", "DESC"),
|
||||
"rating": ("COALESCE(p.rating,0)", "DESC"),
|
||||
}
|
||||
# Pair archive scopes for variant and image reads, as conditions on alias pp.
|
||||
|
||||
@@ -47,11 +47,11 @@ import sqlite3
|
||||
import time
|
||||
import types
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from .models import BUSY_TIMEOUT_SECONDS, PromptModel
|
||||
from .sqlite_uri import readonly_uri
|
||||
|
||||
try:
|
||||
from ..utils import library_location as ll
|
||||
@@ -322,7 +322,7 @@ def _verify_copy(temp: str, counts, documents) -> None:
|
||||
"verify-failed",
|
||||
f"{MESSAGES['verify-failed']} {detail}" if detail else None,
|
||||
)
|
||||
uri = Path(os.path.abspath(temp)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(temp)
|
||||
try:
|
||||
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
copied = _describe(conn)
|
||||
|
||||
+3
-4
@@ -14,6 +14,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from .sqlite_uri import readonly_uri
|
||||
from .managed_connection import (
|
||||
ManagedConnection,
|
||||
await_closed,
|
||||
@@ -1199,7 +1200,7 @@ class PromptModel:
|
||||
if header != SQLITE_HEADER:
|
||||
return False, "Not a SQLite database file"
|
||||
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
try:
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
except sqlite3.Error as e:
|
||||
@@ -1306,9 +1307,7 @@ class PromptModel:
|
||||
replacement (for example, incompatible page sizes in WAL mode),
|
||||
propagate the error rather than copy files under other open owners.
|
||||
"""
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
source = sqlite3.connect(readonly_uri(Path(src_path).resolve()), uri=True)
|
||||
try:
|
||||
target = self.get_connection()
|
||||
# The target is written under backup's step too: mark it in use
|
||||
|
||||
@@ -42,6 +42,7 @@ from typing import Dict, Optional
|
||||
from . import workflow_documents as wd
|
||||
from .execution_history_schema import rotate_history_epoch
|
||||
from .safety_backup import take_safety_backup
|
||||
from .sqlite_uri import readonly_uri
|
||||
|
||||
logger = logging.getLogger("prompt_manager.database.restore_stage")
|
||||
|
||||
@@ -114,7 +115,7 @@ def file_digest(path: str) -> str:
|
||||
|
||||
|
||||
def _readonly(path: str) -> sqlite3.Connection:
|
||||
return sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)
|
||||
return sqlite3.connect(readonly_uri(Path(path).resolve()), uri=True)
|
||||
|
||||
|
||||
def _schema(path: str) -> list:
|
||||
|
||||
@@ -31,7 +31,15 @@ MAX_STATE_BYTES = 4096
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_LIBRARY_SORTS = (
|
||||
"newest",
|
||||
"oldest",
|
||||
"name",
|
||||
"name-desc",
|
||||
"most-used",
|
||||
"last-used",
|
||||
"rating",
|
||||
)
|
||||
_MEDIA = ("all", "image", "video")
|
||||
# How a Library view's Tags match (#444): every tag, any of them, or parents
|
||||
# without tags ("untagged" never goes with tags; the listing refuses both).
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The read-only SQLite URI of a path, safe for Windows network shares."""
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def readonly_uri(db_path, *, windows=None):
|
||||
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
|
||||
|
||||
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
|
||||
``file://server/share/x.db``, a remote authority SQLite refuses to open.
|
||||
With an empty authority (``file:////server/share/x.db``) SQLite hands
|
||||
Windows the ``//server/share`` path, which it opens as the UNC path.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = os.name == "nt"
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not windows:
|
||||
return PurePosixPath(db_path).as_uri() + "?mode=ro"
|
||||
path = PureWindowsPath(db_path)
|
||||
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
|
||||
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
|
||||
return path.as_uri() + "?mode=ro"
|
||||
@@ -1,409 +0,0 @@
|
||||
# Review and clean: review-only audit
|
||||
|
||||
Date: 2026-11-01. Baseline: `main` at `7e11d9cae266b74e0820799eb0d74ab493e66026` (version **3.4.1**).
|
||||
|
||||
**REVIEW ONLY — DO NOT MERGE. Keep this PR in draft.** The maintainer approved sharing this audit for additional review; this branch contains no cleanup implementation.
|
||||
|
||||
All measurements and source-line references describe commit `7e11d9cae266b74e0820799eb0d74ab493e66026`, not the latest main. Main subsequently gained #592 (thumbnail location) at `043403d09e0ac199123d7da88c5a6c3e33c8bc46`; that addition is outside this audit snapshot. The documentation branch starts from that newer main so its only diff is this report.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed with a small cleanup of proven dead helpers, test-only scaffolding and unused assets, preceded by the risk-focused tests below. Keep the legacy pages for now. Two legacy jobs still need a retirement decision: prompt sorting by use, and managing the underlying records used only as negative components. Do not build replacements as part of this audit.
|
||||
|
||||
The most consequential code findings are inconsistent Windows UNC handling in SQLite readers, a write-only prompt registry that grows with executions, and model unloading on the server event loop. These are independent of deleting the old pages. The first and third need narrow regression tests and fixes; removal of the registry needs an explicit compatibility decision.
|
||||
|
||||
The audit changed no production source, tests, workflows, protected assets or configuration. This review branch publishes only the report at the maintainer's request. No cleanup, service restart or release is included. Raw logs, scripts, databases, archives and machine-specific files remain outside Git. This is a review-only exception to the normal policy that EGM reports stay local.
|
||||
|
||||
## Expected payoff
|
||||
|
||||
These are measured projections, not completed reductions. No projected reduction has been implemented.
|
||||
|
||||
| Work | Before → proposed after | Reduction / limits |
|
||||
| --- | --- | --- |
|
||||
| Legacy UI retirement | 121,845 → **111,707 runtime lines** | **10,138 lines / 8.3%**, across 16 files. Requires parity approval; replacement bookmark shims may reduce the net saving. |
|
||||
| Small dead-helper cleanup | About **150 runtime lines removed**, plus obsolete tests | Modest: the measured helper/class spans total 149 lines before adjacent fields, comments and test changes. |
|
||||
| Development-file package exclusions | **17,348,582 → 6,766,947 bytes ZIP** | **61.0% smaller** in a local preview excluding `tests/`, `tools/`, `docs/` and `.github/`; 1,028 → 362 archived files. Runtime completeness still needs installation testing. |
|
||||
| Unused image artifacts | **1,737,407 bytes removed** from the current tree | Three files. This does not shrink existing Git history. |
|
||||
|
||||
Line counts include comments and whitespace. Package savings concern the Registry packaging path, not Git clones or history. Both ZIPs were local audit previews; neither is included in this branch or published to the Registry.
|
||||
|
||||
The strongest return is packaging exclusions, targeted correctness fixes and eventual legacy retirement: retiring the old UI removes two large controllers and their supporting code. The first helper-removal batch has a small numerical payoff. Defer broad file splitting and cosmetic reorganization until there is a demonstrated maintenance benefit. With Python coverage at 95.10% and frontend line coverage at 90.55%, prioritize safety invariants over percentage increases.
|
||||
|
||||
## 1. Scope, method and limits
|
||||
|
||||
Read the audit brief, local `CLAUDE.md` and `AGENTS.md`, [README.md](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/README.md), [pyproject.toml](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/pyproject.toml), the redesign plan, the testing contract's execution/safety/portability/parity sections, the available directory contracts, and relevant EGM designs and drift records. The EGM records for #444, #447, #550, #523 and #556 distinguish approved redesign changes from actual gaps. `openspec/AGENTS.md` does not exist at this head; no substitute spec was invented.
|
||||
|
||||
The review scans the tracked tree and takes deeper passes through registration, HTTP routes, prompt/image identity, backup/restore, library movement, deletion, model lifecycle, and the legacy/workspace UI entry points. It is not a claim that every line of the roughly 122,000 runtime source lines received a separate manual review.
|
||||
|
||||
Raw audit evidence was retained locally and is not part of this branch. The observations, results and limitations needed for review are summarized here. Inventory uses `git ls-files`; generated environments, the copied local briefs and audit artifacts do not enter the counts. Vulture 60%/80% output and coverage were treated as leads. Candidate decisions also checked browser script tags/imports, node schemas, route registration, dynamic `getattr`, SQL/config names, shutdown hooks, packaging and tool/test callers. Names inside comments, a same-named unrelated test helper, or a same-named local JS function do not constitute a caller.
|
||||
|
||||
Disposable probes exercised the findings without editing production code: 1,000 unique registry registrations and forced expiry; SQLite URI construction for a non-local UNC authority; unload call/thread identity; invalid save-request shapes; and native-Library eligibility of a negative-only record. Results appear with the corresponding findings. These were audit probes, not committed regression tests or proof of a fix.
|
||||
|
||||
### Baseline measurements
|
||||
|
||||
| Measurement | Before / current audit head |
|
||||
| --- | ---: |
|
||||
| Tracked files | 1,028 |
|
||||
| Runtime `.py`, `.js`, `.mjs`, `.css`, `.html` files outside protected directories | 295 |
|
||||
| Lines in those runtime files | 121,845 |
|
||||
| Test/tooling source files in the same language set | 532 |
|
||||
| Test/tooling source lines | 214,122 |
|
||||
| Runtime files over 800 lines | 34 |
|
||||
| Runtime Python functions over 50 lines | 212 |
|
||||
| Python test files | 238 |
|
||||
| Frontend unit-test files | 103 |
|
||||
| Tooling unit-test files | 17 |
|
||||
| Page-workflow spec files | 67 |
|
||||
| Real-host spec files | 14 |
|
||||
| Frontend files with coverage baselines | 123 |
|
||||
|
||||
The runtime count includes the 1,703-line generated `workspace/concept.css` mirror; it is not a hand-editing candidate. Protected sources and the approved concept are counted separately in `inventory.json`. Line totals are physical lines, including whitespace/comments, with no claim that minified/vendor code and handwritten code have the same cost. No removal has happened, so before and after are currently identical.
|
||||
|
||||
Largest runtime files, in lines:
|
||||
|
||||
| File | Lines | Review implication |
|
||||
| --- | ---: | --- |
|
||||
| [py/api/images.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/images.py) | 4,315 | Several real seams: listing/cursors, file access, thumbnails, inspection. |
|
||||
| [web/js/admin.js](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/js/admin.js) | 3,102 | Legacy; retire after parity, avoid an interim cosmetic rewrite. |
|
||||
| [web/js/gallery.js](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/js/gallery.js) | 3,053 | Legacy; same. |
|
||||
| [database/operations.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/operations.py) | 2,854 | Prompt/tag/link operations share a large facade. |
|
||||
| [utils/prompt_pair_resolver.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/prompt_pair_resolver.py) | 2,749 | Complex resolver with differential contracts; split only with a demonstrated seam and parity evidence. |
|
||||
| [py/api/admin.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/admin.py) | 1,893 | Both live scan/maintenance machinery and legacy routes remain. |
|
||||
| [utils/image_monitor.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/image_monitor.py) | 1,539 | Metadata-first attribution, queue fallback and shutdown behavior are coupled. |
|
||||
| [py/api/autotag_routes.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/autotag_routes.py) | 1,534 | Native and compatibility routes plus model/stream/job policies. |
|
||||
|
||||
Counts and function spans were computed from the tracked files at the audit commit. Only the summarized results are published here.
|
||||
|
||||
### Baseline checks
|
||||
|
||||
| Check | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Python 3.12.9, `python -m coverage run -m tests.run_suite -q` | **PASS: 6,104 tests, 75 skips; 335.479 s** | `python-baseline.log` |
|
||||
| `coverage combine`; `coverage report --fail-under=85` | **PASS: 95.10% combined**; 38,237 statements, 12,364 branches | `python-coverage.txt`, `python-coverage.json` |
|
||||
| Frontend units, Node 24.22.2 | **PASS: 1,444 tests** | Frontend baseline log |
|
||||
| Viewer fixture suite in pinned image | **PASS: 18 passed, 2 skipped** | Frontend baseline log |
|
||||
| Full Chromium + touch workflows and combined frontend gate | **PASS: 2,473 passed, 1 skipped (23.7 min); 123-file coverage gate passed** | `frontend-baseline.log` |
|
||||
| Tooling tests with the project Python selected | **PASS: 124 tests** | `tooling-baseline.log` |
|
||||
| Viewer.js, Toolkit and concept stylesheet verification | **PASS**: Viewer.js 1.15.2, Toolkit 0.2.0, stylesheet 30,447 bytes | `vendor-check.log` |
|
||||
| `black --check .` | **PASS: 452 files unchanged** | `black.log` |
|
||||
| `flake8 .` | **PASS** | `flake8.log` |
|
||||
| Frontend source/baseline membership | **PASS: 123 files** | `frontend-baseline-structure.log` |
|
||||
| Existing System parity-table validation | **PASS: 54 rows, no structural/test-reference errors** | `system-parity.json` |
|
||||
| Tracked-file distribution preview | **1,028 files; 32,088,917 bytes uncompressed / 17,348,582-byte ZIP**; includes tests/tooling/docs | `distribution-preview.json`, `tracked-bundle-preview.zip` |
|
||||
| Standard sdist/wheel build, from a clean tracked snapshot | **FAIL: setuptools rejects multiple top-level packages**; separate from Registry packaging | `tracked-package-build.log` |
|
||||
| Native Windows, other Python versions, Firefox/WebKit, real ComfyUI and cross-pack matrix | **Not run in this audit**; retain CI/managed-host gates | See verification plan |
|
||||
|
||||
Combined frontend coverage is **90.55% lines/statements, 93.52% functions and 92.61% branches** (37,918 measured lines, 18,664 branches). The source/baseline membership check also passes. The table above records the final results; raw reports are not committed.
|
||||
|
||||
The Python measurement is on macOS. The browser run uses `mcr.microsoft.com/playwright:v1.64.1-noble`, ARM64, limited to four CPUs and four workers, under the browser-suite lock. Its image digest is `sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea`. It runs Chromium/touch only, not the full browser matrix. The Python environment is confined to this worktree's ignored `venv/`.
|
||||
|
||||
Initial setup attempts were invalid and are retained rather than hidden. A global disposable data-directory override defeated fixture isolation: the initial Python run had 19 failures/55 errors, and browser workers shared a database (`the live database changed while the seed ran`). That browser run was stopped. Removing the override restored the suites' own disposable fixtures. The first tooling attempt selected a Python without watchdog; setting `PM_TEST_PYTHON` fixed that prerequisite. An initial `.venv` also fell outside the repository's `venv/` lint/coverage exclusions; it was replaced with `venv/`. These are setup corrections, not source fixes or reruns of a failing CI head. The invalid attempts are retained in the local audit evidence separately from the corrected baseline. No watchdog limit was raised, no test weakened, and no live state was involved.
|
||||
|
||||
## 2. Quality findings
|
||||
|
||||
### F01 — HIGH: several retained SQLite readers reject Windows UNC libraries
|
||||
|
||||
Locations: [database/models.py:1202](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/models.py#L1202) and `:1310`; [database/restore_stage.py:117](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/restore_stage.py#L117); [database/library_move.py:321](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/library_move.py#L321); [utils/history_read_fence.py:57](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/history_read_fence.py#L57); [py/api/backup_restore.py:189](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/backup_restore.py#L189); [py/api/admin.py:402](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/admin.py#L402).
|
||||
|
||||
These readers independently build a SQLite URI with `Path(...).as_uri()`. On Windows a network path becomes `file://server/share/...`; the normal SQLite build rejects that non-local URI authority. The project already implements the compatible empty-authority form in [database/health.py:98](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/health.py#L98) (`readonly_uri`), with portable and native-control tests at [tests/test_database_health.py:337](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/test_database_health.py#L337) and `:363`.
|
||||
|
||||
Cost: a library may be opened by ordinary filesystem-path connections yet fail backup verification/download, staging, Move library verification, or fenced history reads. Safety-backed operations can be refused before writing. This is a recovery/availability defect, not evidence of observed data loss.
|
||||
|
||||
Evidence: the probe produces `file://server/share/prompts.db?mode=ro` and SQLite's `invalid uri authority: server`; the shared helper produces `file:////server/share/prompts.db?mode=ro`. **No native Windows share was exercised here.**
|
||||
|
||||
Proposed fix: use one low-level SQLite read-only URI helper at every retained call site, retaining each caller's timeout, `trusted_schema`, snapshot and closure policies. Start with failing URI-boundary tests, then native Windows backup/restore/history controls on a disposable share. Do not infer native success from a `PureWindowsPath` assertion. Avoid a new dependency cycle by putting the helper below models/health, or preserving an appropriate facade.
|
||||
|
||||
### F02 — HIGH: the prompt metadata registry retains each execution indefinitely
|
||||
|
||||
Locations: [utils/comfyui_integration.py:89](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/comfyui_integration.py#L89), `:128`, `:178`; writers at `prompt_manager.py:263`, `prompt_manager_text.py:234`, `prompt_manager_pair.py:177`; construction in `prompt_manager_base.py:89`.
|
||||
|
||||
Each executed node invents a time-based ID and adds full text/metadata to both a thread-local dictionary and the process-global dictionary. Production code writes this registry but never calls its reader or cleanup method. Even explicit `cleanup_old_prompts` removes only global entries. It does not bound or clear the execution thread's dictionary.
|
||||
|
||||
Evidence: 1,000 unique registrations leave 1,000 entries in both stores; after forcing every global entry to expire, global count is zero while the thread-local count remains 1,000. The current 91.95% combined coverage for this file exercises lookup behavior but has no bounded-growth invariant. ComfyUI saves metadata from node inputs; the old SaveImage patch is deliberately absent (#75).
|
||||
|
||||
Proposed fix: determine whether the registry has a supported external consumer. If not, remove the obsolete registry and its node-side writes, preserving the tests that SaveImage is untouched and outputs/history still carry the proper text. If an external contract exists, add an explicit bounded lifecycle to both stores with deterministic long-run tests. Do not simply delete the tested reader and leave production writes accumulating. This is an **UNSURE removal candidate**, despite its write-only internal use.
|
||||
|
||||
### F03 — MEDIUM: Unload blocks ComfyUI's event loop
|
||||
|
||||
Locations: [py/api/autotag_routes.py:1403](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/autotag_routes.py#L1403)–`:1426`; [py/autotag.py:911](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/autotag.py#L911); consumer [web/js/workspace/system-tagging.mjs:53](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/js/workspace/system-tagging.mjs#L53).
|
||||
|
||||
`unload_autotag_model` takes the model gate but calls `service.unload_model()` synchronously. That operation destroys the model, runs `gc.collect()` and may empty CUDA caches. It can stall unrelated UI/API/queue work. The existing image-tag job already calls the same operation through `await model_gate.run(...)` ([py/api/image_tag_jobs.py:614](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/image_tag_jobs.py#L614)).
|
||||
|
||||
Evidence: the audit probe records identical event-loop and unload thread IDs. [tests/test_api_autotag.py:734](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/test_api_autotag.py#L734) tests success/failure/counts, not event-loop responsiveness or cancellation while the worker is still unloading.
|
||||
|
||||
Proposed fix: run unload through the existing model-gate executor mechanism; hold the gate until the worker is finished even if the request is cancelled. Add an event-gated fake unload that proves an unrelated loop callback can complete while unload waits, and that another model user remains refused until release. No model download is needed to test this.
|
||||
|
||||
### F04 — MEDIUM: malformed save requests escape validation as server errors
|
||||
|
||||
Location: [py/api/prompts.py:727](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/prompts.py#L727)–`:745`.
|
||||
|
||||
The handler calls `.get()` and `.strip()` before validating the JSON object and field types. `[]`, `{"text":123}`, and `{"text":"fixture","category":123}` all return a 500 envelope with an AttributeError logged, instead of a bounded 400 validation refusal. The save route is still registered and must remain compatible with existing consumers.
|
||||
|
||||
Evidence: all three outcomes are reproduced in `probes.log`. Existing invalid-rating coverage does not cover these shapes. The 96.39% combined coverage for this file therefore does not establish its input contract.
|
||||
|
||||
Proposed fix: validate shape/types with the existing bounded JSON-body helper before normalization; add table-driven route tests proving 400 responses, unchanged database rows, and unchanged behavior for valid legacy payloads. This intentionally changes invalid-input responses, so it is a bug fix requiring approval, not a behavior-neutral refactor.
|
||||
|
||||
### F05 — MEDIUM: image API responsibilities are too interwoven for safe retirement
|
||||
|
||||
Location: [py/api/images.py:1455](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/images.py#L1455)–`:4315` (also the helpers/cache structures above its mixin).
|
||||
|
||||
One 4,315-line file combines route wiring, legacy and workspace listing, signed cursors, source scopes, file containment, thumbnails/video subprocesses, favorites, inspection and prompt lookup. Other mixins import helpers from it. Removing legacy paths without identifying those dependencies risks deleting shared workspace/node behavior.
|
||||
|
||||
Proposed fix: after caller-led removal, extract only useful seams—thumbnail generation, media access/containment, and workspace listing/formatting—keeping the current mixin/helper facade while callers transition. Add behavior tests for retained boundaries before moving them. Do not split to satisfy a line target or rewrite the resolver alongside it.
|
||||
|
||||
### F06 — MEDIUM: a second configuration writer survives only for tests
|
||||
|
||||
Locations: [py/config.py:81](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/config.py#L81) (`write_private_json`), `:1059` (`save_to_file`); contrast [py/config_store.py:81](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/config_store.py#L81) and `:157`.
|
||||
|
||||
This unused production path truncates the existing file before JSON serialization, bypasses the commit lock/revision contract, loses unknown sections, and logs failures instead of returning them. Only tests call it. Keeping it makes it easy for future code to bypass the safe `ConfigStore` writer.
|
||||
|
||||
Proposed fix: remove the **TEST-ONLY** save method and its private writer together, replacing fixture setup through existing disposable fixture helpers where needed. Remove their own tests only with their implementation; retain `ConfigStore` atomicity, unknown-key preservation and permission tests. Do not remove the live `load_from_file` reader or change old configuration formats.
|
||||
|
||||
### F07 — LOW: instructions and status documentation disagree with the implementation
|
||||
|
||||
Locations/evidence:
|
||||
|
||||
- `CLAUDE.md:30` says 3.3.x; [pyproject.toml:4](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/pyproject.toml#L4) says 3.4.1.
|
||||
- `CLAUDE.md:1` and `AGENTS.md:2` refer to absent `openspec/AGENTS.md`.
|
||||
- `AGENTS.md:84` still says macOS runs in portability CI; `portability.yml:40` runs Windows only. [README.md:18](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/README.md#L18) also says the Python suite runs on all three OSes, contradicting its own final testing section.
|
||||
- `AGENTS.md:126` says three coverage shards/two other-browser shards; current `viewerjs-check.yml:66` and `:206` use **four** and **three**. `CLAUDE.md:158` also still says two other-browser shards. [TESTING.md](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/TESTING.md) retains some old three-/four-shard and two-/three-shard prose together.
|
||||
- [docs/application-redesign.md:73](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/docs/application-redesign.md#L73) names Python 3.10–3.13 and macOS CI, and `:78` asks for PR screenshots. The actual floor is Python 3.12 and the maintainer prohibits PR image uploads.
|
||||
- The redesign plan describes history under #209 as pending although the chronological Library exists; default entry points already changed under #525.
|
||||
- `CLAUDE.md`'s general HTML-refresh rule needs the [TESTING.md:7561](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/TESTING.md#L7561) exception: `workspace.html` is server-cached and needs a ComfyUI restart when edited.
|
||||
- Several EGM line anchors and run-counting descriptions predate the cached-runtime receipt work; they are context, not authority for deleting code.
|
||||
|
||||
Proposed fix: reconcile README, TESTING, redesign status and the two local briefs from current code/workflows; preserve the test parsers' required sections. Keep the managed OpenSpec block intact while recording the missing-file situation locally or recovering its authoritative source after a maintainer ruling. Do not regenerate the protected concept or add screenshots. Keep local briefs untracked.
|
||||
|
||||
### F08 — LOW: two automation jobs are outside the timeout contract
|
||||
|
||||
Locations: [.github/workflows/publish.yml:15](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/.github/workflows/publish.yml#L15), [.github/workflows/viewerjs-update.yml:21](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/.github/workflows/viewerjs-update.yml#L21); guard coverage at [tests/vendor/ci-workflow.test.mjs:279](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/vendor/ci-workflow.test.mjs#L279).
|
||||
|
||||
These jobs declare no `timeout-minutes`. The current guard enumerates the four test/quality/portability workflows, so tooling green does not enforce the repository's broader “every CI job” rule. A stalled publish/update can hold a runner much longer than intended.
|
||||
|
||||
Proposed fix: first obtain the required three green-main duration measurements, then add limits and comments and extend the guard to all workflows. Do not invent durations or change a limit to hide a failure. Do not dispatch the publishing workflow for this audit.
|
||||
|
||||
### F09 — MEDIUM: the packaging path has no exclusions for development content
|
||||
|
||||
Location: [pyproject.toml:9](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/pyproject.toml#L9)–`:11`, [.github/workflows/publish.yml:4](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/.github/workflows/publish.yml#L4)–`:9`.
|
||||
|
||||
There is no `.comfyignore` or export-ignore manifest. The inspected [publish action](https://raw.githubusercontent.com/Comfy-Org/publish-node-action/v1/action.yml) runs `comfy node publish`; the current [upstream packager](https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/file_utils.py) includes tracked files when no exclusion specification is present. On that basis, a future clean-checkout publish would include the development tree. This is an inference from the current upstream source, not inspection of an already published Registry artifact.
|
||||
|
||||
The local tracked-file preview confirms 1,028 files / 32,088,917 uncompressed bytes: `tests/` alone contributes 549 files / 11,739,034 bytes, `docs/` 73 / 8,727,000, and `tools/` 36 / 239,782. These costs are separate from working runtime dependencies. See `distribution-preview.json`; the ZIP is only a local audit artifact and was not uploaded.
|
||||
|
||||
Proposed fix: add reviewed `.comfyignore` exclusions and a non-publishing archive-content check, preserving all runtime Python/web assets, semantics JSON, required licenses and install metadata. Verify the exact supported CLI version and archive before release. No publishing command was run. The action's unpinned CLI installation also makes its future packaging behavior version-dependent.
|
||||
|
||||
Separately, `uv build` fails from a clean tracked snapshot because setuptools discovers multiple top-level packages. That does not establish a Registry failure; wheel support is a separate scope decision. Do not casually edit [pyproject.toml](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/pyproject.toml): **any change to that file on main matches the publish trigger**, even with the same version. No packaging metadata was edited here.
|
||||
|
||||
### Checks that did not establish a defect
|
||||
|
||||
- The reviewed dynamic SQL identifiers/orders were local constants or whitelists; no exploitable SQL injection was demonstrated. `library._ORDERS`, pair/tag sort maps and fixed history ordering are safe exceptions to the blanket `order_by_clause` prose. Do not mechanically force unrelated sort domains through one function.
|
||||
- The several Python/browser resolvers are necessary ports for local, non-uploaded inspection; differential tests enforce agreement. They are not duplicate metadata inspectors to delete based on filenames.
|
||||
- Prompt hashes ultimately use `utils.hashing.generate_prompt_hash`; no second live prompt-hash algorithm was found in the reviewed paths.
|
||||
- The restore fingerprint deliberately does not cover every older-row tag/favorite edit; the safety backup retains them. That limitation is already recorded in TESTING and is not a newly discovered data-loss bug.
|
||||
- npm installation emitted four moderate-severity advisory notices. A later **offline** audit of the correct `tools/vendor` package returned no advisory entries; that does not disprove the install notice or provide a trustworthy fresh advisory assessment. No dependency versions or vendor pins were changed. Keep this discrepancy as an unassessed tooling-dependency item, not a claim of four exploitable runtime vulnerabilities.
|
||||
|
||||
## 3. Dead-code candidate decisions
|
||||
|
||||
`DEAD` below means no reachable caller found after the entry-point checks, and removal is proposed for the implementation pass. **Nothing in this table has been removed yet.** Counts/spans are recorded in `candidate-spans.json` and the tracked-file/name/line search results in `reference-audit.json`; the vulture reports include rejected false positives as well as leads.
|
||||
|
||||
| Candidate | Label | Evidence and disposition |
|
||||
| --- | --- | --- |
|
||||
| [database/annotations.py:294](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/annotations.py#L294), `_current_set` | DEAD | Only the definition occurs as a symbol; the similarly named test describes a result, not a call. Current annotation paths construct their own result. Remove the six-line helper. |
|
||||
| [py/api/autotag_routes.py:946](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/autotag_routes.py#L946), `_gated_runner` | DEAD | No member caller, callback registration or dynamic method selection. [tests/test_jobs.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/test_jobs.py) defines an unrelated local helper with this name. Remove nine lines; retain `model_gate.run`. |
|
||||
| [py/api/thumbnail_jobs.py:92](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/thumbnail_jobs.py#L92), `from_query` parameter | DEAD | The value is never read; one internal call supplies it. Remove the keyword/argument only, preserving body/query behavior. Do not remove `thumbnail_options`. |
|
||||
| `image_meta_data.png` | DEAD | No tracked text, test fixture, page, tooling or packaging reference; not under `WEB_DIRECTORY` or a static media root. Remove unused 86,639-byte artifact. |
|
||||
| `test_metadata_file.png` | DEAD | Same checks; not used by the PNG/metadata tests. Remove unused 1,616,051-byte artifact. |
|
||||
| `images/pm-lora-results.png` | DEAD | Unlike the ten other `images/` assets, it is not linked by [README-legacy.md](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/README-legacy.md) or any tracked caller. Remove 34,717 bytes. |
|
||||
| [py/api/__init__.py:478](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/__init__.py#L478), `_acquire_long_job`; `_LongJobGuard` and `_long_jobs` field | TEST-ONLY | Only tests/doc examples use this old lock. Production admissions use `JobRegistry`. Remove this test-only cluster and its tests/assertions. **Keep `_long_job_busy_response`**, which several live admin paths call. |
|
||||
| [py/api/__init__.py:1080](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/__init__.py#L1080), `_parse_comfyui_prompt` | TEST-ONLY | Only [tests/test_api_core.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/test_api_core.py) calls the wrapper. Remove wrapper; move any distinct parser assertions to retained `utils/file_prompt.parse_comfyui_prompt` tests rather than losing legacy-data-reader coverage. Keep the actual parser. |
|
||||
| [py/config.py:1059](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/config.py#L1059), `save_to_file`, and its sole helper `write_private_json` | TEST-ONLY | Calls are confined to `test_config.py` / `test_thumbnail_settings.py`; live writers use ConfigStore. See F06. |
|
||||
| [utils/hashing.py:129](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/hashing.py#L129), `is_duplicate_prompt` | TEST-ONLY | Only `test_hashing.py` and its own module examples refer to it. Remove wrapper/tests/docs; retain the shared hash, text/key functions and SQL registrations. |
|
||||
| [utils/validators.py:300](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/validators.py#L300), `parse_tags_string` | TEST-ONLY | Only `test_validators.py` calls it. Not in `utils.__all__`; not an API/config parser. Remove with its own tests. |
|
||||
| `prompt_manager_base.py:436`, `search_prompts_api`; `:440`, `get_recent_prompts_api`; `:458`, `get_gallery_status` | TEST-ONLY | Only node-contract tests call them; none is a ComfyUI `FUNCTION`, route or JS callback. Remove if no supported direct-Python consumer is claimed; keep actual `_search_prompts`, DB reads and monitor/tracker status methods. |
|
||||
| [database/review_changes.py:50](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/review_changes.py#L50), `observer_count` | TEST-ONLY | Keep: it lets lifecycle tests prove observer unsubscribe/leak behavior without inspecting internals. Explicit testing seam, not a user feature. |
|
||||
| [py/api/duplicate_files.py:110](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/duplicate_files.py#L110), `query_plan_sql` | TEST-ONLY | Keep: exposes the real query shape for structural database/performance tests. Removing it only to shrink code would weaken useful assertions. |
|
||||
| [py/api/thumbnail_encode.py:229](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/thumbnail_encode.py#L229), `reset_encoders`; [utils/video_metadata.py:52](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/video_metadata.py#L52), `reset_missing_notice` | TEST-ONLY | Keep: deterministic test/fixture reset seams prevent state leakage across suites. |
|
||||
| [utils/execution_contexts.py:87](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/execution_contexts.py#L87), `context_digest` | TEST-ONLY | Candidate for removal with its focused tests; do not remove live snapshot/content digests, persisted encodings or schema validators. Low priority until its historical API intent is checked. |
|
||||
| [py/lora_utils.py:408](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/lora_utils.py#L408), `get_civitai_image_urls` | TEST-ONLY | Candidate for removal with its extraction tests; the live allow-listed download planner is separate and stays. Confirm direct-Python bridge consumers before deleting this public helper. |
|
||||
| [utils/prompt_graph.py:298](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/prompt_graph.py#L298), `positive_prompt_nodes` | TEST-ONLY | Keep pending an explicit public-helper decision. It is a conservative graph projection used by attribution tests, while runtime counting uses `run_prompt_nodes` / component projections. Do not delete the latter or weaken attribution assertions. |
|
||||
| [utils/prompt_inspector.py:466](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/prompt_inspector.py#L466), `read_inspection_metadata`; `:769`, `inspect_prompt_roles` | TEST-ONLY | **Keep**: explicitly documented compatibility projections over the current reader/resolver. The brief protects legacy-data readers; these tiny facades cost little and must not be confused with old HTML inspectors. |
|
||||
| DB convenience methods including `get_prompts_by_category`, `get_top_rated_prompts`, `delete_prompts_by_category`, `get_image_paths_matching`, `list_library_images`, older context/output reads | TEST-ONLY | Keep in this pass: compatibility/data-reader or destructive-operation surfaces need a stronger contract review. The facade documentation names several; low call counts do not authorize dropping old data access. |
|
||||
| `PromptModel.restore_from_file`, migrations/schema helpers, inline/document workflow readers | TEST-ONLY where applicable | Keep regardless of call count: required recovery, upgrade and old-data compatibility. `reset_schema_cache` is also a necessary test isolation seam. |
|
||||
| [utils/prompt_tracker.py:440](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/prompt_tracker.py#L440), `extend_prompt_timeout`'s `additional_seconds` | UNSURE | Public compatibility parameter is explicitly documented as unused; current behavior resets a timestamp. Keep the signature and do not invent new timeout semantics during cleanup. |
|
||||
| Entire [utils/comfyui_integration.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/comfyui_integration.py) registry and its node writes | UNSURE | Reachable writes, test-only reads, no automatic cleanup; F02. Resolve the external-consumer question before removal. |
|
||||
| Existing HTTP routes with no workspace caller, including old search, pair, scan, restore and image endpoints | UNSURE | Public HTTP consumers and node/bridge clients may exist. Keep until each route's consumer contract is checked; do not call them dead from frontend absence. |
|
||||
|
||||
### Legacy-only UI inventory
|
||||
|
||||
All 16 files below are currently reachable from registered HTML entry points and therefore **LEGACY-ONLY**, not DEAD. Together they account for **10,138 physical lines**. This is a candidate retirement envelope, not a promised net deletion: bookmark shims and retained tests may remain.
|
||||
|
||||
- Pages: [web/admin.html](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/admin.html) (997), [web/gallery.html](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/gallery.html) (624), [web/metadata.html](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/metadata.html) (431).
|
||||
- Controllers: [web/js/admin.js](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/js/admin.js) (3,102), `gallery.js` (3,053), `tags-page.js` (1,146).
|
||||
- Modules loaded only by those pages/controllers: `data-actions.js` (51), `html-escape.js` (42), `image-helpers.js` (162), `list-state.js` (64), `prompt-list-sort.js` (64), `scan-progress.js` (121), `sse-stream.js` (154), `tag-autocomplete.js` (61), `view-router.js` (27), `theme-bridge.js` (39).
|
||||
|
||||
[web/comfy/prompt_manager.js](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/comfy/prompt_manager.js) has a same-named local `escapeHtml` method but does not load the `html-escape.js` module. Conversely, **keep** `api-client.js`, `gallery-settings.js`, `png-metadata.js`, `comfy-metadata.js`, `cache-ownership.js`, node editors, prompt-library/bridge adapters, and `workspace-entry.js`: the workspace and/or canvas still use them. Keep [web/index.html](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/web/index.html)'s compatibility redirect. Never hand-edit `web/lib/`, `web/js/shared/`, `reference/`, the concept or its generated stylesheet mirror.
|
||||
|
||||
Rejected static false positives include `RETURN_TYPES`, `FUNCTION`, `OUTPUT_IS_LIST`, `IS_CHANGED`, `NODE_CLASS_MAPPINGS`, decorator-registered handlers, dynamically selected health checks, SQL/schema constants imported from tests or validators, and context-manager/redirect-handler signature arguments. Unused signature arguments do not authorize breaking those interfaces.
|
||||
|
||||
## 4. Legacy parity and retirement gate
|
||||
|
||||
The approved concept and later rulings allow consolidation and some explicit removals. This table evaluates jobs, not whether every old button has a clone. “Covered” identifies an actual native destination and a declared test; it does not certify behavior on a running user host. “Blocked” means no equivalent job was demonstrated and no specific removal ruling was found. The complete System table is reproduced below.
|
||||
|
||||
### Browsing, editing, metadata and page lifetime
|
||||
|
||||
| Legacy capability / current evidence | Workspace destination | Proof / retirement status |
|
||||
| --- | --- | --- |
|
||||
| Dashboard recent/search list; text/category/tag filtering | Prompt library toolbar and Refine | `workspace-library.spec.mjs`: “search, category and tag filters preserve their constraints and return standalone positives”. Covered. |
|
||||
| Folder filtering of prompts | Prompt library → Refine → Folder | `workspace-library-filters.spec.mjs`: “Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there”. Covered (#444). |
|
||||
| Multiple tags AND/OR; untagged prompts | Library Tag match; Tags → Untagged prompts | `workspace-library-filters.spec.mjs`: “Tag match Any shows every prompt carrying one of the tags; changing only the match applies it”; vocabulary/state tests cover untagged. Covered. |
|
||||
| Newest/oldest and A–Z prompt sorting | Library sort | `workspace-library-state.test.js`, `workspace-library.spec.mjs` toolbar/sort cases. Covered for these orders. |
|
||||
| **Recently Used, Most Used, Z–A prompt sorting** (`prompt-list-sort.js:16`–`:22`) | **None**; native `library.mjs:39` and `library-state.mjs:13` allow only newest/name/oldest | **BLOCKER B1.** No equivalent was found. Preserve legacy. Decide whether each job is required or intentionally retired; do not implement it in this pass. Ratings sorts are a separate approved removal. |
|
||||
| Add prompt; category/tags/notes editing; duplicate rejection | New prompt / Edit metadata | `workspace-library-editor.spec.mjs`: “editor modes persist through reload with selected saved negative”; editor recovery cases. Covered for eligible parents and saved-pair metadata. |
|
||||
| Change existing prompt text in place | Create text revision | Same editor test proves a new positive/pair is created and old text remains. Consolidated with **different deliberate semantics**; include that difference in retirement approval rather than claiming identical mutation. Keep underlying legacy text-update API until consumer audit. |
|
||||
| **Manage the actual prompt-level category/tags/notes/text of a record used only as a negative component** | **No native component-management surface demonstrated**; pair metadata belongs to the pair | **BLOCKER B2 / contract decision.** `library.py:12` and `:449` exclude pure-negative records; `workspace-library.spec.mjs:47` asserts the grouping intentionally. Probe: old component is readable, absent from native parents, native detail is null. Grouping itself is approved; retiring management of that record's own metadata needs an explicit decision. No replacement screen in this pass. |
|
||||
| Prompt rating/minimum rating/Highest or Lowest Rated | No native rating controls | **Approved removal**, D153 / #447, [TESTING.md:3085](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/TESTING.md#L3085); keep stored ratings and the node/API contracts that still use them. Do not replace ratings with favorites in the data model. |
|
||||
| Select prompts; bulk category and tags | Prompt library selection bar | `workspace-library-selection.spec.mjs`: “Set category writes the selected prompts on the server, reloads the shelf and clears the selection”; “Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf”. Covered. |
|
||||
| Confirm/retry prompt deletion | Library Delete… / archived shelf / Delete prompt… | `workspace-library-delete.spec.mjs`: “Delete… reviews the selection, deletes the unpaired, archives the paired and keeps the protected”; paired permanent-delete cases. Covered, with approved reference-safe archive/delete rules. |
|
||||
| View a prompt's images / filmstrip | Its Library history gallery and shared viewer | `workspace-library.spec.mjs`: “history displays the selected full negative beneath its positive and filters one shared physical gallery”. Covered. |
|
||||
| Tag list search; alphabetical and count sorts | Tags & categories toolbar | `workspace-vocabulary-state.test.js`, `workspace-vocabulary.spec.mjs`, #444 tag-sort cases. Covered; TAG_SORTS includes both directions and most/fewest prompts. |
|
||||
| Rename/delete/merge tags and browse associated prompts | Tags & categories | `workspace-vocabulary.spec.mjs`: “rename and merge persist and collapse duplicate links”, “delete tag confirms real impact and removes only the vocabulary name”, “prompt counts equal Library Browse results”. Covered. |
|
||||
| Gallery colliding filenames, source/folder/media filters, filename search | Images | `workspace-images.spec.mjs`: “native Images keeps colliding filenames distinct and inspects the selected source”; source/folder/media filter and filename-search tests. Covered. |
|
||||
| Grid/list, thumbnail size, continuous browsing | Images Grid/List/Masonry, thumbnail size, append + Load more | `workspace-images.spec.mjs`: “layout changes retain selection and use one collection in Grid, List and Masonry”; “continuous loading appends without replacing cards or losing selected details”. Covered. Manual columns/page-size/paged mode have an explicit simplified/approved-removal record, not a replacement control. |
|
||||
| Video playback; autoplay/mute/loop; mixed-media navigation | Shared viewer + Appearance | `workspace-system.spec.mjs`: “video defaults reach the viewer”; Images/video viewer cases; `workspace-inspect.spec.mjs`: “local video plays inline and in the viewer with real tags”. Covered. |
|
||||
| Slideshow, zoom/rotate/flip/fullscreen, keyboard/touch navigation | Shared media viewer | `tests/vendor` viewer fixture suite and [tests/browser/workspace-viewer-keys.spec.mjs](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/tests/browser/workspace-viewer-keys.spec.mjs) / `workspace-viewer-mode.spec.mjs`; viewer key and teardown tests. Keep the actual viewer tests when legacy controller tests go away. |
|
||||
| Metadata sidebar, full positive/negative text, settings, raw workflow, downloads | Shared current-file inspector/viewer | `full-inspector.spec.mjs`, `prompt-inspector.spec.mjs`, `workspace-history.spec.mjs`; the same file/prompt inspection contract is used throughout. Covered. |
|
||||
| Local PNG chooser/drag-and-drop, workflow download, copy individual prompts | Inspect a file | `workspace-inspect.spec.mjs`: “chooser opens the shared full inspector without a second read”, “drop accepts exactly one File and never navigates”, “local inspection makes no content request or storage write”; inspector download tests. Covered; local video extends it. |
|
||||
| Metadata COPY ALL summary | Shared inspector → Copy settings summary | `workspace-inspect.spec.mjs`: “Copy settings summary copies the same legacy summary in Images and Inspect”; no-clipboard fallback case. Covered (#249 L3 / D117). |
|
||||
| Per-image manual/automatic tags; batch generate/review/save | Images selection/tag dialog, Needs review, System tagging | `workspace-tagging-bulk.spec.mjs`, image-tagging/review specs, and native System table below. Covered with the approved image-owned tag model. Do not delete annotation/review persistence with old UI code. |
|
||||
| Add a prompt with category/tags/notes from the gallery (`gallery.js:2383`, `:2591`) | Images → New prompt / shared editor | `workspace-library-editor.spec.mjs`: “new from Images cancel preserves browse” also saves on its second open and checks navigation to the saved history; “editor modes persist through reload with selected saved negative” covers metadata persistence. Covered. The legacy form is a blank editor, not an automatic metadata import. Keep `/save` for existing consumers. |
|
||||
| Protect a prompt from cleanup; deliberately unprotect it | New native parents are retained by Library state; explicit `__protected__` tags remain editable through metadata/tag controls | Maintenance and `workspace-library-delete.spec.mjs` protected-record cases; [TESTING.md:2870](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/TESTING.md#L2870) / `:2924` records how to unprotect and the intentionally stronger deletion rule. Consolidated, with the default/explicit-delete semantics included in retirement approval rather than cloning the checkbox. |
|
||||
| Scan/duplicate job progress, reconnect, cancellation | Activity and shared review/job UI | `workspace-activity.spec.mjs`, System sources/maintenance tests. Covered; jobs remain session-only and lost jobs are shown honestly. |
|
||||
| Old static URLs and `admin#/tags/<tags>?mode=or` bookmarks | Plain-page mappings proposed below; deep filter URLs unresolved | **Approval gate.** Current URLs intentionally remain usable under #525. Hash fragments never reach the HTTP handler, so a blanket server redirect cannot preserve tag bookmarks. |
|
||||
| Bootstrap failure fallback and no-JS links | Current inline `workspace-boot-failure` links point to the legacy pages | **Retirement dependency**, not dead markup. `workspace.spec.mjs` tests module-fetch and startup-throw failures. Replace the fallback with a working reload/canvas/help path before removing its destinations. |
|
||||
|
||||
### System parity: complete current inventory
|
||||
|
||||
Source: [TESTING.md:5619](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/TESTING.md#L5619)–`:5708`. Its validator currently reports **54 rows: 48 covered, 4 kept in legacy, 2 approved removals**, with no missing declared-test references. S13 already removed many old System controls after coverage was approved. Those historical anchors do not imply the controls are still in the current HTML.
|
||||
|
||||
| Capability | Legacy reference (historical) | Native destination/control | Current disposition | Declared proof |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Image scan directories add/remove | `admin.html:377–390`; `admin.js:200,1273–1323` | S4 Sources; `sources` · button · "+ Add source" | covered | Add source validates on the server, then marks the host draft dirty; Save persists (workspace-system-sources.spec.mjs) |
|
||||
| Currently monitoring list + Refresh | `admin.html:392–400`; `admin.js:1032` | S4 Sources; `sources` · button · "Refresh" | covered | watching only with a live observer; restart pending otherwise (workspace-system-sources.spec.mjs) |
|
||||
| Monitoring enabled | `py/config.py:212,248` | S4 Sources; `sources` · switch · "Watch for new outputs" | covered | Watch for new outputs saves monitoring_enabled; a restart applies it (workspace-system-sources.spec.mjs) |
|
||||
| Search results auto-hide `result_timeout` | `admin.html:407–412` | S3 Appearance; `appearance` · spinbutton · "Node search auto-hide (seconds)" | covered | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
|
||||
| Web UI display mode | `admin.html:414–422` | S3 Appearance; `appearance` · combobox · "Open from ComfyUI" | covered | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
|
||||
| Infinite scrolling default | `admin.html:424–430`; `admin.js:689–727` | Legacy prompt list (README continuous browsing); read-only in the report; — | kept in legacy | shouldLoadMore asks for the next page only near the bottom with pages left (list-state.test.js) |
|
||||
| Worker threads | `admin.html:437–445` | S4 Sources; `sources` · slider · "Worker threads" | covered | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
|
||||
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations; `integrations` · switch · "LoraManager integration" | covered | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
|
||||
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations; `integrations` · button · "Review import" | covered | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
|
||||
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview; `overview` · button · "Explore" | covered | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
|
||||
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; [py/api/prompts.py:238](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/prompts.py#L238) | S9 Backup & export; `backups` · button · "Export all prompts JSON" | covered | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Saved pairs export | [py/api/pairs.py:84](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/pairs.py#L84) | S9 Backup & export; `backups` · button · "Export saved pairs JSON" | covered | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Library export | [py/api/library.py:223](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/library.py#L223) | S9 Backup & export; `backups` · button · "Export JSON" | covered | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Diagnostics run | `admin.html:171,659`; `admin.js:1984` | S12 Diagnostics; `diagnostics` · button · "Run checks" | covered | Run checks opens the shared Health report (workspace-system-diagnostics.spec.mjs) |
|
||||
| Synthetic image-link test | `admin.html:662`; `admin.js:2101` | S12 Diagnostics; `diagnostics` · button · "Create test link…" | covered | Create test link confirms first and names the prompt (workspace-system-diagnostics.spec.mjs) |
|
||||
| Maintenance cleanup_duplicates, vacuum, cleanup_orphaned_images, prune_orphaned_prompts | `admin.html:692–748`; `admin.js:1834` | S6/S7 Maintenance; `maintenance` · button · "Review plan" · in "Duplicate prompt text" | covered | Apply reviewed operation closes, toasts, opens the job in Activity (workspace-system-maintenance.spec.mjs) |
|
||||
| Maintenance check_hash_duplicates, check_consistency | `admin.html:692–748`; `admin.js:1834` | S2 Health report (Maintenance notice, D123); `maintenance` · button · "Run health checks" | covered | Run health checks opens Health report and updates metric 4 (workspace-system.spec.mjs) |
|
||||
| Maintenance statistics (database info) | `admin.html:692–748`; `admin.js:1834` | S12 Diagnostics; `diagnostics` · term · "Database" | covered | meta-grid shows real database and source status, the pinned Viewer.js version and the host renderers (workspace-system-diagnostics.spec.mjs) |
|
||||
| Maintenance select all / clear all / run | `admin.html:754–762`; `admin.js:1822–1833` | S7 per-operation review; `maintenance` · button · "Review plan" | covered | plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing (workspace-system-maintenance.spec.mjs) |
|
||||
| Prompt cleanup `/cleanup` | [py/api/admin.py:620](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/admin.py#L620) | S6/S7 Maintenance; `maintenance` · button · "Review plan" · in "Duplicate prompt text" | covered | test_duplicate_prompts_plan_counts_the_legacy_groups (test_maintenance_plans.py) |
|
||||
| Database backup | `admin.html:179,824`; `admin.js:2139,2454` | S9 Backup & export; `backups` · button · "Back up" | covered | Back up downloads through the api client and revokes the object URL (D103) (workspace-system-backups.spec.mjs) |
|
||||
| Restore from file | `admin.html:183,616–642`; `admin.js:2177–2248` | S8/S9 Backup & export; `backups` · button · "Review restore…" | covered | Review restore stages the chosen file and shows the real summary with the backup box (D105) (workspace-system-backups.spec.mjs) |
|
||||
| Metadata scan with progress, cancel, pill | `admin.html:187,805–865`; `admin.js:2336–2500` | S4 Sources; `sources` · button · "Review scan" | covered | Review scan opens the shared Review metadata scan with all sources (workspace-system-sources.spec.mjs) |
|
||||
| Rescan-required notice + dismiss | `admin.html:1367–1410`; `admin.js:991–1011` | S4 Sources; `sources` · button · "Later" | covered | notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed) without scanning (workspace-system-sources.spec.mjs) |
|
||||
| View logs with level and limit | `admin.html:195,885–907,974`; `admin.js:2579–2641` | S12 Logs; `diagnostics` · combobox · "Level" | covered | level filter is minimum-level over all six choices (workspace-system-diagnostics.spec.mjs) |
|
||||
| Log statistics | `admin.html:921–941`; `admin.js:2512` | S12 Logs; `diagnostics` · status · "Log statistics" | covered | log statistics show entries, errors, warnings, files and size (workspace-system-diagnostics.spec.mjs) |
|
||||
| Log files list and download | `admin.html:910,982–985`; `admin.js:2545,2642,2679` | S12 Logs; `diagnostics` · button · "Download log" | covered | each listed file downloads by its returned name and a link has no Download (workspace-system-diagnostics.spec.mjs) |
|
||||
| Clear/truncate logs | `admin.html:915`; `admin.js:2704` | S12 Logs; `diagnostics` · button · "Clear all logs…" | covered | Clear all logs states both counts, confirms first and reads everything again (workspace-system-diagnostics.spec.mjs) |
|
||||
| Rotated-log cleanup (new, #329) | — | S12 Logs; `diagnostics` · button · "Review log cleanup" | covered | Review log cleanup lists only rotated copies and keeps the rest (workspace-system-diagnostics.spec.mjs) |
|
||||
| Set log level (incl. CRITICAL), console logging | `admin.html:948–966`; `admin.js:2530,2729` | S12 Logs; `diagnostics` · combobox · "Runtime log level" | covered | runtime level (including CRITICAL) and console logging save and say they apply until the server restarts (workspace-system-diagnostics.spec.mjs) |
|
||||
| Log file_logging / max_file_size / backup_count / buffer_size | `py/api/logging_routes.py:56,285–299` | S12 Logs (read-only); `diagnostics` · group · "Log files & retention" | covered | each listed file downloads by its returned name and a link has no Download (workspace-system-diagnostics.spec.mjs) |
|
||||
| Auto-tag model list with status | `admin.html:1095–1148`; `admin.js:3641` | S11 Auto-tagging; `tagging` · combobox · "Tagging model" | covered | model options are the four real engines with size and the description shows live status (workspace-system-tagging.spec.mjs) |
|
||||
| Auto-tag model download | `admin.html:1413–1438`; `admin.js:3680–3747`; `gallery.js:3412` | S11 Auto-tagging; `tagging` · button · "Download" | covered | Download starts the job and Activity shows it without Cancel (workspace-system-tagging.spec.mjs) |
|
||||
| Unload model | `gallery.js:3480` | S11 Auto-tagging; `tagging` · button · "Unload" | covered | Unload is enabled only while a model is loaded, and a held gate's refusal names the operation (workspace-system-tagging.spec.mjs) |
|
||||
| Tag template, WD14 thresholds, already-tagged mode | `admin.html:1150–1198`; `admin.js:3748` | S11 Auto-tagging; `tagging` · combobox · "Existing tags" | covered | defaults save through Save preferences and the Tag image dialog uses them (workspace-system-tagging.spec.mjs) |
|
||||
| Batch auto-tag output images | `admin.html:191,1210,1279–1318`; `admin.js:3748–3840` | S11 Auto-tagging; `tagging` · button · "Review" | covered | Tag output images reviews the images, tags each one and never a prompt (#379) (workspace-system-tagging.spec.mjs) |
|
||||
| Batch review loop | `admin.html:1206,1218–1364`; `admin.js:3841–4172` | S11 + image-tagging plan; `tagging` · button · "Preview tagging review" | covered | Preview tagging review opens the Tag image dialog for the selected image (workspace-system-tagging.spec.mjs) |
|
||||
| Gallery per-image auto-tag / review | `gallery.html:601,605`; `gallery.js:3506,3674–3753` | Image-tagging plan (selection bar); — | covered | Auto-tag selected with Apply automatically runs a job with the chosen model, thresholds and suggestion count (workspace-tagging-bulk.spec.mjs) |
|
||||
| Gallery thumbnail generation | `gallery.js:1852,1967` | S5/S7; `sources` · button · "Review options" · in "Thumbnail generation" | covered | Start closes the dialog, toasts, and opens the job in Activity (workspace-system-sources.spec.mjs) |
|
||||
| Clear server thumbnails | `gallery.js:2175–2192` | S7 Maintenance; `maintenance` · button · "Review options" · in "Thumbnail cache" | covered | Thumbnail cache opens the S5 dialog (workspace-system-maintenance.spec.mjs) |
|
||||
| Browser cache clear | `gallery.js:2194–2207` | S7 Maintenance; `maintenance` · button · "Review plan" · in "Metadata cache" | covered | Metadata cache reviews and clears only owned browser entries when there are some (D102) (workspace-system-maintenance.spec.mjs) |
|
||||
| Refresh file listing | `gallery.js:2209` | S4 Sources; `sources` · button · "Refresh" · in "Refresh files" | covered | Refresh files calls /gallery/rescan and toasts its counts (workspace-system-sources.spec.mjs) |
|
||||
| Duplicate image scan, review, delete | `gallery.js:2234–2577` | S6/S7 Maintenance; `maintenance` · button · "Find duplicates" | covered | Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101) (workspace-system-maintenance.spec.mjs) |
|
||||
| Default view mode, image quality | `gallery-settings.js:26–27` | S3 Appearance; `appearance` · combobox · "Default layout" | covered | defaults apply to a new visit but not to the current view or a saved view (workspace-system.spec.mjs) |
|
||||
| Auto-load metadata, cache metadata, show file paths | `gallery-settings.js:31–33` | S3 Appearance; `appearance` · switch · "Load metadata on selection" | covered | metadata off shows an explicit load and loads once on request (workspace-system.spec.mjs) |
|
||||
| Video autoplay, mute, loop | `gallery-settings.js:40–42` | S3 Appearance; `appearance` · switch · "Autoplay videos" | covered | video defaults reach the viewer (workspace-system.spec.mjs) |
|
||||
| Image info on cards; sidebar collapsed | `gallery-settings.js:30,34` | S3 Appearance; `appearance` · switch · "Show image details on cards" | covered | added rows reach their consumers (workspace-system.spec.mjs) |
|
||||
| Reset gallery settings | `gallery.js:1813` | S3 Appearance; `appearance` · button · "Reset" | covered | reset restores defaults without touching other keys (workspace-system.spec.mjs) |
|
||||
| Lazy loading | `gallery-settings.js:25` | Legacy gallery (native always lazy); — | kept in legacy | a legacy gallery Save keeps the workspace keys and every other stored key (gallery-settings.test.js) |
|
||||
| Results per page / grid columns / paged mode | `gallery-settings.js:28–29,35` | README continuous browsing; — | approved removal | numeric strings from <select> values are accepted and columns are clamped (gallery-settings.test.js) |
|
||||
| Startup thumbnail prompt | `gallery-settings.js:38–39`; `gallery.js:2578` | README; Overview thumbnail row; — | approved removal | thumbnail row reflects the status endpoint (workspace-system.spec.mjs) |
|
||||
| Client debug output `debugMode` | `gallery-settings.js:36` | Legacy gallery (S12-10); — | kept in legacy | a legacy gallery Save keeps the workspace keys and every other stored key (gallery-settings.test.js) |
|
||||
| API timeout `apiTimeout` | `gallery-settings.js:37` | Legacy gallery (S12-10; read by no request); — | kept in legacy | defaults are a complete, sane settings object (gallery-settings.test.js) |
|
||||
| config.json-only keys | `py/config.py:212–234,722–739`; [py/api/diagnostics_report.py:185](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/py/api/diagnostics_report.py#L185) | S12 report (read-only); `diagnostics` · button · "Download diagnostic report" | covered | test_report_lists_config_only_keys (test_api_diagnostics_report.py) |
|
||||
|
||||
The four kept-in-legacy settings are presentation/debug preferences, not four missing maintenance tools: legacy infinite-scroll default, lazy-loading toggle, debug output and an API-timeout value read by no request. The native app always uses continuous browsing/lazy loading. Include disposition of these old controls/keys in the retirement approval; keep existing stored preferences readable and preserve unrelated keys. Do not invent new native settings to reproduce inert controls.
|
||||
|
||||
### Bookmark proposal (not approved)
|
||||
|
||||
1. Keep the old HTTP entry points long term, even after their HTML/controllers retire.
|
||||
2. Plain `/admin` → workspace Library; plain `/gallery` → Images; `/gallery.html` → Inspect. Preserve any reverse-proxy mount prefix.
|
||||
3. Serve a tiny `/admin` compatibility shim for fragments, because the server cannot see `#/tags`. Bare `#/tags` can map to workspace Tags. Selected-tag AND/OR bookmarks require a separately approved mapping or explicit retirement of their filter semantics; until that decision, keep their legacy path functional.
|
||||
4. Do not drop unknown query/hash state silently or redirect a tag bookmark to unfiltered Images. Add tests for direct visits, proxy prefixes, Back navigation and fragment handling.
|
||||
5. Replace/remove every old link, including the static boot-failure/noscript fallback and README links, before deleting page files. `workspace.html` edits require a host restart because its response is cached.
|
||||
|
||||
### Backend retirement boundary
|
||||
|
||||
Retirement is not permission to delete every route old controllers called. The workspace still uses `/export`, backup/restore, prompt/pair/tag operations, settings, scans, thumbnails, logs, shared file serving and the model routes. The node library and bridge also call the HTTP surface independently. Keep schemas, migrations, restore/old-data readers, public node names/input order and root node modules. Review each apparently unused route as a separate contract decision after the frontend is gone.
|
||||
|
||||
## 5. Coverage work, ranked by risk
|
||||
|
||||
The aggregate gate is already strong. The useful additions are the following behavioral invariants, not a general percentage chase.
|
||||
|
||||
| Priority | Retained boundary / evidence | Test work before refactoring |
|
||||
| --- | --- | --- |
|
||||
| 1 | UNC-backed backup, restore, Move library, history fence (F01) | Assert each reader uses the valid URI; then native Windows share round trips, old-schema restore, handle closure and rollback/no-write-on-failure. `history_read_fence.py` is 98.39% covered yet misses this whole platform contract. |
|
||||
| 1 | Backup/restore cancellation/adoption and library-move copy failures | `backup_restore.py` is 88.19% combined (78.79% branches); relevant gaps include `:506`, `:558`–`:563`, `:576`–`:577`, `:956`–`:970`. Exercise a cancelled worker that fails, post-publish cleanup failures that must not report an unperformed restore, and stage/handle retirement. In `library_move.py:517`–`:519` and `:538`–`:539`, fail a real copy operation and prove source/old library remain usable and only owned temps are cleaned. |
|
||||
| 1 | Delete/trash and removed-image recovery | Keep original bytes and reference-protection assertions; prove file/stream release before cleanup on Windows. The local macOS run measures [utils/trash.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/utils/trash.py) at 29.15% because Linux/BSD branches are skipped; do not treat that as missing Linux tests or lower a floor. Preserve `test_trash_freedesktop.py`, mount-root and route-audit coverage. This audit did not mount a filesystem or exercise native Windows locking. |
|
||||
| 2 | Prompt/image linking, cached run counts and metadata | Retain conservative ambiguous/unlinked results, FIFO cleanup, saved negative roles, document/inline-reader parity and Python/browser differential tests. Add a bounded-growth test before changing F02; do not replace the registry-reader tests without proving saved image metadata, run counting and history remain correct. |
|
||||
| 3 | API shape/type/oversize boundaries | F04's three cases must fail before the fix, then return 400 and preserve storage. Retain envelope/error-redaction tests; audit the other `.json()`-then-normalize handlers using the same case matrix. |
|
||||
| 3 | Model unload responsiveness and cancellation | Event-gated worker tests for F03; success/failure-only fake service tests are insufficient. Keep model-gate refusal/release tests. |
|
||||
| 4 | Shared code whose legacy tests would disappear | Before deleting legacy specs, move distinct shared PNG/Comfy metadata parsing, API-client/proxy-prefix behavior, gallerySettings unknown-key preservation, Viewer lifecycle, backup/restore and data-safety assertions into native/shared tests. Remove tests only for code actually removed. |
|
||||
|
||||
Existing frontend floors are only 47% lines for admin, 54% for gallery and 57% for tags-page; these are legacy-specific baselines, not permission to give extracted new modules low coverage. New files need at least 80% lines and all retained per-file floors remain. Removing a measured file must remove its obsolete baseline entry and pass `coverage:baseline-check`; retained shared modules still enter combined coverage.
|
||||
|
||||
## 6. Repository organization and distribution
|
||||
|
||||
- Remove the three unreferenced image artifacts listed as DEAD: **1,737,407 bytes** in the current tree. Deleting them does not shrink old Git history, and no history rewrite is proposed.
|
||||
- Keep [README-legacy.md](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/README-legacy.md): README links it for the still-shipped old UI and the upgrade/rollback history, and TESTING links its database-path compatibility section. Do not remove old-data/recovery instructions just because the old controls retire. Later split the useful historical upgrade notes from page-only instructions, updating every link.
|
||||
- Ten of the eleven `images/` files are live documentation assets. Keep them while the legacy README points to them; do not move them for cosmetic root cleanup. `assets/readme/` is the current README's live image set.
|
||||
- Keep root node files exactly where ComfyUI imports them. Do not introduce a `src/` migration or rename a node/input during cleanup.
|
||||
- `docs/` is broadly ignored, including the audit and local plans. Existing tracked concept/design/docs remain tracked; new required tests/spec references in that tree must be explicitly accounted for. Local `EGM.md`, CLAUDE and AGENTS stay excluded. No needed currently tracked runtime file was found hidden by an ignore rule.
|
||||
- TESTING is 7,562 lines / 585,548 bytes. It combines execution commands, a safety contract, machine-parsed parity tables and a large decision/deviation ledger. A later organization commit can split reference material by feature while leaving a short testing entry point, stable links and parser inputs. Update `legacy-coverage.mjs` and its meaningful tests in the same change if tables move. Do not blindly move headings the guard parses.
|
||||
- Defer large facade splits until actual removals show the durable seams. [database/operations.py](https://github.com/ComfyAssets/ComfyUI_PromptManager/blob/7e11d9cae266b74e0820799eb0d74ab493e66026/database/operations.py) can eventually keep the public facade while delegating prompt search, vocabulary and image links. Avoid copying database logic into the API or creating divergent Toolkit controls.
|
||||
- The local tracked-bundle preview includes tests, tooling, docs and screenshots (F09); upstream packaging-source inspection explains why the absence of `.comfyignore` matters. Add a non-publishing artifact-content check and exclusions for the next approved release. No standard Python artifact was produced because the clean-snapshot build failed. Do not make an opportunistic version/build-metadata change or upload the local audit ZIPs.
|
||||
|
||||
## 7. Proposed PR order — approval required
|
||||
|
||||
These are proposed future implementation batches, not work included in this review-only PR. Use issue-linked implementation branches once the maintainer supplies an existing issue or approves creating/updating one. No issue will be posted as an incidental audit action. This review branch uses #334 as the related cutover context and does not close it or any other issue. Each PR needs exact-head checks, bot-review handling, Windows impact/evidence and a `Manual check:` line. Keep required checks/coverage thresholds unchanged.
|
||||
|
||||
| Order | Proposed scope and logical commits | Validation / manual tier |
|
||||
| --- | --- | --- |
|
||||
| 1 — tests and small dead-code cleanup | About 4–6 commits: (1) add distinct retained data-safety coverage that does not just mirror implementation; (2) remove `_current_set`, `_gated_runner` and the unused keyword; (3) remove the old lock/scaffolding and safe test-only wrappers with their own tests; (4) remove the unused config writer while preserving ConfigStore behavior; (5) remove the three unreferenced assets; (6) reconcile root docs/local briefs. Defer public/compatibility helpers marked keep/UNSURE. | Targeted Python/Node tests, full Python suite and both coverage gates, baseline check, vendor/tooling checks, black/flake8 and pre-push. Legacy pages stay. **Manual check: none** for the behavior-neutral portion. |
|
||||
| 2 — narrow recovery/lifecycle fixes, before large refactors | About 4–6 commits, tests before each fix: UNC-safe SQLite reads and native controls; off-loop model unload/cancellation; invalid save-body refusals. These are explicit bug fixes, not a claim of byte-for-byte unchanged failure behavior. The registry only joins if its external-contract question is resolved and host tests prove removal safe. | Native Windows run is mandatory for UNC/handles. Managed Classic/Nodes 2.0 execution/reload tests for node-side changes. **Manual check: quick** for model unload/host behavior; upgrade to **required** if the real dependency behavior cannot be proved with the managed test host, and then keep that work out of a batch until tested. Python changes need a maintainer restart later. |
|
||||
| 3 — maintainability/organization only if still useful | About 3–5 commits: tests protecting the chosen seam; extract retained image/thumbnail/media helpers behind stable facades; remove genuinely redundant private helpers; split testing reference material with stable links/guards; define an artifact-content check without publishing. Avoid moving root node modules or touching pyproject/vendor/concept pins. Do not refactor legacy controllers destined for deletion. | Retained behavior/persistence/failure/cleanup tests and coverage on every new source file (80%+ for JS). **Manual check: none** unless host-facing behavior is involved; then isolate/validate it. |
|
||||
| Separate retirement stage — blocked | Only after parity-table approval and B1/B2/bookmark/fallback decisions. Small commits: links/compatibility entry points; old pages; controllers and proven legacy-only helper modules; application-owned obsolete CSS if any; route/query review last. No protected CSS/vendor edits. Keep database recovery/compatibility code. | Native workspace/shared tests replace distinct legacy coverage first; remove only obsolete tests, rerun baseline check and full frontend gate, and use the CI browser/real-host matrix. **Manual check: required**: targeted maintainer upgrade, bookmarked URL and real-host check. This stage is its own PR(s) and cannot join an untested cleanup batch. |
|
||||
|
||||
If multiple approved PRs are ready together, follow the brief's combined-branch/one-commit-per-PR queue policy. Do not consume additional queue runs merely to parallelize cosmetic cleanup. Do not push a fix while the preceding head's CI still runs. No merging, publication or running-install modification is part of the current authorization.
|
||||
|
||||
## 8. Open decisions and unresolved evidence
|
||||
|
||||
1. **The maintainer approved sharing the audit for further review. Review the proposed implementation batches before starting cleanup.** Public compatibility helpers marked keep/UNSURE and all legacy pages remain unless separately approved.
|
||||
2. **B1:** preserve Recently Used / Most Used / reverse-alphabetical prompt browsing via separate work, or explicitly retire those jobs. Recommendation: keep the legacy page until a ruling; do not quietly remove them.
|
||||
3. **B2:** decide what users should do with prompt-level metadata on pure-negative component records. Recommendation: retain existing management access until that data contract is settled. Positive-parent grouping and pair metadata are already intentional; they do not themselves answer this question.
|
||||
4. **Registry:** identify any supported external consumer. Recommendation: remove the write-only registry only if there is none; otherwise bound both stores. Its public-looking method names and regression assertions merit an explicit decision.
|
||||
5. Approve bookmark mappings and the bootstrap-failure fallback before retirement. Deep tag-filter fragments cannot be preserved by a plain server redirect.
|
||||
6. Standard wheel support and the exact previously published Registry archive remain unverified. The local preview establishes development-file inclusion for the current tracked-file packaging path; `.comfyignore` and an artifact guard are proposed for approval, with no release or pyproject change. npm's initial notices and later offline result do not establish a current vulnerability verdict.
|
||||
7. The audit has no native Windows run, full Firefox/WebKit run, managed real-host matrix, live user test or fresh exact-head CI observation. Those are implementation/retirement verification gates, not results claimed here. No Windows claim is based solely on the macOS pass or the URI probe.
|
||||
|
||||
The next action is additional review of this report. **Keep this PR in draft; do not mark ready, enqueue or merge it. Implementation belongs in separately approved branches.**
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -3,7 +3,6 @@ PromptManager: Main custom node implementation that extends CLIPTextEncode
|
||||
with persistent prompt storage and search capabilities.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -259,19 +258,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
encoding_text.strip(),
|
||||
{
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": prepend_text.strip() if prepend_text else None,
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
@@ -38,7 +37,6 @@ except ImportError:
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
@@ -86,7 +84,6 @@ class PromptManagerBase:
|
||||
self.db = PromptDatabase()
|
||||
self.prompt_tracker = get_prompt_tracker(self.db)
|
||||
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
|
||||
self.comfyui_integration = get_comfyui_integration()
|
||||
|
||||
self._start_gallery_system()
|
||||
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
|
||||
@@ -433,18 +430,6 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to search prompts."""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to get recent prompts."""
|
||||
try:
|
||||
return self.db.get_recent_prompts(limit=limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recent prompts: {e}")
|
||||
return []
|
||||
|
||||
def _start_gallery_system(self):
|
||||
"""Initialize and start the gallery monitoring system."""
|
||||
try:
|
||||
@@ -455,13 +440,6 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Failed to start gallery system: {e}")
|
||||
self.logger.warning("Gallery features will be disabled")
|
||||
|
||||
def get_gallery_status(self) -> Dict[str, Any]:
|
||||
"""Get status of the gallery system."""
|
||||
return {
|
||||
"image_monitor": self.image_monitor.get_status(),
|
||||
"prompt_tracker": self.prompt_tracker.get_status(),
|
||||
}
|
||||
|
||||
def cleanup_gallery_system(self):
|
||||
"""Clean up gallery system resources.
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -173,19 +172,6 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
self.logger.warning(f"Could not save or track paired prompt: {error}")
|
||||
if is_positive:
|
||||
self._mark_untracked(prompt, unique_id)
|
||||
try:
|
||||
self.comfyui_integration.register_prompt(
|
||||
f"promptmanager_pair_{time.time_ns()}",
|
||||
positive,
|
||||
{
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"negative_text": negative_text,
|
||||
"category": category.strip() or None,
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
record_runtime_context(
|
||||
type(self).__name__,
|
||||
text,
|
||||
|
||||
@@ -3,7 +3,6 @@ PromptManagerText: A text-only version of PromptManager that outputs STRING
|
||||
without CLIP encoding, while maintaining all database and search features.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -230,19 +229,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
final_text.strip(),
|
||||
{
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": prepend_text.strip() if prepend_text else None,
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
|
||||
@@ -89,7 +89,6 @@ from .source_rescope import SourceRescopeMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.native_memory import release_free_memory
|
||||
from ...utils.png_text import png_text
|
||||
@@ -101,7 +100,6 @@ except ImportError:
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.native_memory import release_free_memory
|
||||
from utils.png_text import png_text
|
||||
@@ -254,23 +252,6 @@ async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
|
||||
return data, None
|
||||
|
||||
|
||||
class _LongJobGuard:
|
||||
"""Async context manager handed out by ``_acquire_long_job``."""
|
||||
|
||||
def __init__(self, lock):
|
||||
self._lock = lock
|
||||
|
||||
async def __aenter__(self):
|
||||
# The caller checked ``locked()`` on this same loop iteration, so this
|
||||
# acquire never waits.
|
||||
await self._lock.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self._lock.release()
|
||||
return False
|
||||
|
||||
|
||||
# ── Gzip compression middleware ────────────────────────────────────────
|
||||
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
|
||||
_GZIP_TYPES = frozenset(
|
||||
@@ -426,10 +407,6 @@ class PromptManagerAPI(
|
||||
self._workspace_card_cache = {}
|
||||
# Favorite keys of each root's cached scan (images._favorite_listing).
|
||||
self._favorite_listing_cache = {}
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# This session's jobs (py/api/jobs.py): the metadata scan, and later
|
||||
# every other long operation the Activity page tracks.
|
||||
self._jobs = JobRegistry(self.logger)
|
||||
@@ -486,26 +463,6 @@ class PromptManagerAPI(
|
||||
_public_error = staticmethod(_public_error)
|
||||
_read_json_body = staticmethod(_read_json_body)
|
||||
|
||||
def _acquire_long_job(self, name):
|
||||
"""Claim the single-flight slot for a long job family, or None when busy.
|
||||
|
||||
Usage in a handler::
|
||||
|
||||
job = self._acquire_long_job("duplicates")
|
||||
if job is None:
|
||||
return self._long_job_busy_response("Duplicate scan")
|
||||
async with job:
|
||||
...
|
||||
|
||||
Families are free-form strings ("duplicates", "thumbnails",
|
||||
"autotag", ...); the lock is released when the ``async with`` block
|
||||
exits, including on exceptions. Must be called from the event loop.
|
||||
"""
|
||||
lock = self._long_jobs.setdefault(name, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return None
|
||||
return _LongJobGuard(lock)
|
||||
|
||||
@staticmethod
|
||||
def _long_job_busy_response(label):
|
||||
"""409 envelope for a long job that is already running."""
|
||||
@@ -1079,7 +1036,3 @@ class PromptManagerAPI(
|
||||
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
return parse_comfyui_prompt(metadata)
|
||||
|
||||
+3
-1
@@ -30,8 +30,10 @@ from .jobs import (
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.sqlite_uri import readonly_uri
|
||||
from ...utils.parallel import map_parallel
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from utils.parallel import map_parallel
|
||||
|
||||
try:
|
||||
@@ -399,7 +401,7 @@ def _inspect_restore_upload(path):
|
||||
Returns:
|
||||
None when acceptable, otherwise a human-readable reason (blocking).
|
||||
"""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
try:
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
except sqlite3.Error as e:
|
||||
|
||||
@@ -943,16 +943,6 @@ class AutotagRoutesMixin:
|
||||
return refusal.response
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
def _gated_runner(self, owner):
|
||||
"""Executor calls under the model gate *owner* holds (plain when None)."""
|
||||
if owner is None:
|
||||
return self._run_in_executor
|
||||
|
||||
async def run(func, *args, **kwargs):
|
||||
return await model_gate.run(owner, func, *args, **kwargs)
|
||||
|
||||
return run
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
@@ -1421,7 +1411,8 @@ class AutotagRoutesMixin:
|
||||
)
|
||||
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
# Off the loop; a dropped request keeps the gate until it ends.
|
||||
await model_gate.run(owner, service.unload_model)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -56,6 +55,7 @@ from .maintenance_plans import PlanExpired, PlanUsed
|
||||
from .restore_plans import RestorePlanStore
|
||||
|
||||
try:
|
||||
from ...database.sqlite_uri import readonly_uri
|
||||
from ...database import restore_stage
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.safety_backup import (
|
||||
@@ -66,6 +66,7 @@ try:
|
||||
safety_temp,
|
||||
)
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from database import restore_stage
|
||||
from database.operations import PromptDatabase
|
||||
from database.safety_backup import (
|
||||
@@ -186,7 +187,7 @@ def snapshot_corruption(path):
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read(len(SQLITE_HEADER)) != SQLITE_HEADER:
|
||||
return "Not a SQLite database file"
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
except (OSError, sqlite3.Error) as error:
|
||||
|
||||
+3
-1
@@ -377,7 +377,9 @@ class LibraryRoutesMixin:
|
||||
[parent["id"] for parent in result["items"]]
|
||||
)
|
||||
result["next_cursor"] = _encode(
|
||||
result.pop("next_key"), scope, name=options["sort"] == "name"
|
||||
result.pop("next_key"),
|
||||
scope,
|
||||
name=options["sort"] in ("name", "name-desc"),
|
||||
)
|
||||
return result, records
|
||||
|
||||
|
||||
+33
-11
@@ -69,6 +69,8 @@ _state_lock = threading.Lock()
|
||||
_parsed = preview_location.ParsedLocation()
|
||||
_available = True
|
||||
_problem = None
|
||||
# The newest check whose answer was published.
|
||||
_published_order = 0
|
||||
_keys = {}
|
||||
_key_cache = {}
|
||||
_clock = time.monotonic
|
||||
@@ -167,10 +169,13 @@ def refresh():
|
||||
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
|
||||
return parsed.mode == SOURCE_MODE
|
||||
try:
|
||||
available, problem, marker = _probe.check(parsed.path, parsed.id)
|
||||
(available, problem, marker), order = _probe.ordered_check(
|
||||
parsed.path, parsed.id
|
||||
)
|
||||
except (OSError, TimeoutError, RuntimeError):
|
||||
available, problem, marker = False, "unreachable", None
|
||||
_set_state(parsed, available, problem, marker)
|
||||
order = _probe.latest_order()
|
||||
_set_state(parsed, available, problem, marker, order)
|
||||
return available
|
||||
|
||||
|
||||
@@ -207,11 +212,16 @@ def mark_invalid():
|
||||
_available, _problem = False, "invalid"
|
||||
|
||||
|
||||
def _set_state(parsed, available, problem, marker):
|
||||
global _available, _problem
|
||||
def _set_state(parsed, available, problem, marker, order):
|
||||
"""Publish a check's answer, unless a check that started later already
|
||||
published (two callers can finish out of order, #611)."""
|
||||
global _available, _problem, _published_order
|
||||
with _state_lock:
|
||||
if _parsed is not parsed:
|
||||
return # the setting changed meanwhile
|
||||
if order < _published_order:
|
||||
return
|
||||
_published_order = order
|
||||
_available, _problem = available, problem
|
||||
keys = dict((marker or {}).get("keys") or {})
|
||||
if keys != _keys:
|
||||
@@ -261,14 +271,25 @@ class _Probe:
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._checking = None
|
||||
self._order = 0
|
||||
|
||||
def check(self, path, expected_id):
|
||||
"""``folder_state`` within IO_BUDGET; raises TimeoutError.
|
||||
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
|
||||
return self.ordered_check(path, expected_id)[0]
|
||||
|
||||
The answer comes from a check that started after this call: one
|
||||
already running may have looked before what the caller just did, so
|
||||
it is waited for (never run twice at once; a stuck one times out here)
|
||||
and followed by a new one."""
|
||||
def latest_order(self):
|
||||
"""The order number of the check started last."""
|
||||
with self._lock:
|
||||
return self._order
|
||||
|
||||
def ordered_check(self, path, expected_id):
|
||||
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
|
||||
|
||||
``order`` grows with each check started, so answers can be published
|
||||
in the order they looked. The answer comes from a check that started
|
||||
after this call: one already running may have looked before what the
|
||||
caller just did, so it is waited for (never run twice at once; a stuck
|
||||
one times out here) and followed by a new one."""
|
||||
key = (path, expected_id)
|
||||
with self._lock:
|
||||
earlier = self._running(key)
|
||||
@@ -284,7 +305,7 @@ class _Probe:
|
||||
self._checking = None
|
||||
if "error" in pending[2]:
|
||||
raise pending[2]["error"]
|
||||
return pending[2]["value"]
|
||||
return pending[2]["value"], pending[2]["order"]
|
||||
|
||||
def _running(self, key):
|
||||
"""The check of *key* still running (lock held), else None."""
|
||||
@@ -300,7 +321,8 @@ class _Probe:
|
||||
|
||||
def _start_check(self, path, expected_id):
|
||||
"""Start one check on its own thread (lock held)."""
|
||||
result = {}
|
||||
self._order += 1
|
||||
result = {"order": self._order}
|
||||
done = threading.Event()
|
||||
|
||||
def run():
|
||||
|
||||
+9
-1
@@ -727,7 +727,15 @@ class PromptRoutesMixin:
|
||||
async def save_prompt(self, request):
|
||||
"""Save a new prompt with metadata and duplicate detection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
data, error = await self._read_json_body(request)
|
||||
if error is not None:
|
||||
return error
|
||||
for field in ("text", "category", "notes"):
|
||||
if data.get(field) is not None and not isinstance(data[field], str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"{field} must be text"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
|
||||
@@ -632,10 +632,6 @@ class AutoThumbnailer:
|
||||
"""True once the worker has nothing queued or in hand."""
|
||||
return self._wait_for(lambda: not self._busy, timeout)
|
||||
|
||||
def wait_batch(self, size, timeout=None):
|
||||
"""True once the worker holds a batch of *size* files."""
|
||||
return self._wait_for(lambda: self._batch == size, timeout)
|
||||
|
||||
|
||||
class _PreviewGate:
|
||||
"""Admission of view requests on the event loop (#249 I2).
|
||||
@@ -967,15 +963,6 @@ class AutoThumbnailMixin:
|
||||
return "queued"
|
||||
return "skipped"
|
||||
|
||||
def _capture_target(self, image_path):
|
||||
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
|
||||
located = self._capture_source(image_path)
|
||||
if located is None:
|
||||
return None
|
||||
src, root, rel, is_video = located
|
||||
dst = DeferredPreview(root, rel).resolve()
|
||||
return None if dst is None else (src, dst, is_video)
|
||||
|
||||
def _capture_source(self, image_path):
|
||||
"""``(src, root, rel, is_video)`` of a media file under a current
|
||||
root (not a preview, a link or in a trash folder), or None."""
|
||||
|
||||
@@ -90,7 +90,7 @@ class ThumbnailOptions:
|
||||
}
|
||||
|
||||
|
||||
def thumbnail_options(raw, *, from_query=False):
|
||||
def thumbnail_options(raw):
|
||||
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
|
||||
|
||||
An absent mode is ``missing``; the settings are the server's now.
|
||||
@@ -136,7 +136,7 @@ class ThumbnailJobsMixin:
|
||||
``mode=prune`` lists the removed sources' thumbnails (#320)."""
|
||||
if request.query.get("mode") == "prune":
|
||||
return await self.get_prune_plan()
|
||||
options, error = thumbnail_options(request.query, from_query=True)
|
||||
options, error = thumbnail_options(request.query)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
try:
|
||||
|
||||
@@ -78,27 +78,6 @@ def _canonical_path(path: str) -> str:
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def write_private_json(path: str, data: Any) -> None:
|
||||
"""Write ``data`` as JSON to ``path`` readable by the current user only.
|
||||
|
||||
config.json can hold an API key (integrations.lora_manager), so it is
|
||||
created with mode 0600 and, on POSIX, an existing more open file is
|
||||
tightened too. Windows has no POSIX modes; there the chmod is a best
|
||||
effort that only affects the read-only bit. Parent directories are
|
||||
created as needed. Raises OSError when the file cannot be written.
|
||||
"""
|
||||
import json
|
||||
|
||||
parent_dir = os.path.dirname(path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
if os.name == "posix":
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _lexical_path(path: str) -> str:
|
||||
"""``path`` normalised for comparison without touching the filesystem.
|
||||
|
||||
@@ -717,12 +696,6 @@ class AutotagConfig:
|
||||
def suggestion_limit(cls) -> int:
|
||||
return cls.SUGGESTION_LIMIT
|
||||
|
||||
@staticmethod
|
||||
def valid_suggestion_limit(value: Any) -> bool:
|
||||
return (
|
||||
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
|
||||
) # bool, float and str are rejected
|
||||
|
||||
@classmethod
|
||||
def all_instructions(cls) -> List[Dict[str, str]]:
|
||||
"""Built-in first, then the saved entries (copies)."""
|
||||
@@ -1063,35 +1036,6 @@ class PromptManagerConfig:
|
||||
else:
|
||||
config_logger.info(f"Config file not found: {config_path}, using defaults")
|
||||
|
||||
@classmethod
|
||||
def save_to_file(cls, config_path: Optional[str] = None):
|
||||
"""Save the current configuration to a JSON file.
|
||||
|
||||
Serializes the complete configuration (including gallery settings) to
|
||||
a JSON file. Creates the directory structure if it doesn't exist.
|
||||
|
||||
Args:
|
||||
config_path (str): Path where the JSON configuration file should be saved.
|
||||
Parent directories will be created if they don't exist.
|
||||
Defaults to :meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
than propagating them.
|
||||
|
||||
Example:
|
||||
PromptManagerConfig.save_to_file('backup_config.json')
|
||||
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
try:
|
||||
write_private_json(config_path, cls.get_config())
|
||||
config_logger.info(f"Saved configuration to {config_path}")
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error saving config to {config_path}: {e}")
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
"""Update configuration attributes from a dictionary.
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.4.1"
|
||||
version = "3.6.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
|
||||
classifiers = ["Operating System :: OS Independent"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.2.1"]
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Runtime dependencies
|
||||
# Runtime dependencies (Pillow, aiohttp and numpy floors match ComfyUI requirements.txt)
|
||||
watchdog>=6.0.0
|
||||
Pillow>=12.3.0
|
||||
aiohttp>=3.14.5
|
||||
Pillow>=10.1.0
|
||||
aiohttp>=3.11.8
|
||||
|
||||
# Tests and coverage
|
||||
coverage>=7.16.2
|
||||
numpy>=2.2.6 # WD14 engine tests
|
||||
coverage>=7.17.0
|
||||
numpy>=1.25.0 # WD14 engine tests
|
||||
|
||||
# Code quality
|
||||
black==26.10.1
|
||||
flake8==7.4.1
|
||||
flake8==7.5.0
|
||||
|
||||
# Security scanning
|
||||
bandit[toml]>=1.9.4
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=4.6.2
|
||||
pre-commit>=4.7.0
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Runtime dependencies for PromptManager.
|
||||
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
|
||||
watchdog>=2.1.0 # Output directory monitoring
|
||||
Pillow>=8.0.0 # Image metadata extraction and thumbnails
|
||||
Pillow>=10.1.0 # Image metadata extraction and thumbnails
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,47 @@
|
||||
// #615: each old page says it will be removed and links to where its job lives now. The page keeps working, and the
|
||||
// notice takes its own room: nothing is pushed off a page whose layout fills the window.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const PAGES = Object.freeze([
|
||||
{ path: '/prompt_manager/admin', section: 'library', heading: 'Prompt library' },
|
||||
{ path: '/prompt_manager/gallery', section: 'images', heading: 'Images' },
|
||||
{ path: '/prompt_manager/gallery.html', section: 'inspect', heading: 'Inspect a file' },
|
||||
]);
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset', {})).toBeOK();
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pm_update_notice_dismissed', '3.2.0');
|
||||
localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false }));
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
});
|
||||
|
||||
for (const { path, section, heading } of PAGES) {
|
||||
test(`${path} says it will be removed and links to ${section}`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
const notice = page.locator('#legacy-removal-notice');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText('This page will be removed in a future release.');
|
||||
await notice.getByRole('link', { name: 'Open the workspace' }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace#/${section}$`));
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(heading);
|
||||
});
|
||||
}
|
||||
|
||||
test('the notice leaves the metadata page filling the window', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/gallery.html');
|
||||
await expect(page.locator('#legacy-removal-notice')).toBeVisible();
|
||||
const { bottom, height, scroll } = await page.locator('#image-area').evaluate(area => ({
|
||||
bottom: area.getBoundingClientRect().bottom,
|
||||
height: window.innerHeight,
|
||||
scroll: document.documentElement.scrollHeight,
|
||||
}));
|
||||
expect(bottom).toBeLessThanOrEqual(height);
|
||||
expect(scroll).toBeLessThanOrEqual(height);
|
||||
});
|
||||
@@ -21,8 +21,9 @@ export function mapLibraryConcept(reference) {
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated"; D205
|
||||
// (#620) appends the old Dashboard's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Recently used','Most used','Name Z–A']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
|
||||
@@ -167,8 +167,6 @@ const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview, images) {
|
||||
// D165: the discreet legacy-pages line follows the settings layout.
|
||||
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
|
||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
|
||||
const rows = {
|
||||
@@ -265,7 +263,7 @@ const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
||||
].sort();
|
||||
|
||||
test('system comparison detects mutations', async ({ page, request }) => {
|
||||
|
||||
@@ -191,7 +191,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
// D153 (#447): no "Highest rated".
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first','Recently used','Most used','Name Z–A']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
|
||||
@@ -205,7 +205,7 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
return (await response.json()).id;
|
||||
};
|
||||
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'shuffle' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
|
||||
|
||||
@@ -135,34 +135,11 @@ test('/prompt_manager/web lands on the workspace Library and Back does not bounc
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
|
||||
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
|
||||
// #620: the old pages are on their way out (notice since 3.5.1), so the workspace no longer links them.
|
||||
test('System no longer links the old pages (#620)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
const line = page.locator('#workspace-main .legacy-pages');
|
||||
await expect(line).toBeVisible();
|
||||
await expect(line).toContainText('Legacy pages');
|
||||
const links = line.getByRole('link');
|
||||
await expect(links).toHaveCount(4);
|
||||
for (const [name, url, ready] of [
|
||||
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
|
||||
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
|
||||
['Gallery', '/prompt_manager/gallery', 'body'],
|
||||
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
|
||||
]) {
|
||||
const opened = context.waitForEvent('page');
|
||||
await line.getByRole('link', { name, exact: true }).click();
|
||||
const tab = await opened;
|
||||
await tab.waitForLoadState('domcontentloaded');
|
||||
const target = new URL(tab.url());
|
||||
expect(target.pathname + target.hash).toBe(url);
|
||||
await expect(tab.locator(ready).first()).toBeAttached();
|
||||
await tab.close();
|
||||
}
|
||||
// Only on System's Overview section, and gone with the page.
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(line).toBeHidden();
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('System');
|
||||
await expect(page.locator('#workspace-main a[href*="/admin"], #workspace-main a[href*="/gallery"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
const bootFailure = page => page.locator('#workspace-boot-failure');
|
||||
@@ -175,7 +152,7 @@ test('a healthy workspace never shows the load failure message (#525)', async ({
|
||||
});
|
||||
|
||||
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
|
||||
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
|
||||
test(`when ${label} fails to load the page offers a reload and ComfyUI (#525, #620)`, async ({ page }) => {
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
await page.route(pattern, route => route.abort());
|
||||
@@ -183,19 +160,17 @@ for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*
|
||||
const failure = bootFailure(page);
|
||||
await expect(failure).toBeVisible();
|
||||
await expect(failure).toHaveAttribute('role', 'alert');
|
||||
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
|
||||
await expect(dashboard).toBeVisible();
|
||||
await expect(failure.getByRole('link')).toHaveCount(4);
|
||||
await dashboard.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
|
||||
await expect(failure.getByRole('link', { name: 'Reload the page', exact: true })).toHaveAttribute('href', 'workspace');
|
||||
await expect(failure.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', '../');
|
||||
await expect(failure.getByRole('link')).toHaveCount(2);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
|
||||
test('when a module throws while starting the page still offers a reload (#525, #620)', async ({ page }) => {
|
||||
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
|
||||
await expect(bootFailure(page).getByRole('link', { name: 'Reload the page', exact: true })).toBeVisible();
|
||||
// The broken module's own error is this test's premise, not a failure of the page.
|
||||
page.workspaceErrors.length = 0;
|
||||
});
|
||||
@@ -569,7 +544,7 @@ test('System is native: the concept shell, section navigation, sections and the
|
||||
// D92: no "Sample configuration" chip.
|
||||
await expect(main.locator('.heading .chip')).toHaveCount(0);
|
||||
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
|
||||
const nav = main.getByRole('group', { name: 'System sections', exact: true });
|
||||
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
@@ -143,7 +143,7 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
|
||||
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
|
||||
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
|
||||
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
|
||||
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
for(const sort of ['newest','oldest','name','name-desc','most-used','last-used']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
});
|
||||
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
|
||||
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
|
||||
|
||||
@@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'rating' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
@@ -244,5 +244,7 @@ test('a view saved under the retired Highest rated sort opens on Recently added
|
||||
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
|
||||
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
|
||||
for (const sort of ['oldest', 'name-desc', 'most-used', 'last-used']) {
|
||||
assert.equal(normalizeViewState('library', { sort }).sort, sort);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,13 +93,6 @@ test('every Overview card opens its own native page, never a legacy one (#525)',
|
||||
}
|
||||
});
|
||||
|
||||
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
|
||||
const { LEGACY_PAGES } = await source;
|
||||
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
|
||||
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
|
||||
for (const page of LEGACY_PAGES) assert.ok(page.label);
|
||||
});
|
||||
|
||||
test('a job of the Activity page is its own deep link', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff } = await source;
|
||||
const id = '0123456789abcdef'.repeat(2);
|
||||
|
||||
@@ -732,6 +732,32 @@ class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
|
||||
|
||||
class TestUnloadAutotag(AutotagAPITestCase):
|
||||
|
||||
async def test_unload_runs_off_the_loop_and_holds_the_gate_until_done(self):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
self.addCleanup(release.set)
|
||||
threads = []
|
||||
|
||||
def unload():
|
||||
threads.append(threading.get_ident())
|
||||
started.set()
|
||||
release.wait(5)
|
||||
self.service._loaded_type = None
|
||||
|
||||
self.service._loaded_type = "gguf"
|
||||
self.service.unload_model = unload
|
||||
first = asyncio.ensure_future(
|
||||
self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
)
|
||||
self.assertTrue(await asyncio.to_thread(started.wait, 5))
|
||||
self.assertNotEqual(threads, [threading.get_ident()])
|
||||
second = await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
self.assertEqual(second.status, 409)
|
||||
release.set()
|
||||
data = await (await first).json()
|
||||
self.assertEqual(data["message"], "GGUF model unloaded successfully")
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_nothing_loaded(self):
|
||||
resp = await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
data = await resp.json()
|
||||
|
||||
+3
-54
@@ -343,7 +343,7 @@ class TestReadJsonBody(AioHTTPTestCase):
|
||||
|
||||
|
||||
class TestLongJobLock(unittest.TestCase):
|
||||
"""_acquire_long_job: one job per family at a time, released on exit."""
|
||||
"""The refusal a busy long job answers with."""
|
||||
|
||||
def setUp(self):
|
||||
database = MagicMock()
|
||||
@@ -353,33 +353,6 @@ class TestLongJobLock(unittest.TestCase):
|
||||
def _run(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_second_acquire_is_refused_until_first_releases(self):
|
||||
async def scenario():
|
||||
first = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(first)
|
||||
async with first:
|
||||
self.assertIsNone(self.api._acquire_long_job("duplicates"))
|
||||
# Other families are independent
|
||||
other = self.api._acquire_long_job("thumbnails")
|
||||
self.assertIsNotNone(other)
|
||||
async with other:
|
||||
pass
|
||||
again = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(again)
|
||||
async with again:
|
||||
pass
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_lock_is_released_when_the_job_raises(self):
|
||||
async def scenario():
|
||||
with self.assertRaises(RuntimeError):
|
||||
async with self.api._acquire_long_job("duplicates"):
|
||||
raise RuntimeError("boom")
|
||||
self.assertIsNotNone(self.api._acquire_long_job("duplicates"))
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_busy_response_is_409_with_envelope(self):
|
||||
resp = self.api._long_job_busy_response("Duplicate scan")
|
||||
self.assertEqual(resp.status, 409)
|
||||
@@ -796,6 +769,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
|
||||
self.assertEqual(resp.status, 200, path)
|
||||
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip", path)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-cache", path)
|
||||
await resp.read() # aiohttp before 3.11.11 hangs on an unread body
|
||||
again = await self.client.request(
|
||||
"GET",
|
||||
path,
|
||||
@@ -806,6 +780,7 @@ class TestRevalidatedCachingBehindMiddlewares(CoreRoutesTestCase):
|
||||
)
|
||||
self.assertEqual(again.status, 304, path)
|
||||
self.assertEqual(again.headers["Cache-Control"], "no-cache", path)
|
||||
await again.read()
|
||||
|
||||
|
||||
# ── instance helpers ──────────────────────────────────────────────────
|
||||
@@ -951,32 +926,6 @@ class TestMetadataParsing(unittest.TestCase):
|
||||
f.write(b"nope")
|
||||
self.assertEqual(self.api._extract_comfyui_metadata(garbage), {})
|
||||
|
||||
def test_parse_a1111_parameters(self):
|
||||
meta = {"parameters": "a cat\nNegative prompt: dog\nSteps: 20"}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertEqual(parsed["positive_prompt"], "a cat")
|
||||
self.assertEqual(parsed["negative_prompt"], "dog")
|
||||
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
|
||||
|
||||
def test_parse_comfyui_fields(self):
|
||||
meta = {
|
||||
"prompt": json.dumps({"1": {"class_type": "X", "inputs": {}}}),
|
||||
"workflow": "not json",
|
||||
"steps": "20",
|
||||
"cfg": "not-json-either",
|
||||
}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertIsInstance(parsed["prompt"], dict)
|
||||
self.assertEqual(parsed["workflow"], "not json")
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertEqual(parsed["parameters"]["cfg"], "not-json-either")
|
||||
|
||||
parsed = self.api._parse_comfyui_prompt(
|
||||
{"prompt": "plain text", "workflow": json.dumps({"nodes": []})}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], "plain text")
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -42,6 +42,37 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
self.assertEqual(len(media["images"]), 1)
|
||||
self.assertEqual(media["images"][0]["path"], "same.png")
|
||||
|
||||
async def test_used_and_reverse_name_sorts_page_through_every_prompt(self):
|
||||
ids = {text: self._save_prompt(text) for text in ("alpha", "beta", "gamma")}
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
for text, runs, used in (
|
||||
("alpha", 1, "2026-01-01T00:00:00+00:00"),
|
||||
("beta", 9, "2026-02-01T00:00:00+00:00"),
|
||||
("gamma", 0, None),
|
||||
):
|
||||
conn.execute(
|
||||
"UPDATE prompts SET run_count=?, last_used_at=? WHERE id=?",
|
||||
(runs, used, ids[text]),
|
||||
)
|
||||
conn.commit()
|
||||
for sort, expected in (
|
||||
("most-used", ["beta", "alpha", "gamma"]),
|
||||
("last-used", ["beta", "alpha", "gamma"]),
|
||||
("name-desc", ["gamma", "beta", "alpha"]),
|
||||
):
|
||||
with self.subTest(sort=sort):
|
||||
seen, cursor = [], None
|
||||
for _ in expected:
|
||||
params = {"sort": sort, "limit": 1}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
response, page = await self.get(**params)
|
||||
self.assertEqual(response.status, 200)
|
||||
seen.extend(item["text"] for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
self.assertEqual(seen, expected)
|
||||
self.assertIsNone(cursor)
|
||||
|
||||
async def test_scoped_cursor_tampering_filters_and_continuation(self):
|
||||
for text in ("one", "two", "three"):
|
||||
self._save_prompt(text)
|
||||
|
||||
@@ -684,6 +684,28 @@ class TestTagMutationRoutes(RouteCoverageCase):
|
||||
|
||||
class TestSaveRoute(RouteCoverageCase):
|
||||
|
||||
async def test_a_malformed_body_is_refused_and_saves_nothing(self):
|
||||
for body in (
|
||||
[],
|
||||
"text",
|
||||
{"text": 123},
|
||||
{"text": "fixture", "category": 123},
|
||||
{"text": "fixture", "notes": 123},
|
||||
{"text": "fixture", "tags": 123},
|
||||
{"text": "fixture", "rating": "high"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json=body
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", data=b"{not json"
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(self.api.db.search_prompts(text="fixture"), [])
|
||||
|
||||
async def test_invalid_rating_is_400(self):
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json={"text": "ok", "rating": 99}
|
||||
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -378,25 +377,6 @@ class TestValidation(unittest.TestCase):
|
||||
self.assertTrue(text.endswith("."))
|
||||
|
||||
|
||||
class TestNoLegacySaveCallers(unittest.TestCase):
|
||||
def test_save_to_file_has_no_callers(self):
|
||||
offenders = []
|
||||
for folder in ("py", "utils", "database"):
|
||||
for path in (REPO / folder).rglob("*.py"):
|
||||
for number, line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(), 1
|
||||
):
|
||||
code = line.split("#", 1)[0]
|
||||
if (
|
||||
re.search(r"\bsave_to_file\(", code)
|
||||
and "def save_to_file" not in code
|
||||
):
|
||||
if "PromptManagerConfig.save_to_file('" in line:
|
||||
continue # its own docstring example
|
||||
offenders.append(f"{path.relative_to(REPO)}:{number}")
|
||||
self.assertEqual(offenders, [])
|
||||
|
||||
|
||||
try:
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
|
||||
@@ -197,7 +197,6 @@ class JobCase(unittest.TestCase):
|
||||
node.logger = logging.getLogger("test.cached_runs")
|
||||
node.prompt_tracker = mock.Mock()
|
||||
self.trackers.append(node.prompt_tracker)
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.pending_first_use = self.pending
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Tests for utils.comfyui_integration."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.comfyui_integration import ComfyUIMetadataIntegration
|
||||
|
||||
|
||||
class TestSaveImageNotPatched(unittest.TestCase):
|
||||
"""Regression for #75: SaveImage must save each node's own prompt inputs.
|
||||
|
||||
The old SaveImage patch wrote the last-run PromptManager prompt into every
|
||||
PromptManager node, so negative prompts were saved as copies of the positive.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
def save_images(
|
||||
self_node,
|
||||
images,
|
||||
filename_prefix="ComfyUI",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
return {"prompt": prompt}
|
||||
|
||||
self.original_save_images = save_images
|
||||
fake_nodes = types.ModuleType("nodes")
|
||||
fake_nodes.SaveImage = type("SaveImage", (), {"save_images": save_images})
|
||||
self._saved_nodes = sys.modules.get("nodes")
|
||||
sys.modules["nodes"] = fake_nodes
|
||||
self.fake_nodes = fake_nodes
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
if self._saved_nodes is None:
|
||||
sys.modules.pop("nodes", None)
|
||||
else:
|
||||
sys.modules["nodes"] = self._saved_nodes
|
||||
|
||||
def test_save_images_is_left_untouched(self):
|
||||
ComfyUIMetadataIntegration()
|
||||
self.assertIs(self.fake_nodes.SaveImage.save_images, self.original_save_images)
|
||||
|
||||
def test_registered_prompt_does_not_rewrite_saved_metadata(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "positive final text", {})
|
||||
prompt = {
|
||||
"39": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
|
||||
"40": {"class_type": "PromptManager", "inputs": {"text": "blurry"}},
|
||||
}
|
||||
|
||||
result = self.fake_nodes.SaveImage().save_images([], prompt=prompt)
|
||||
|
||||
self.assertEqual(result["prompt"]["39"]["inputs"]["text"], "a cat")
|
||||
self.assertEqual(result["prompt"]["40"]["inputs"]["text"], "blurry")
|
||||
|
||||
def test_prompt_registry_still_available_to_nodes(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "hello", {})
|
||||
self.assertEqual(integration.get_current_prompt_text("pm_1"), "hello")
|
||||
|
||||
|
||||
import threading # noqa: E402
|
||||
import unittest.mock as mock # noqa: E402
|
||||
|
||||
from utils import comfyui_integration # noqa: E402
|
||||
from utils.comfyui_integration import get_comfyui_integration # noqa: E402
|
||||
|
||||
|
||||
class IntegrationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
# Patch the module's own ``time`` name rather than ``time.time`` globally:
|
||||
# background threads from other tests (image monitor, watchdog) would
|
||||
# otherwise tick this clock and make fresh registrations look stale.
|
||||
self.clock = mock.patch.object(
|
||||
comfyui_integration, "time", types.SimpleNamespace(time=self._now)
|
||||
)
|
||||
self.clock.start()
|
||||
self.addCleanup(self.clock.stop)
|
||||
self._ticks = 1000.0
|
||||
self.integration = get_comfyui_integration()
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
|
||||
def _now(self):
|
||||
self._ticks += 1.0
|
||||
return self._ticks
|
||||
|
||||
|
||||
class TestSingletonAndRegistry(IntegrationTestCase):
|
||||
def test_singleton_keeps_registrations_across_constructions(self):
|
||||
self.assertIs(get_comfyui_integration(), self.integration)
|
||||
self.assertIs(ComfyUIMetadataIntegration(), self.integration)
|
||||
self.integration.register_prompt("n1", "hello", {"category": "x"})
|
||||
ComfyUIMetadataIntegration() # __init__ runs again: must not reset state
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "hello")
|
||||
|
||||
def test_latest_prompt_of_this_thread_wins_without_a_node_id(self):
|
||||
self.integration.register_prompt("n1", "first", {})
|
||||
self.integration.register_prompt("n2", "second", {})
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("missing"), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "first")
|
||||
|
||||
def test_nothing_registered_returns_none(self):
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
self.assertIsNone(self.integration.get_current_prompt_text("n1"))
|
||||
|
||||
|
||||
class TestCrossThreadLookup(IntegrationTestCase):
|
||||
def _register_in_thread(self, text):
|
||||
worker = threading.Thread(
|
||||
target=self.integration.register_prompt, args=("w1", text, {})
|
||||
)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
def test_recent_prompt_from_another_thread_is_used(self):
|
||||
self._register_in_thread("from worker")
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "from worker")
|
||||
|
||||
def test_stale_prompt_from_another_thread_is_ignored(self):
|
||||
self._register_in_thread("from worker")
|
||||
self._ticks += 400.0
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
|
||||
def test_global_entry_of_this_thread_is_found_without_thread_local(self):
|
||||
self.integration.register_prompt("n1", "mine", {})
|
||||
self.integration._thread_local = threading.local()
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "mine")
|
||||
|
||||
def test_cleanup_removes_old_registrations_only(self):
|
||||
self._register_in_thread("old")
|
||||
self._ticks += 700.0
|
||||
self.integration.register_prompt("n2", "new", {})
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600)
|
||||
texts = sorted(v["text"] for v in self.integration._current_prompts.values())
|
||||
self.assertEqual(texts, ["new"])
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600) # nothing to do
|
||||
self.assertEqual(len(self.integration._current_prompts), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+4
-63
@@ -213,7 +213,8 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
try:
|
||||
# Modify a value
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 42
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
with open(tmp.name, "w", encoding="utf-8") as handle:
|
||||
json.dump(PromptManagerConfig.get_config(), handle)
|
||||
|
||||
# Reset and reload
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 100
|
||||
@@ -240,55 +241,6 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_save_creates_directory(self):
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
nested_path = os.path.join(tmp_dir, "subdir", "config.json")
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(nested_path)
|
||||
self.assertTrue(os.path.exists(nested_path))
|
||||
with open(nested_path) as f:
|
||||
data = json.load(f)
|
||||
self.assertIn("database", data)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_saved_file_is_private_to_the_user(self):
|
||||
import stat
|
||||
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
path = os.path.join(tmp_dir, "config.json")
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(mode, 0o600)
|
||||
with open(path) as f:
|
||||
self.assertIn("database", json.load(f))
|
||||
# Overwriting an existing, more open file tightens it as well
|
||||
os.chmod(path, 0o644)
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_saved_file_is_valid_json(self):
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp.close()
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
with open(tmp.name) as f:
|
||||
data = json.load(f)
|
||||
self.assertIsInstance(data, dict)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
class TestIntegrationConfig(unittest.TestCase):
|
||||
"""Test IntegrationConfig for LoRA Manager settings."""
|
||||
@@ -404,18 +356,6 @@ class TestConfigPathOverride(unittest.TestCase):
|
||||
os.environ[self.ENV] = custom
|
||||
self.assertEqual(PromptManagerConfig.get_config_path(), custom)
|
||||
|
||||
def test_save_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "nested", "custom.json")
|
||||
os.environ[self.ENV] = custom
|
||||
PromptManagerConfig.RESULT_TIMEOUT = 42
|
||||
|
||||
PromptManagerConfig.save_to_file()
|
||||
|
||||
with open(custom) as f:
|
||||
saved = json.load(f)
|
||||
self.assertEqual(saved["web_ui"]["result_timeout"], 42)
|
||||
|
||||
def test_load_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "custom.json")
|
||||
@@ -503,7 +443,8 @@ class TestAutotagConfig(unittest.TestCase):
|
||||
PromptManagerConfig.update_config(
|
||||
{"autotag": {"defaults": {"suggestion_limit": 25}}}
|
||||
)
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(PromptManagerConfig.get_config(), handle)
|
||||
self.config_class.SUGGESTION_LIMIT = 10
|
||||
PromptManagerConfig.load_from_file(path)
|
||||
self.assertEqual(self.config_class.suggestion_limit(), 25)
|
||||
|
||||
@@ -15,12 +15,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerTextEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManagerText with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node(self, mock_db_class, mock_integration, mock_tracker, mock_monitor):
|
||||
def _make_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -30,73 +29,64 @@ class TestPromptManagerTextEmptyInput(unittest.TestCase):
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "")
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_none_coerced_empty(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_none_coerced_empty(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Falsy text value should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
# ComfyUI may pass empty string for unconnected inputs
|
||||
result = node.process_text(text="", category="", tags="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before", append_text="after")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "before after")
|
||||
self.assertNotIn(" ", result[0])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
self.assertEqual(result[0], "before main prompt after")
|
||||
|
||||
def test_only_prepend(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_only_prepend(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Prepend with empty text should output just prepend."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before")
|
||||
self.assertEqual(result[0], "before")
|
||||
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManager (CLIP variant) with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node_and_clip(
|
||||
self, mock_db_class, mock_integration, mock_tracker, mock_monitor
|
||||
):
|
||||
def _make_node_and_clip(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -110,30 +100,22 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="", prepend_text="before", append_text="after"
|
||||
)
|
||||
@@ -141,24 +123,16 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
self.assertEqual(result[1], "before after")
|
||||
self.assertNotIn(" ", result[1])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[1], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
@@ -167,7 +141,6 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestConnectedTextInput(unittest.TestCase):
|
||||
"""Test that externally connected text is preserved in output (issue #123).
|
||||
@@ -177,7 +150,7 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
value reaches Python; these tests verify Python handles it correctly.
|
||||
"""
|
||||
|
||||
def _make_text_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
def _make_text_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -186,7 +159,7 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def _make_clip_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
def _make_clip_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -199,10 +172,10 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_text_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
self, mock_db, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Simulates screenshot: connected text + prepend + append must all appear."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="This is the prompt",
|
||||
prepend_text="This is prepend text",
|
||||
@@ -213,21 +186,17 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_text_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_text_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="This is the prompt")
|
||||
self.assertEqual(result[0], "This is the prompt")
|
||||
|
||||
def test_clip_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
self, mock_db, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Same scenario for PromptManager (CLIP variant)."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip,
|
||||
text="This is the prompt",
|
||||
@@ -239,13 +208,9 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_clip_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_clip_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="This is the prompt")
|
||||
self.assertEqual(result[1], "This is the prompt")
|
||||
|
||||
|
||||
@@ -324,7 +324,6 @@ class CaptureTests(unittest.TestCase):
|
||||
node.db.save_prompt_pair.side_effect = RuntimeError("offline")
|
||||
node.logger = logging.getLogger("test.capture.nodes")
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text + " TRIGGER"
|
||||
clip = Mock()
|
||||
clip.tokenize.side_effect = lambda text: text
|
||||
|
||||
@@ -24,7 +24,6 @@ class TestFocusedNode(unittest.TestCase):
|
||||
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
|
||||
node = PromptManagerFocused.__new__(PromptManagerFocused)
|
||||
node.logger = logging.getLogger("test.focused")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
clip = mock.Mock()
|
||||
|
||||
+1
-29
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for hashing utilities.
|
||||
|
||||
Tests is_duplicate_prompt and generate_prompt_hash, which are
|
||||
Tests and generate_prompt_hash, which are
|
||||
currently untested. generate_prompt_hash is covered in test_basic.py
|
||||
but we add edge cases here.
|
||||
"""
|
||||
@@ -15,7 +15,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from tests.background_stops import stop_tracker # noqa: E402
|
||||
from utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
is_duplicate_prompt,
|
||||
STRIPPED_CODE_POINTS,
|
||||
prompt_key,
|
||||
register_prompt_functions,
|
||||
@@ -69,33 +68,6 @@ class TestSingleHashImplementation(unittest.TestCase):
|
||||
self.assertFalse(os.path.exists(os.path.join(ROOT, "restart_gallery.py")))
|
||||
|
||||
|
||||
class TestIsDuplicatePrompt(unittest.TestCase):
|
||||
"""Test is_duplicate_prompt function."""
|
||||
|
||||
def test_identical_texts_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("hello", "hello"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt("Hello World", "hello world"))
|
||||
|
||||
def test_whitespace_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt(" hello ", "hello"))
|
||||
|
||||
def test_different_texts_not_duplicates(self):
|
||||
self.assertFalse(is_duplicate_prompt("text A", "text B"))
|
||||
|
||||
def test_threshold_parameter_accepted(self):
|
||||
# threshold is unused but should not cause errors
|
||||
self.assertTrue(is_duplicate_prompt("same", "same", threshold=0.5))
|
||||
|
||||
def test_empty_strings_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("", ""))
|
||||
|
||||
def test_unicode_comparison(self):
|
||||
self.assertTrue(is_duplicate_prompt("日本語", "日本語"))
|
||||
self.assertFalse(is_duplicate_prompt("日本語", "中文"))
|
||||
|
||||
|
||||
class TestGeneratePromptHashEdgeCases(unittest.TestCase):
|
||||
"""Additional edge cases for generate_prompt_hash."""
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Mock ComfyUI server before importing anything that touches config. Only the
|
||||
# first test module to run installs the stub; nothing else in sys.modules is
|
||||
# replaced, so later modules (test_comfyui_integration) see the real package.
|
||||
# replaced, so later modules see the real package.
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
@@ -881,17 +881,6 @@ class TestOutputListener(unittest.TestCase):
|
||||
self.assertTrue(monitor.stop_monitoring())
|
||||
|
||||
|
||||
class TestModuleIsolation(unittest.TestCase):
|
||||
"""This module must not leave mocks behind for later test modules."""
|
||||
|
||||
def test_comfyui_integration_is_not_replaced_by_a_mock(self):
|
||||
module = sys.modules.get("utils.comfyui_integration")
|
||||
self.assertFalse(
|
||||
isinstance(module, MagicMock),
|
||||
"utils.comfyui_integration was replaced by a MagicMock in sys.modules",
|
||||
)
|
||||
|
||||
|
||||
class TestMonitorThreadSafety(unittest.TestCase):
|
||||
"""Singleton creation must be thread-safe."""
|
||||
|
||||
|
||||
@@ -406,8 +406,6 @@ class TestLegacyRoutesHoldTheRegistry(_LegacyHoldCase):
|
||||
)
|
||||
await self._busy(resp)
|
||||
self.assertTrue(victim.exists())
|
||||
# The duplicate family lock was not taken by the refused request.
|
||||
self.assertIsNotNone(self.api._acquire_long_job(self.api.LONG_JOB_DUPLICATES))
|
||||
|
||||
async def test_lora_scan_refused_while_scan_runs(self):
|
||||
from py.config import IntegrationConfig
|
||||
|
||||
@@ -179,7 +179,6 @@ class NodeTestCase(unittest.TestCase):
|
||||
self.node.logger = logging.getLogger("test.node")
|
||||
self.node.prompt_tracker = mock.Mock()
|
||||
self.node.image_monitor = mock.Mock()
|
||||
self.node.comfyui_integration = mock.Mock()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all() # Windows cannot unlink an open database file
|
||||
@@ -189,20 +188,16 @@ class NodeTestCase(unittest.TestCase):
|
||||
|
||||
|
||||
class TestBaseSaveAndSearch(NodeTestCase):
|
||||
def test_init_wires_database_tracker_monitor_and_integration(self):
|
||||
def test_init_wires_database_tracker_and_monitor(self):
|
||||
with (
|
||||
mock.patch.object(prompt_manager_base, "PromptDatabase") as db_cls,
|
||||
mock.patch.object(prompt_manager_base, "get_prompt_tracker") as tracker,
|
||||
mock.patch.object(prompt_manager_base, "get_image_monitor") as monitor,
|
||||
mock.patch.object(
|
||||
prompt_manager_base, "get_comfyui_integration"
|
||||
) as integration,
|
||||
):
|
||||
node = PromptManagerBase()
|
||||
self.assertIs(node.db, db_cls.return_value)
|
||||
tracker.assert_called_once_with(db_cls.return_value)
|
||||
monitor.assert_called_once_with(db_cls.return_value, tracker.return_value)
|
||||
self.assertIs(node.comfyui_integration, integration.return_value)
|
||||
monitor.return_value.start_monitoring.assert_called_once_with()
|
||||
|
||||
def test_saving_an_existing_prompt_updates_its_metadata(self):
|
||||
@@ -242,31 +237,18 @@ class TestBaseSaveAndSearch(NodeTestCase):
|
||||
self.node._save_prompt_to_database("a cat on a mat")
|
||||
self.assertEqual(self.node._search_prompts(""), [])
|
||||
self.assertEqual(self.node._search_prompts(" "), [])
|
||||
self.assertEqual(len(self.node.search_prompts_api("cat")), 1)
|
||||
self.assertEqual(len(self.node.get_recent_prompts_api(limit=5)["prompts"]), 1)
|
||||
self.assertEqual(len(self.node._search_prompts("cat")), 1)
|
||||
|
||||
def test_search_and_recent_errors_return_empty_lists(self):
|
||||
self.node.db = mock.Mock()
|
||||
self.node.db.search_prompts.side_effect = RuntimeError("locked")
|
||||
self.node.db.get_recent_prompts.side_effect = RuntimeError("locked")
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.assertEqual(self.node._search_prompts("cat"), [])
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.assertEqual(self.node.get_recent_prompts_api(), [])
|
||||
|
||||
def test_gallery_system_start_failure_is_logged(self):
|
||||
self.node.image_monitor.start_monitoring.side_effect = OSError("no inotify")
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.node._start_gallery_system()
|
||||
self.node.image_monitor.get_status.return_value = {"running": False}
|
||||
self.node.prompt_tracker.get_status.return_value = {"active_prompts_count": 0}
|
||||
self.assertEqual(
|
||||
self.node.get_gallery_status(),
|
||||
{
|
||||
"image_monitor": {"running": False},
|
||||
"prompt_tracker": {"active_prompts_count": 0},
|
||||
},
|
||||
)
|
||||
self.node.cleanup_gallery_system()
|
||||
self.node.image_monitor.stop_monitoring.assert_not_called()
|
||||
|
||||
@@ -376,7 +358,6 @@ class TestPromptManagerEncoding(NodeTestCase):
|
||||
self.assertEqual(saved["text"], "a cat")
|
||||
self.assertIn("prepend:best", saved["tags"])
|
||||
self.assertIn("append:8k", saved["tags"])
|
||||
self.node.comfyui_integration.register_prompt.assert_called_once()
|
||||
|
||||
def test_tracking_failure_does_not_break_encoding(self):
|
||||
self.node._track_prompt_execution = mock.Mock(side_effect=RuntimeError("db"))
|
||||
|
||||
@@ -19,7 +19,6 @@ def bare(node_cls):
|
||||
"""A node without its database, tracker or gallery side effects."""
|
||||
node = node_cls.__new__(node_cls)
|
||||
node.logger = __import__("logging").getLogger("test.wiring")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
return node
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""What the Registry package leaves out (#615).
|
||||
|
||||
``comfy node publish`` zips the tracked files minus ``.comfyignore``. The checks
|
||||
here hold the two ways that can go wrong: a development folder creeping back
|
||||
into the download, or a rule that drops something ComfyUI needs at run time.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import fnmatch
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_DIRS = ("py", "database", "utils", "web")
|
||||
DEVELOPMENT = (
|
||||
"tests/test_package_contents.py",
|
||||
"tools/vendor/package.json",
|
||||
"docs/application-redesign.md",
|
||||
".github/workflows/publish.yml",
|
||||
"images/x.png",
|
||||
"TESTING.md",
|
||||
"requirements-dev.txt",
|
||||
)
|
||||
KEPT = (
|
||||
"__init__.py",
|
||||
"prompt_manager.py",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"README-legacy.md",
|
||||
"assets/readme/hero.webp",
|
||||
)
|
||||
|
||||
|
||||
def _patterns():
|
||||
lines = (ROOT / ".comfyignore").read_text(encoding="utf-8").splitlines()
|
||||
return [line.strip() for line in lines if line.strip() and line[0] != "#"]
|
||||
|
||||
|
||||
def _ignored(path):
|
||||
"""The rules this file allows: ``folder/`` from the root, or a root file."""
|
||||
for pattern in _patterns():
|
||||
if pattern.endswith("/"):
|
||||
if path.startswith(pattern.lstrip("/")):
|
||||
return True
|
||||
elif fnmatch.fnmatchcase(path, pattern.lstrip("/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TestPackageContents(unittest.TestCase):
|
||||
def test_every_rule_is_anchored_to_the_root(self):
|
||||
# An unanchored "tests/" would also drop web/js/tests, and "images/" a
|
||||
# runtime folder of that name deeper down.
|
||||
for pattern in _patterns():
|
||||
self.assertTrue(pattern.startswith("/"), pattern)
|
||||
self.assertNotIn("!", pattern)
|
||||
|
||||
def test_development_files_are_left_out(self):
|
||||
for path in DEVELOPMENT:
|
||||
self.assertTrue(_ignored(path), path)
|
||||
|
||||
def test_what_comfyui_needs_stays(self):
|
||||
for path in KEPT:
|
||||
self.assertFalse(_ignored(path), path)
|
||||
for folder in RUNTIME_DIRS:
|
||||
for file in (ROOT / folder).rglob("*"):
|
||||
if file.is_file():
|
||||
relative = file.relative_to(ROOT).as_posix()
|
||||
self.assertFalse(_ignored(relative), relative)
|
||||
|
||||
def test_runtime_code_imports_nothing_left_out(self):
|
||||
sources = list(ROOT.glob("*.py"))
|
||||
for folder in ("py", "database", "utils"):
|
||||
sources.extend((ROOT / folder).rglob("*.py"))
|
||||
for source in sources:
|
||||
tree = ast.parse(source.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
names = []
|
||||
if isinstance(node, ast.Import):
|
||||
names = [alias.name for alias in node.names]
|
||||
elif isinstance(node, ast.ImportFrom) and not node.level:
|
||||
names = [node.module or ""]
|
||||
for name in names:
|
||||
self.assertNotIn(name.split(".")[0], ("tests", "tools"), source)
|
||||
|
||||
def test_the_old_pages_name_their_replacement(self):
|
||||
for page, section in (
|
||||
("admin", "library"),
|
||||
("gallery", "images"),
|
||||
("metadata", "inspect"),
|
||||
):
|
||||
html = (ROOT / "web" / f"{page}.html").read_text(encoding="utf-8")
|
||||
self.assertRegex(html, r'id="legacy-removal-notice"')
|
||||
self.assertRegex(html, re.escape(f'href="workspace#/{section}"'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(unittest.main())
|
||||
@@ -47,7 +47,6 @@ class TestPairExecution(unittest.TestCase):
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.execution")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = lambda text: text + " LORA"
|
||||
self.extra = {"workflow": {"unchanged": True}, "sibling": {"keep": True}}
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ class TestPairUsageTracking(unittest.TestCase):
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
return node
|
||||
|
||||
def execute(self, graph, text="cat", negative="blur"):
|
||||
|
||||
@@ -89,5 +89,55 @@ class TestProbeCheck(unittest.TestCase):
|
||||
self.probe.check(*KEY)
|
||||
|
||||
|
||||
class TestRefreshOrder(unittest.TestCase):
|
||||
"""Only the newest check's answer is published (#611)."""
|
||||
|
||||
def test_an_older_check_never_overwrites_a_newer_answer(self):
|
||||
parsed = preview_location.ParsedLocation(
|
||||
mode=preview_store.FOLDER_MODE, path="/thumbs", id="x"
|
||||
)
|
||||
self.addCleanup(setattr, preview_store, "_available", preview_store._available)
|
||||
self.addCleanup(setattr, preview_store, "_problem", preview_store._problem)
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
newer_published = threading.Event()
|
||||
calls = []
|
||||
publish = preview_store._set_state
|
||||
|
||||
def state(_path, _expected_id):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
started.set()
|
||||
self.assertTrue(release.wait(WAIT))
|
||||
return HERE
|
||||
return GONE
|
||||
|
||||
def set_state(*args, **kwargs):
|
||||
if args[1]: # the older check's "available" arrives last
|
||||
self.assertTrue(newer_published.wait(WAIT))
|
||||
publish(*args, **kwargs)
|
||||
|
||||
def newer():
|
||||
preview_store.refresh()
|
||||
newer_published.set()
|
||||
|
||||
with (
|
||||
patch.object(preview_store, "_parsed", parsed),
|
||||
patch.object(preview_location, "folder_state", state),
|
||||
patch.object(preview_store, "_set_state", set_state),
|
||||
):
|
||||
first = threading.Thread(target=preview_store.refresh)
|
||||
first.start()
|
||||
self.assertTrue(started.wait(WAIT))
|
||||
second = threading.Thread(target=newer)
|
||||
second.start()
|
||||
release.set()
|
||||
second.join(WAIT)
|
||||
first.join(WAIT)
|
||||
self.assertFalse(first.is_alive() or second.is_alive())
|
||||
self.assertFalse(preview_store._available)
|
||||
self.assertEqual(preview_store._problem, "missing")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -287,6 +287,9 @@ class TestUnavailableFolder(FolderModeCase):
|
||||
self.assertTrue(stray.exists())
|
||||
|
||||
async def test_a_stalled_store_call_marks_it_unreachable_and_stops_io(self):
|
||||
# Without the probe: woken by the stall, it finds this healthy folder again
|
||||
# at once, and the lookup below would rightly do store I/O.
|
||||
self.assertTrue(preview_store.stop_probe())
|
||||
make_png(self.output_dir / "m.png", size=(64, 64))
|
||||
self.preview("m.png").write_bytes(b"x")
|
||||
ticks = iter([0.0, preview_store.IO_BUDGET + 1.0])
|
||||
|
||||
@@ -162,7 +162,6 @@ class NodeCase(DatabaseCase):
|
||||
node.db = self.db
|
||||
node.logger = logging.getLogger("test.prompt_attribution.node")
|
||||
node.prompt_tracker = self.tracker
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.pending_first_use = PendingFirstUse()
|
||||
node.pending_pair_first_use = PendingFirstUse()
|
||||
|
||||
@@ -45,7 +45,6 @@ class TestPromptPairNode(unittest.TestCase):
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.pair_node")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = Mock(
|
||||
side_effect=lambda text: text + " LORA"
|
||||
)
|
||||
@@ -241,12 +240,9 @@ class TestPromptPairNode(unittest.TestCase):
|
||||
)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_invalid_text_does_not_write_and_integration_failures_do_not_block(self):
|
||||
def test_invalid_text_does_not_write(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.node.encode_prompt(self.clip, [], negative_text="blur")
|
||||
self.node.comfyui_integration.register_prompt.side_effect = RuntimeError(
|
||||
"metadata unavailable"
|
||||
)
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
|
||||
|
||||
@@ -294,7 +294,6 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
node.db = self.db
|
||||
node.logger = __import__("logging").getLogger("test.prompt_usage")
|
||||
node.prompt_tracker = mock.Mock()
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.cached_runs = mock.Mock()
|
||||
node.cached_runs.replayed.return_value = False
|
||||
|
||||
@@ -243,7 +243,6 @@ class TestNodesSaveNothing(NodeCase):
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
node.comfyui_integration.register_prompt.assert_not_called()
|
||||
if cls is PromptManagerText:
|
||||
self.assertEqual(result, (POSITIVE,))
|
||||
elif cls is PromptManagerPair:
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
{
|
||||
"page": "library",
|
||||
"name": "L",
|
||||
"state": {**LIBRARY_STATE, "sort": "name-desc"},
|
||||
"state": {**LIBRARY_STATE, "sort": "shuffle"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
@@ -562,7 +562,7 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
|
||||
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
|
||||
(" " + "n" * 120, json.dumps({"sort": "shuffle", "q": 4})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ class TestSplitNode(unittest.TestCase):
|
||||
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
|
||||
node = PromptManagerSplit.__new__(PromptManagerSplit)
|
||||
node.logger = logging.getLogger("test.split")
|
||||
node.comfyui_integration = unittest.mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
|
||||
clip = unittest.mock.Mock()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Every read-only SQLite open uses one URI helper (#620).
|
||||
|
||||
pathlib writes a Windows network path as ``file://server/share/x.db``, which
|
||||
SQLite refuses. A reader that builds its own URI breaks backups, restores,
|
||||
library moves or history reads for a library on a network share.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from database.sqlite_uri import readonly_uri # noqa: E402
|
||||
|
||||
|
||||
class TestReadonlyUri(unittest.TestCase):
|
||||
def test_a_network_path_has_an_empty_authority(self):
|
||||
self.assertEqual(
|
||||
readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True),
|
||||
"file:////server/share/Comfy%20UI/prompts.db?mode=ro",
|
||||
)
|
||||
|
||||
def test_no_reader_builds_its_own_uri(self):
|
||||
offenders = []
|
||||
for folder in ("database", "py", "utils"):
|
||||
for path in (ROOT / folder).rglob("*.py"):
|
||||
if path.name == "sqlite_uri.py":
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if re.search(r"as_uri\(\)\s*\+\s*[\"']\?mode=ro", text):
|
||||
offenders.append(path.relative_to(ROOT).as_posix())
|
||||
self.assertEqual(offenders, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -78,7 +78,7 @@ class TestDefaultsAndConfig(ConfigCase):
|
||||
self.assertEqual(ts.current().format, "webp")
|
||||
|
||||
def test_the_never_read_gallery_thumbnail_size_is_gone(self):
|
||||
# R1: older save_to_file wrote its default 256 into config.json; it
|
||||
# R1: older versions wrote its default 256 into config.json; it
|
||||
# must never size a preview.
|
||||
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
||||
PromptManagerConfig.update_config(
|
||||
|
||||
@@ -17,7 +17,6 @@ from utils.validators import (
|
||||
validate_tags,
|
||||
validate_category,
|
||||
sanitize_input,
|
||||
parse_tags_string,
|
||||
validate_result_timeout,
|
||||
validate_worker_threads,
|
||||
)
|
||||
@@ -125,48 +124,6 @@ class TestSanitizeInput(unittest.TestCase):
|
||||
self.assertEqual(result, "a\nb\nc\nd")
|
||||
|
||||
|
||||
class TestParseTagsString(unittest.TestCase):
|
||||
"""Test parse_tags_string function."""
|
||||
|
||||
def test_simple_comma_separated(self):
|
||||
result = parse_tags_string("tag1, tag2, tag3")
|
||||
self.assertEqual(result, ["tag1", "tag2", "tag3"])
|
||||
|
||||
def test_extra_whitespace(self):
|
||||
result = parse_tags_string(" tag1 , tag2 ")
|
||||
self.assertEqual(result, ["tag1", "tag2"])
|
||||
|
||||
def test_empty_string_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(""), [])
|
||||
|
||||
def test_none_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(None), [])
|
||||
|
||||
def test_non_string_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(123), [])
|
||||
|
||||
def test_single_tag(self):
|
||||
self.assertEqual(parse_tags_string("solo"), ["solo"])
|
||||
|
||||
def test_deduplication(self):
|
||||
result = parse_tags_string("tag1, tag1, tag2, tag1")
|
||||
self.assertEqual(result, ["tag1", "tag2"])
|
||||
|
||||
def test_empty_segments_skipped(self):
|
||||
result = parse_tags_string("tag1,,tag2,,,tag3")
|
||||
self.assertEqual(result, ["tag1", "tag2", "tag3"])
|
||||
|
||||
def test_max_20_tags(self):
|
||||
tags = ", ".join(f"tag{i}" for i in range(25))
|
||||
result = parse_tags_string(tags)
|
||||
self.assertEqual(len(result), 20)
|
||||
|
||||
def test_strips_null_bytes_from_tags(self):
|
||||
result = parse_tags_string("clean\x00tag, normal")
|
||||
self.assertIn("cleantag", result)
|
||||
self.assertIn("normal", result)
|
||||
|
||||
|
||||
class TestValidatePromptTextEdgeCases(unittest.TestCase):
|
||||
"""Additional edge cases beyond test_basic.py coverage."""
|
||||
|
||||
|
||||
Vendored
+4
-2
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs';
|
||||
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
|
||||
const lock = JSON.parse(readFileSync(new URL('../../tools/vendor/package-lock.json', import.meta.url), 'utf8'));
|
||||
const version = lock.packages['node_modules/@playwright/test'].version;
|
||||
// The tag alone is not a pin: v1.64.1-noble was re-pushed with Node 26 (#609). The digest fixes the image's contents.
|
||||
const pinnedImage = `^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble@sha256:[0-9a-f]{64}$`;
|
||||
|
||||
function job(name) {
|
||||
const match = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:\\n|(?![\\s\\S]))`, 'm'));
|
||||
@@ -18,7 +20,7 @@ function job(name) {
|
||||
for (const name of ['frontend-coverage', 'browser-workflows']) {
|
||||
test(`${name} runs in the pinned Playwright image matching @playwright/test`, () => {
|
||||
const body = job(name);
|
||||
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
|
||||
assert.match(body, new RegExp(pinnedImage, 'm'));
|
||||
assert.doesNotMatch(body, /playwright install/, 'browsers and their fonts come from the image, not a runner install');
|
||||
// Toolcache interpreters are the runner's own builds: their entry points name host paths that do not exist in
|
||||
// the container (setup-python failed with `spawn .../bin/pip ENOENT`). Use the image's Python and Node.
|
||||
@@ -39,7 +41,7 @@ test('real ComfyUI compatibility runs in the pinned Playwright image on the revi
|
||||
const match = testWorkflow.match(/^ {2}comfyui:\n([\s\S]*?)(?=^ {2}[a-z][a-z-]*:\n|(?![\s\S]))/m);
|
||||
assert.ok(match, 'comfyui job exists');
|
||||
const body = match[1];
|
||||
assert.match(body, new RegExp(`^ container:\\n image: mcr\\.microsoft\\.com/playwright:v${version.replaceAll('.', '\\.')}-noble$`, 'm'));
|
||||
assert.match(body, new RegExp(pinnedImage, 'm'));
|
||||
assert.doesNotMatch(body, /playwright install/, 'Chromium and its system libraries come from the image, not apt');
|
||||
assert.doesNotMatch(body, /apt-get|actions\/setup-(python|node)@/);
|
||||
assert.match(body, /python3 -m venv --without-pip /, 'a user-space venv on the image Python');
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const workflow = readFileSync(new URL('../../.github/workflows/viewerjs-check.yml', import.meta.url), 'utf8');
|
||||
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble$/m;
|
||||
const PINNED_IMAGE = /^\s+image: mcr\.microsoft\.com\/playwright:v\d+\.\d+\.\d+-noble@sha256:[0-9a-f]{64}$/m;
|
||||
|
||||
function jobs(text) {
|
||||
const lines = text.split('\n');
|
||||
@@ -237,7 +237,7 @@ test('every npm ci restores the npm download cache keyed on the vendor lockfile
|
||||
assert.match(jobSteps[cacheAt], /hashFiles\('tools\/vendor\/package-lock\.json'\)/, `${id} keys the cache on the lockfile`);
|
||||
assert.match(jobSteps[installAt], /npm ci --prefix tools\/vendor --ignore-scripts/, `${id} still installs without scripts`);
|
||||
// The key names the job's own pinned image, so an image bump starts a fresh cache.
|
||||
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble$/m.exec(body)?.[1];
|
||||
const image = /^ {6}image: mcr\.microsoft\.com\/playwright:(v\d+\.\d+\.\d+)-noble@sha256:[0-9a-f]{64}$/m.exec(body)?.[1];
|
||||
assert.ok(image, `${id} runs in the pinned Playwright image`);
|
||||
assert.match(jobSteps[cacheAt], new RegExp(`key: npm-\\$\\{\\{ runner\\.os \\}\\}-playwright-${image.replaceAll('.', '\\.')}-\\$\\{\\{ hashFiles`),
|
||||
`${id} keys the cache on its image ${image}`);
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""ComfyUI integration utilities for PromptManager.
|
||||
|
||||
This module tracks the prompts PromptManager nodes encode during execution so
|
||||
other components (e.g. image/prompt linking) can look them up.
|
||||
|
||||
ComfyUI already writes every node's own inputs into saved image metadata, so
|
||||
SaveImage is deliberately NOT patched. An earlier patch wrote the last-run prompt
|
||||
into every PromptManager node, saving negative prompts as copies of the
|
||||
positive (#75).
|
||||
|
||||
Key features:
|
||||
- Thread-safe prompt registration and retrieval
|
||||
- Cross-thread prompt context sharing
|
||||
- Standard metadata format compatibility
|
||||
- Automatic cleanup of old prompt registrations
|
||||
|
||||
The integration works by:
|
||||
1. PromptManager nodes register their prompts during execution
|
||||
2. Callers retrieve the current prompt text by node id or recency
|
||||
|
||||
Typical usage:
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
|
||||
integration = get_comfyui_integration()
|
||||
integration.register_prompt(node_id, prompt_text, metadata)
|
||||
text = integration.get_current_prompt_text(node_id)
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
from .logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
class ComfyUIMetadataIntegration:
|
||||
"""Integrates PromptManager with ComfyUI's standard metadata system.
|
||||
|
||||
This singleton class manages the integration between PromptManager custom nodes
|
||||
and ComfyUI's standard metadata system. It ensures that prompts generated by
|
||||
PromptManager appear in the standard ComfyUI metadata format for compatibility
|
||||
with third-party tools and parsers.
|
||||
|
||||
The integration uses thread-local storage combined with global tracking to
|
||||
handle prompt context across different execution threads, which is necessary
|
||||
because ComfyUI executions may span multiple threads.
|
||||
|
||||
Key responsibilities:
|
||||
- Register prompts from PromptManager nodes during execution
|
||||
- Manage prompt lifecycle and cleanup
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
"""Ensure singleton pattern with thread safety.
|
||||
|
||||
Returns:
|
||||
The single instance of ComfyUIMetadataIntegration
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ComfyUI integration system.
|
||||
|
||||
Sets up prompt tracking data structures. Uses _initialized flag to
|
||||
prevent duplicate initialization.
|
||||
"""
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
|
||||
self.logger = get_logger("prompt_manager.comfyui_integration")
|
||||
self._current_prompts = {}
|
||||
self._thread_local = threading.local()
|
||||
self._initialized = True
|
||||
|
||||
def register_prompt(self, node_id: str, prompt_text: str, metadata: Dict[str, Any]):
|
||||
"""
|
||||
Register a prompt from PromptManager for inclusion in ComfyUI metadata.
|
||||
|
||||
This method is called by PromptManager nodes during execution to register
|
||||
their prompt text for later inclusion in image metadata. The prompt is stored
|
||||
in both thread-local and global storage to handle cross-thread access scenarios.
|
||||
|
||||
Args:
|
||||
node_id: Unique identifier for this prompt (typically the node ID)
|
||||
prompt_text: The actual prompt text that was encoded by PromptManager
|
||||
metadata: Additional metadata from PromptManager (category, tags, etc.)
|
||||
"""
|
||||
thread_id = threading.current_thread().ident
|
||||
|
||||
# Store in thread-local storage
|
||||
if not hasattr(self._thread_local, "prompts"):
|
||||
self._thread_local.prompts = {}
|
||||
|
||||
self._thread_local.prompts[node_id] = {
|
||||
"text": prompt_text,
|
||||
"metadata": metadata,
|
||||
"timestamp": time.time(),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
# Also store globally for cross-thread access
|
||||
with self._lock:
|
||||
self._current_prompts[f"{thread_id}_{node_id}"] = {
|
||||
"text": prompt_text,
|
||||
"metadata": metadata,
|
||||
"timestamp": time.time(),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
self.logger.debug(
|
||||
f"Registered prompt for node {node_id}: {prompt_text[:50]}..."
|
||||
)
|
||||
|
||||
def get_current_prompt_text(self, node_id: str = None) -> Optional[str]:
|
||||
"""
|
||||
Get the current prompt text for metadata inclusion.
|
||||
|
||||
Retrieves the most appropriate prompt text for inclusion in image metadata.
|
||||
Uses a fallback strategy: thread-local storage first, then global storage,
|
||||
with recency checks to avoid stale prompts.
|
||||
|
||||
Args:
|
||||
node_id: Optional specific node ID to retrieve prompt for
|
||||
|
||||
Returns:
|
||||
The prompt text string if available, None if no suitable prompt found
|
||||
"""
|
||||
# First try thread-local storage
|
||||
if hasattr(self._thread_local, "prompts"):
|
||||
if node_id and node_id in self._thread_local.prompts:
|
||||
return self._thread_local.prompts[node_id]["text"]
|
||||
elif self._thread_local.prompts:
|
||||
# Return the most recent prompt from this thread
|
||||
latest = max(
|
||||
self._thread_local.prompts.values(), key=lambda x: x["timestamp"]
|
||||
)
|
||||
return latest["text"]
|
||||
|
||||
# Fallback to global storage
|
||||
thread_id = threading.current_thread().ident
|
||||
with self._lock:
|
||||
# Look for prompts from current thread
|
||||
thread_prompts = {
|
||||
k: v
|
||||
for k, v in self._current_prompts.items()
|
||||
if v["thread_id"] == thread_id
|
||||
}
|
||||
|
||||
if thread_prompts:
|
||||
latest = max(thread_prompts.values(), key=lambda x: x["timestamp"])
|
||||
return latest["text"]
|
||||
|
||||
# Last resort: return the most recent prompt from any thread
|
||||
if self._current_prompts:
|
||||
latest = max(
|
||||
self._current_prompts.values(), key=lambda x: x["timestamp"]
|
||||
)
|
||||
# Only return if it's recent (within last 5 minutes)
|
||||
if time.time() - latest["timestamp"] < 300:
|
||||
return latest["text"]
|
||||
|
||||
return None
|
||||
|
||||
def cleanup_old_prompts(self, max_age_seconds: int = 600):
|
||||
"""
|
||||
Clean up old prompt registrations.
|
||||
|
||||
Removes prompt registrations that are older than the specified age to
|
||||
prevent memory leaks and ensure that only recent, relevant prompts are
|
||||
used for metadata inclusion.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before a prompt registration
|
||||
is considered stale and removed (default: 600
|
||||
seconds/10 minutes)
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
with self._lock:
|
||||
old_keys = [
|
||||
key
|
||||
for key, prompt_data in self._current_prompts.items()
|
||||
if current_time - prompt_data["timestamp"] > max_age_seconds
|
||||
]
|
||||
|
||||
for key in old_keys:
|
||||
del self._current_prompts[key]
|
||||
|
||||
if old_keys:
|
||||
self.logger.debug(f"Cleaned up {len(old_keys)} old prompt registrations")
|
||||
|
||||
|
||||
# Global instance
|
||||
_integration_instance = None
|
||||
|
||||
|
||||
def get_comfyui_integration() -> ComfyUIMetadataIntegration:
|
||||
"""Get the global ComfyUI integration instance.
|
||||
|
||||
Returns:
|
||||
The singleton ComfyUIMetadataIntegration instance, creating it if necessary
|
||||
"""
|
||||
global _integration_instance
|
||||
if _integration_instance is None:
|
||||
_integration_instance = ComfyUIMetadataIntegration()
|
||||
return _integration_instance
|
||||
+1
-33
@@ -8,19 +8,15 @@ regardless of minor formatting differences.
|
||||
Key features:
|
||||
- SHA256-based prompt text hashing with normalization
|
||||
- Content hashing for complex prompt metadata structures
|
||||
- Duplicate detection utilities
|
||||
- Consistent normalization (lowercase, trimmed whitespace)
|
||||
|
||||
Typical usage:
|
||||
from utils.hashing import generate_prompt_hash, is_duplicate_prompt
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
hash1 = generate_prompt_hash("Beautiful landscape")
|
||||
hash2 = generate_prompt_hash(" beautiful landscape ")
|
||||
# hash1 == hash2 (normalization makes them identical)
|
||||
|
||||
if is_duplicate_prompt(text1, text2):
|
||||
print("Duplicate prompts detected")
|
||||
|
||||
The hashing is designed for:
|
||||
- Database deduplication (preventing duplicate prompt storage)
|
||||
- Quick similarity checking
|
||||
@@ -124,31 +120,3 @@ def stored_prompt_text_sql(expression: str) -> str:
|
||||
character of Y, multi-byte ones included, so it strips what Python does.
|
||||
"""
|
||||
return f"trim({expression}, char({_STRIP_CODES}))"
|
||||
|
||||
|
||||
def is_duplicate_prompt(text1: str, text2: str, threshold: float = 0.95) -> bool:
|
||||
"""
|
||||
Check if two prompts are likely duplicates using hash comparison.
|
||||
|
||||
Compares the normalized hashes of two prompt texts to determine if
|
||||
they are functionally identical. This is an exact match after normalization,
|
||||
not a similarity measure.
|
||||
|
||||
Args:
|
||||
text1: First prompt text to compare
|
||||
text2: Second prompt text to compare
|
||||
threshold: Similarity threshold (not used - kept for API compatibility)
|
||||
|
||||
Returns:
|
||||
True if the prompts have identical normalized hashes (i.e., are duplicates),
|
||||
False otherwise
|
||||
|
||||
Note:
|
||||
The threshold parameter is not used in the current implementation as
|
||||
this performs exact hash matching rather than similarity scoring.
|
||||
It's maintained for potential future fuzzy matching capabilities.
|
||||
"""
|
||||
hash1 = generate_prompt_hash(text1)
|
||||
hash2 = generate_prompt_hash(text2)
|
||||
|
||||
return hash1 == hash2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Restore publication checks for snapshot-owned history reads."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -10,11 +9,13 @@ if TYPE_CHECKING:
|
||||
from ..database.operations import PromptDatabase
|
||||
|
||||
try:
|
||||
from ..database.sqlite_uri import readonly_uri
|
||||
from ..database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
)
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
@@ -53,9 +54,7 @@ class HistoryReadFence:
|
||||
self._check(generation, epoch)
|
||||
# An API restore retires its previous model. Never reopen that owner,
|
||||
# and never reuse the projection transaction's view of the epoch.
|
||||
conn = sqlite3.connect(
|
||||
Path(self._path).as_uri() + "?mode=ro", uri=True, timeout=0.25
|
||||
)
|
||||
conn = sqlite3.connect(readonly_uri(self._path), uri=True, timeout=0.25)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT storage_epoch FROM execution_history_metadata "
|
||||
|
||||
@@ -295,30 +295,3 @@ def sanitize_input(text: str) -> str:
|
||||
result_lines.append(line)
|
||||
|
||||
return "\n".join(result_lines).strip()
|
||||
|
||||
|
||||
def parse_tags_string(tags_string: str) -> List[str]:
|
||||
"""
|
||||
Parse a comma-separated tags string into a clean list.
|
||||
|
||||
Converts comma-separated tag strings into a clean, deduplicated list
|
||||
of tags. Each tag is sanitized and trimmed.
|
||||
|
||||
Args:
|
||||
tags_string: Comma-separated tags string (e.g., "tag1, tag2, tag3")
|
||||
|
||||
Returns:
|
||||
List of unique, cleaned tag strings. Empty input returns empty list.
|
||||
Limited to maximum 20 tags to prevent abuse.
|
||||
"""
|
||||
if not tags_string or not isinstance(tags_string, str):
|
||||
return []
|
||||
|
||||
# Split by comma and clean each tag
|
||||
tags = []
|
||||
for tag in tags_string.split(","):
|
||||
clean_tag = sanitize_input(tag).strip()
|
||||
if clean_tag and clean_tag not in tags: # Avoid duplicates
|
||||
tags.append(clean_tag)
|
||||
|
||||
return tags[:20] # Limit to 20 tags
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<script src="lib/viewerjs/viewer.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-pm-primary text-pm min-h-screen text-[13px]">
|
||||
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/library" style="text-decoration: underline;">Open the workspace</a></div>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-pm-surface border-b border-pm px-4 py-2" style="min-height: 2.5rem;">
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-pm-primary text-pm min-h-screen">
|
||||
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/images" style="text-decoration: underline;">Open the workspace</a></div>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-pm-surface border-b border-pm px-4 py-2">
|
||||
|
||||
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
|
||||
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
|
||||
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
|
||||
const sort=filters.sort || 'newest';
|
||||
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
if(!['newest','oldest','name','name-desc','most-used','last-used'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
|
||||
// the output folder below a root.
|
||||
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
|
||||
|
||||
@@ -36,7 +36,7 @@ const selectedPairs=new Map();
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first'],['last-used','Recently used'],['most-used','Most used'],['name-desc','Name Z–A']]);
|
||||
// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order.
|
||||
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]);
|
||||
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
|
||||
|
||||
@@ -46,7 +46,7 @@ const FIELDS = Object.freeze({
|
||||
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'most-used', 'last-used'], 'newest'), tags,
|
||||
// #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder
|
||||
// ('/'-joined, below a root).
|
||||
tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024),
|
||||
|
||||
@@ -25,14 +25,6 @@ export const routes = [
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
/** D165 (#525): the legacy pages, which stay at their URLs; System's Overview section links them discreetly. */
|
||||
export const LEGACY_PAGES = Object.freeze([
|
||||
{ label: 'Dashboard', href: '/prompt_manager/admin' },
|
||||
{ label: 'Tags', href: '/prompt_manager/admin#/tags' },
|
||||
{ label: 'Gallery', href: '/prompt_manager/gallery' },
|
||||
{ label: 'Metadata viewer', href: '/prompt_manager/gallery.html' },
|
||||
].map(page => Object.freeze(page)));
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
|
||||
'collection']);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { LEGACY_PAGES, SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS, INVALID_SETTING_CODE } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { mountOverview } from './system-overview.mjs';
|
||||
@@ -145,22 +145,6 @@ export const DELIVERED_SECTIONS = Object.freeze({
|
||||
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
|
||||
* not mounted (the drafts outlive it, Q10).
|
||||
*/
|
||||
/** D165 (#525): one small line under System's Overview, the workspace's only visible way to the legacy pages. */
|
||||
function legacyPagesLine() {
|
||||
const line = el('p', 'small legacy-pages');
|
||||
line.append('Legacy pages: ');
|
||||
LEGACY_PAGES.forEach((page, index) => {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = page.label;
|
||||
link.href = window.ApiClient.resolveUrl(page.href);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
if (index) line.append(' · ');
|
||||
line.append(link);
|
||||
});
|
||||
return line;
|
||||
}
|
||||
|
||||
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
|
||||
navigate = navigateTo }) {
|
||||
const store = systemDrafts(api);
|
||||
@@ -187,7 +171,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
|
||||
saveRow.append(status, save);
|
||||
layout.append(saveRow);
|
||||
main.append(nav, layout);
|
||||
const legacyPages = legacyPagesLine();
|
||||
|
||||
const paintStatus = () => {
|
||||
status.textContent = store.statusText();
|
||||
@@ -213,8 +196,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
|
||||
// Every section is delivered (S12), and the router only names known sections (state.mjs).
|
||||
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
|
||||
layout.prepend(...nodes);
|
||||
if (next === 'overview') main.append(legacyPages);
|
||||
else legacyPages.remove();
|
||||
paintStatus();
|
||||
}
|
||||
show(section);
|
||||
|
||||
@@ -575,7 +575,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
}
|
||||
.more-menu-list[hidden] { display: none; }
|
||||
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
|
||||
.legacy-pages { margin: 16px 0 0; color: var(--muted); /* D165 */ }
|
||||
|
||||
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
|
||||
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
|
||||
|
||||
+3
-2
@@ -361,8 +361,9 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-pm-primary text-pm font-sans overflow-hidden">
|
||||
<div class="h-screen flex">
|
||||
<body class="bg-pm-primary text-pm font-sans overflow-hidden" style="display: flex; flex-direction: column; height: 100vh;">
|
||||
<div id="legacy-removal-notice" role="note" class="bg-pm-surface border-b border-pm px-4 py-2 text-pm" style="font-size: 13px;">This page will be removed in a future release. Its features are moving to the new workspace. <a href="workspace#/inspect" style="text-decoration: underline;">Open the workspace</a></div>
|
||||
<div class="flex" style="flex: 1; min-height: 0;">
|
||||
<!-- Main Image Area -->
|
||||
<div id="image-area" class="flex-1 flex items-center justify-center p-3 bg-pm-primary relative border-2 border-dashed border-transparent transition-colors min-w-0">
|
||||
<!-- Upload prompt overlay -->
|
||||
|
||||
+2
-6
@@ -60,12 +60,8 @@
|
||||
</div>
|
||||
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="workspace-boot-failure" role="alert" hidden style="position: fixed; inset: 16px 16px auto; z-index: 1000; padding: 16px; border: 1px solid #888; border-radius: 8px; background: #1b1b1f; color: #eee; font: 14px/1.5 system-ui, sans-serif;">
|
||||
<p style="margin: 0;">The workspace couldn't load. Reload the page, or use a legacy page:
|
||||
<a style="color: inherit;" href="admin">Dashboard</a> ·
|
||||
<a style="color: inherit;" href="admin#/tags">Tags</a> ·
|
||||
<a style="color: inherit;" href="gallery">Gallery</a> ·
|
||||
<a style="color: inherit;" href="gallery.html">Metadata viewer</a></p>
|
||||
<p style="margin: 0;">The workspace couldn't load. <a style="color: inherit;" href="workspace">Reload the page</a>, or go back to <a style="color: inherit;" href="../">ComfyUI</a>.</p>
|
||||
</div>
|
||||
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
|
||||
<noscript><p>This workspace needs JavaScript.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user