Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14c8b2668 | ||
|
|
91cdb23029 | ||
|
|
be4ea95ec4 | ||
|
|
547bc578b9 | ||
|
|
f35e010f79 | ||
|
|
d868745194 | ||
|
|
1bb3678f32 | ||
|
|
2f67b98287 |
+180
-25
@@ -282,11 +282,12 @@ PromptManager's own background threads stop through one package teardown,
|
||||
works against one 10 s deadline. Each step gets the smaller of its own bound
|
||||
and the time left. The steps, in order:
|
||||
|
||||
1. Detach the monitor's callbacks (Needs review recorder, preview queue,
|
||||
Images listing listener).
|
||||
2. `ImageMonitor.stop_monitoring` closes the image handler, which joins the
|
||||
image worker (5 s). It then calls watchdog's `observer.stop()`, which is
|
||||
not bounded here, and joins the observer (5 s).
|
||||
1. Refuse new previews (`refuse_auto_thumbnails()`), then detach the
|
||||
monitor's callbacks (Needs review recorder, preview queue, Images listing
|
||||
listener).
|
||||
2. `ImageMonitor.stop_monitoring` works against one 5 s deadline. Closing the
|
||||
image handler joins the image worker; watchdog's `observer.stop()` (not
|
||||
bounded here) and the observer join get what is left, which can be 0 s.
|
||||
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
|
||||
kills ffmpeg.
|
||||
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
|
||||
@@ -295,7 +296,11 @@ A failing step, or a second Ctrl+C, is logged with its traceback and does not
|
||||
skip the later steps. A clean run logs one INFO line to PromptManager's log:
|
||||
`Background work stopped in 0.4 s: monitor=ok thumbnails=ok tracker=ok`. If
|
||||
any part is still running, one WARNING goes to the console:
|
||||
`Background work not stopped in time (10.0 s): monitor`.
|
||||
`Background work not stopped in time (10.0 s): monitor`. A Ctrl+C that lands
|
||||
between two steps (each step catches its own) is re-raised after the result is
|
||||
stored, with the parts not reached False, and the `atexit` callback is
|
||||
removed: the later steps are skipped, no summary line is logged, and a later
|
||||
call returns that result and runs nothing.
|
||||
|
||||
ComfyUI never runs aiohttp's `on_cleanup` hooks, because it never calls
|
||||
`runner.cleanup()`. So `atexit` is the live host's path. It runs on Ctrl+C
|
||||
@@ -2653,7 +2658,8 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D134 assigned):
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 assigned). D94,
|
||||
the per-section hand-off panel, was retired by S12 (#329): every section is native.
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -2685,10 +2691,6 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
"N output sources" with "Monitoring and enrichment share one source list.", prefixed
|
||||
by "M of N configured sources available." or "None configured is available; ComfyUI's
|
||||
output folder is used." when that is the case.
|
||||
- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your
|
||||
current tools are ready" with a link to each legacy tool that still owns it (the
|
||||
plan's legacy coverage table); retired section by section, gone after S12. Since S11
|
||||
(#328) only Diagnostics & logs hands off (`SECTION_HANDOFFS`, to the admin page).
|
||||
- **D95** (health report): the concept's `health-check` dialog titled "Health report",
|
||||
with no sample note. It lists every check in the server's order: Integrity check,
|
||||
Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing
|
||||
@@ -2719,7 +2721,7 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
- **D123** (Maintenance): the concept's Maintenance notice
|
||||
("Check first. Review changes. Then apply." + Run health checks) was delivered ahead of
|
||||
the rest of Maintenance (S2), as the native place that runs the health check; since S7
|
||||
the section's two surfaces follow it and its D94 hand-off is gone. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
the section's two surfaces follow it and its hand-off panel is gone. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
failed check toasts "The health check could not finish. Nothing was changed."
|
||||
- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves
|
||||
out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a
|
||||
@@ -2840,6 +2842,11 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
and the media hashes, `GET /__test__/system/digest`). `plan dialogs match the concept`
|
||||
(system-maintenance-concept.spec, both themes and sizes) compares each of the four
|
||||
operations' dialogs with the concept's, mapped by these values only.
|
||||
S12 (#329): the `rotated_logs` plan (Diagnostics & logs › Review log cleanup, `h3`
|
||||
"Rotated log copies") changes no library record: its Backup reads "Not needed: no
|
||||
library record changes" (`backup.policy: "none"`), it has no D55 backup box, and its
|
||||
`details` reads "Listed files (N)" (`a plan without a database change reads Not needed
|
||||
and shows no backup box`, `rotated_logs details read Listed files`).
|
||||
- **D101** (Maintenance, S7): Duplicate image files → Find duplicates starts the
|
||||
duplicate search (Activity, D111); its job view adds "Review groups" after Download
|
||||
report once it finished with groups. The concept's `duplicates` dialog ("Review
|
||||
@@ -3117,6 +3124,55 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
`tag output images review matches the concept`, its mutation check and `the
|
||||
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
|
||||
the batch tests of workspace-system-tagging.spec.
|
||||
- **D109** (Logs, S12, #329): the concept's Logs surface on the live server. The toolbar
|
||||
keeps "Level", Refresh and the download (renamed "Download log": the active log,
|
||||
`/logs/download/prompt_manager.log`, through the api client), with all six minimum
|
||||
levels (All levels, DEBUG, INFO, WARNING, ERROR, CRITICAL; the concept has three) and an
|
||||
added `label` "Lines" + `input#log-limit[type=number]` 1–500 (default 100): a value that
|
||||
is not a whole number 1–500 after trimming gets `aria-invalid` and sends nothing, a
|
||||
Level change included. The `pre` shows the in-memory buffer newest first as `HH:MM:SS
|
||||
LEVEL message` ("No log entries at this level." / "The log could not be read."); a
|
||||
newer read wins. Added after it, `p.small#log-stats[role=status]` "Log statistics":
|
||||
"N entries in memory · E errors · W warnings · F files · S on disk" (errors are ERROR +
|
||||
CRITICAL, singular for 1, links not counted). "Runtime log level" adds CRITICAL after
|
||||
the concept's INFO, WARNING, DEBUG, ERROR; it and "Console logging" are runtime drafts
|
||||
saved by Save preferences (`POST /logs/config`, only the changed keys) and read the
|
||||
server's values; their descriptions say "Applied until the server restarts." (the
|
||||
concept's say a gallery toggle is replaced and output stays quiet). Inside "Log files &
|
||||
retention", after the concept's line ("prompt_manager.log · <size> · rotated copies:
|
||||
N", N the `rotated` rows) and Review log cleanup (D100's `rotated_logs` plan): a
|
||||
`p.small` "Rotates at M MB · keeps K rotated copies · file logging on|off · memory
|
||||
buffer B entries" (read-only), an `.operation-list` of `/logs/files` (`h3` name, `p`
|
||||
"<size> · <YYYY-MM-DD HH:MM> · Active log | Rotated copy | Other file | Link, not
|
||||
downloaded", "Download" named "Download <name>" except for a link), and the danger
|
||||
`.operation` "Clear all logs" / "Empty the active log and delete its rotated copies." /
|
||||
"Clear all logs…", which confirms ("Empties prompt_manager.log, deletes N rotated
|
||||
copies (S) and clears the lines shown here. The other files in the log folder are
|
||||
kept. This cannot be undone."), runs `POST /logs/truncate`, toasts "Logs cleared · N
|
||||
rotated copies deleted." (plus " · K could not be deleted") and reads the viewer,
|
||||
statistics, files and configuration again. Pinned by workspace-system-diagnostics.spec's
|
||||
Task 5 and Task 6 tests.
|
||||
- **D110** (Diagnostics, S12, #329): the concept's Diagnostics surface with live values.
|
||||
The `div.meta-grid` `dd`s read one `GET /system/diagnostics`: Database "Readable ·
|
||||
<size> · N images" or "Not readable: <message>"; Sources "N sources · M watching", "N
|
||||
sources · K not available", or with none configured "ComfyUI output folder · watching
|
||||
| manual scan"; Viewer.js "<pinned version> · pinned vendor". Host renderers is D106's
|
||||
renderer line from the send bridge. Each reads "Loading…" first, "Busy: <job> is
|
||||
running." when an exclusive job refuses the read (D57's naming) and "Could not be
|
||||
read." on any other failure. Run checks opens the shared Health report (D95). The
|
||||
buttons drop the concept's "sample": "Download diagnostic report" saves a fresh report
|
||||
plus `host_renderers` as `prompt-manager-diagnostics-YYYYMMDD-HHMM.json` (local time)
|
||||
and toasts "Diagnostic report downloaded." ("Busy: <job> is running. Try again when it
|
||||
ends." / "The diagnostic report could not be downloaded. Nothing was saved to this
|
||||
device."); "Inspect" opens `#/inspect`, which reads a file and writes nothing. An added
|
||||
`.operation` "Test image link" / "Write a placeholder image record to check that
|
||||
linking works." / "Create test link…" keeps the legacy synthetic link (S12-11): it
|
||||
reads the most recent prompt on mount (`aria-disabled` with "Add a prompt first." or
|
||||
"The most recent prompt could not be read." added to the description), confirms in
|
||||
"Create test link" naming the prompt and how Missing-file links removes the record,
|
||||
and toasts "Test link created (image record N)." or "The test link could not be
|
||||
created. Nothing was changed.". Pinned by workspace-system-diagnostics.spec's Task 4
|
||||
tests.
|
||||
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
|
||||
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
|
||||
button instead of reading the file. The concept has no such state, so no concept
|
||||
@@ -3491,7 +3547,9 @@ workflow (README changelog).
|
||||
### LoRA reference import contract (#221, #249 System S10)
|
||||
|
||||
The LoRA import is staged (`py/lora_import.py`): discover the LoraManager
|
||||
`*.metadata.json` files and fingerprint them (path, size, modification time); stage each
|
||||
`*.metadata.json` files and fingerprint every scanned one, kept or skipped (path, size,
|
||||
modification time and whether it was kept, #362; a skipped sidecar added, removed or edited
|
||||
after the review refuses Start with `plan-changed`, `test_start_refused_when_a_skipped_sidecar_changed`); stage each
|
||||
record (CivitAI example images go into the image cache first, best effort, counted in
|
||||
`downloaded` / `download_errors`; each image is written to a temp file beside its cache
|
||||
entry and moved in place, so a half-written image is never seen as cached); the files are
|
||||
@@ -3557,25 +3615,67 @@ the kept, readable LoRAs and `skipped` the rest. Media per LoRA, without repeats
|
||||
preview (`preview_url`, else LoraManager's `PREVIEW_EXTENSIONS` order), then every file in
|
||||
`<example-images root>[/<library>]/<sha256>/` in natural order; a leading video yields to the
|
||||
first still. A file counts only with a servable extension and inside the guarded media
|
||||
roots. CivitAI downloads run only for a LoRA without local media. The folder guard keeps a
|
||||
settings folder only when it exists and is neither a filesystem root (`/`, `C:\`, a UNC
|
||||
share root) nor the home folder or one of its ancestors (realpath + `normcase`). An
|
||||
unusable layout (no install, unreadable / non-JSON / non-object / oversized settings, no
|
||||
`platformdirs`, a missing or refused centralized sidecar root) gives a review without a
|
||||
token ("LoraManager settings: not usable: <reason>. Nothing was changed."), a legacy scan
|
||||
error with that reason, the default LoRA roots for serving, and trigger words from the
|
||||
default roots in alongside mode. `tests/run_suite.py` and `tests/browser/server.py` point
|
||||
roots. Only an absolute `preview_url` is used; a relative one is skipped with a DEBUG line
|
||||
naming the sidecar. A sidecar that is not valid UTF-8 JSON is skipped like any unreadable
|
||||
one (#362). CivitAI downloads run only for a LoRA without local media; a record's
|
||||
images are linked in reverse order, so its first image (the cover) is the newest link and
|
||||
the card image. The folder guard keeps a folder from the settings, and each of ComfyUI's
|
||||
default LoRA folders (`models/loras`, `extra_model_paths.yaml`, `folder_paths`), only when
|
||||
it exists and is neither a filesystem root (`/`, `C:\`, a UNC share root) nor the home
|
||||
folder or a folder that contains it (realpath + `normcase`); a refused default folder that
|
||||
exists is listed in the layout's `refused_defaults` and logged as one warning each time the
|
||||
refused set changes (`RefusedDefaultsTests`). An unusable layout (no install, unreadable /
|
||||
non-JSON / non-object / oversized settings, no `platformdirs`, a missing or refused
|
||||
centralized sidecar root, "the sidecar folder is a filesystem root, the home folder or a
|
||||
folder that contains it") gives a review without a token ("LoraManager settings: not
|
||||
usable: <reason>. Nothing was changed."), a legacy scan error with that reason, the default
|
||||
LoRA roots for serving (no example-images root), and trigger words from the default roots
|
||||
in alongside mode. A folder discovery cannot list answers the review with 500, code
|
||||
`folder-unreadable` ("A LoRA folder could not be listed (...). Nothing was changed.").
|
||||
`TriggerWordCache.load` never raises: an `OSError` is a warning, anything else is logged
|
||||
with its traceback (#362), both keep the previous map, and a running node retries a failed
|
||||
load only after 60 s (`TRIGGER_RETRY_SECONDS`). `tests/run_suite.py` and `tests/browser/server.py` point
|
||||
`LORA_MANAGER_SETTINGS_DIR` at a disposable folder, so no test reads the developer's real
|
||||
LoraManager settings; tests that call `load_layout` inject their own folder too.
|
||||
|
||||
**Linked-only gate and the layout cache (#362).** While the integration is on, the LoRA
|
||||
allow-list (`ImagesMixin._lora_image_dirs`) is the layout's `media_roots` plus the CivitAI
|
||||
download cache, and `_media_roots(outputs)` appends it after the output folders for Library
|
||||
previews, the Library prompt detail, the per-prompt images page, `serve_output_image` for a
|
||||
root past the outputs, `inspect_image` and `reveal_image`. Under such a root only a file the
|
||||
database records as a linked image is served, inspected or revealed (`_linked_only_error`,
|
||||
by its given and its real path); anything else answers 403. Library responses mark that
|
||||
media `linked_only`: no preview file, no favorite, no Tag button. The allow-list's layout
|
||||
comes from `py/lora_layout_cache.py` (`tests/test_lora_layout_cache.py`, which injects the
|
||||
clocks, `stat` and `ntpath` and never sleeps): one entry keyed by the stripped configured
|
||||
path, reused while the plugin settings file's and the settings file's `(st_mtime_ns,
|
||||
st_size)` (or "missing") are unchanged, the install and every media root still resolve to
|
||||
themselves (a folder swapped for a symlink reloads and the guard runs again), and the entry
|
||||
is under `MAX_AGE_SECONDS` (30 s, monotonic clock) old. A load is stored only when no
|
||||
watched file was modified within `RACY_SECONDS` (3 s) of the load (git's racy rule: a write
|
||||
during the load, LoraManager's in-place write, FAT/HFS+ coarse times), every watched file
|
||||
could be checked (any `stat` error but not-found, and an offline Windows share, is "cannot
|
||||
validate"), the settings were readable, and no `clear()` ran meanwhile (generation
|
||||
counter). Hits take no lock; concurrent misses each load. A finished import (System job or
|
||||
legacy scan, both through `replace_lora_imports`) clears it. The integration-off check runs
|
||||
before it, and the configured-path rule (a configured path that is not an install under
|
||||
`custom_nodes` adds no folders) on its result; a hit also needs the same home folder, which
|
||||
the guard compares against. `serve_image` builds the allow-list in the
|
||||
executor. The cache writes one INFO line, "LoraManager layout changed: <source> settings,
|
||||
<mode> storage, <n> media folders", when a stored layout differs from the last one logged.
|
||||
The review, Start, the legacy scan and the trigger cache read the layout fresh. Image route
|
||||
tests clear the per-process cache in `ImageRouteCoverageCase.setUp`; `write_settings` in
|
||||
`TestLoraManagerMediaServing` back-dates its file 10 s so the first load is stored.
|
||||
|
||||
`tests/browser/workspace_integrations_fixture.py` (`/__test__/integrations/*`) makes a fake
|
||||
LoraManager install and LoRA folder under the server's temp root, holds or fails the
|
||||
import, saves a blocking pair and answers CivitAI downloads with a stub opener, so no
|
||||
test reaches the network.
|
||||
|
||||
```sh
|
||||
python3 -m unittest tests.test_lora_manager_layout tests.test_lora_import tests.test_api_lora \
|
||||
tests.test_lora_database tests.test_lora_utils -v
|
||||
python3 -m unittest tests.test_lora_layout_cache tests.test_lora_manager_layout \
|
||||
tests.test_lora_import tests.test_api_lora tests.test_lora_database tests.test_lora_utils \
|
||||
tests.test_api_images -v
|
||||
node --test tests/js/workspace-system-integrations.test.js tests/js/prompt-bridge-adapter.test.js
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-system-integrations.spec.mjs \
|
||||
system-integrations-concept.spec.mjs \
|
||||
@@ -3587,7 +3687,8 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-integrations.sp
|
||||
System › Auto-tagging (`web/js/workspace/system-tagging.mjs`, D132–D134) edits one
|
||||
server-side tagging configuration, `autotag.defaults` in config.json: `model_type`
|
||||
(`wd14-swinv2`, `wd14-vit`, `gguf`, `hf`), `existing` (`skip`, `merge`,
|
||||
`replace_after_review`), `general_threshold` and `character_threshold` (0–1),
|
||||
`replace_after_review`), `general_threshold` and `character_threshold` (0–1 in
|
||||
steps of 0.05, #369),
|
||||
`template` (non-blank, at most 4,000 characters and 16 KiB) and `suggestion_limit`
|
||||
(1–100). They are host drafts (`autotag_model_type`, `autotag_existing`,
|
||||
`autotag_general_threshold`, `autotag_character_threshold`, `autotag_template`,
|
||||
@@ -3598,7 +3699,13 @@ naming the setting by its row label ("General-tag confidence must be between 0 a
|
||||
`settingRefusal` in system.mjs only for a save that carried `autotag_*` keys; every
|
||||
other section's plain 400 keeps the generic status) and the control is highlighted.
|
||||
The template's limit counts code points, as Python's `len` does. A hand-edited bad value
|
||||
falls back to its default on load, one key at a time. `GET /autotag/models` reports the
|
||||
falls back to its default on load, one key at a time. Every page shows the thresholds in
|
||||
steps of 0.05 (the System rows, the Tag image dialogs, the legacy sliders), so a saved
|
||||
threshold off that grid is refused ("General-tag confidence must be a multiple of
|
||||
0.05.", `validate_saved_threshold`; `draftProblem` holds back Preview tagging review and
|
||||
the batch Review with the same sentence) and falls back on load; an accepted one is
|
||||
stored exactly on the grid (#369). A per-request threshold keeps its own check: `/image-annotations/suggest` and
|
||||
`/jobs/autotag` take 0–1; the legacy `/autotag/single` and `/autotag/start` only require a number. `GET /autotag/models` reports the
|
||||
saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_limit`,
|
||||
`default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected
|
||||
dialogs start from them; a preset (the bulk review, Preview tagging review) wins.
|
||||
@@ -3645,6 +3752,54 @@ it. Every workspace-system-tagging test asserts no `/autotag/download/` request.
|
||||
Changing the routes or `py/config.py` needs a ComfyUI restart; the section itself is
|
||||
HTML, CSS and JS only.
|
||||
|
||||
### Logs and diagnostics contract (#249 System S12)
|
||||
|
||||
System › Diagnostics & logs (`web/js/workspace/system-diagnostics.mjs`, D109–D110)
|
||||
reads and deletes only through the routes below; the legacy admin Logs and Diagnostics
|
||||
modals use the same routes and keep working.
|
||||
|
||||
- **Rule R** (`utils/logging_config.py`): a file may be deleted as a rotated copy only
|
||||
when it is a direct entry of the log folder named `prompt_manager.log.<n>` (`<n>` a
|
||||
decimal integer ≥ 1 with no leading zero, compared after `normcase`) and a regular
|
||||
file that is not a link or junction (`lstat`). Every other entry is `active`, `link`
|
||||
or `other` and is never deleted. `/logs/files` rows carry that `kind`; links are listed
|
||||
with their own `lstat` size and are not counted or sized by `/logs/stats`. A download
|
||||
is served only for a regular, non-link file of the folder (a link, a folder or a name
|
||||
not listed is 404), read off the event loop.
|
||||
- **Reviewed cleanup**: `POST /maintenance/preview {operation: "rotated_logs", options:
|
||||
{}}` lists every rotated copy (basenames, size and time) and writes nothing; `POST
|
||||
/maintenance/apply {token}` starts the `maintenance` job "Rotated log copies"
|
||||
(admission `read`, not cancellable). Under the manager's files lock and every file
|
||||
handler's lock it re-scans; when any listed file is missing, no longer rotated or
|
||||
differs in size, `mtime_ns` or a non-zero inode, it deletes nothing and fails
|
||||
`plan-changed` ("The log files changed since the review; nothing was deleted. Review
|
||||
again.", Activity offers Review again). Otherwise it deletes exactly the listed files;
|
||||
each is a Changed row ("Deleted · <size>"), a file that cannot be removed an Errors
|
||||
row. Two Prompt Manager processes sharing one log folder are not locked against each
|
||||
other: each file's identity is checked just before its unlink.
|
||||
- **Clear all logs** (`POST /logs/truncate`, also the admin page's Clear) empties the
|
||||
active log through its handler, deletes the rotated copies (Rule R) and clears the
|
||||
buffer; other files and links are kept. Response keys are unchanged.
|
||||
- **Runtime settings** (`GET/POST /logs/config`): the level (DEBUG…CRITICAL) and console
|
||||
logging apply until the server restarts; the retention values are shown read-only in
|
||||
the workspace and the API keeps accepting them.
|
||||
- **Report** (`GET /system/diagnostics`): built on the server from an explicit list of
|
||||
fields (version, Python, platform, Viewer.js version, the four legacy checks, database
|
||||
info, source counts, log configuration and statistics, the session's health summary
|
||||
and the non-path configuration with `has_civitai_api_key`). Every path is relative to
|
||||
a ComfyUI anchor or reads "outside ComfyUI"; a backstop scrub replaces any absolute
|
||||
path or the saved key and counts it in `redacted`, which the tests require to be 0. It
|
||||
takes a registry lease (409 `busy` while an exclusive job such as a restore runs).
|
||||
- **Compressed downloads**: the gzip middleware drops a handler's declared
|
||||
`Content-Length` before it compresses a body, so the response declares the compressed
|
||||
length. A stale length made a browser wait for bytes that never came: the log download
|
||||
of any file over 1 KB hung (`test_compressed_body_declares_its_own_length`).
|
||||
|
||||
The browser server writes its logs into `<server root>/logs`
|
||||
(`tests/browser/workspace_diagnostics_fixture.py`), whose hooks seed records with fixed
|
||||
times, rotated copies, a link (POSIX) and a user's `.bak`, and roll the copies over; no
|
||||
test reads or deletes a real log folder.
|
||||
|
||||
### Browser cache ownership (#222)
|
||||
|
||||
The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or
|
||||
|
||||
+69
-62
@@ -23,24 +23,20 @@ Features:
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_version():
|
||||
"""Parse version from pyproject.toml"""
|
||||
"""Parse version from pyproject.toml (``utils/version.py``)."""
|
||||
from .utils.version import project_version
|
||||
|
||||
try:
|
||||
pyproject_path = Path(__file__).parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
content = pyproject_path.read_text()
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
root = Path(__file__).parent
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
return project_version(root)
|
||||
|
||||
|
||||
from .prompt_manager import ( # noqa: E402
|
||||
@@ -268,78 +264,89 @@ def _stop_background_work():
|
||||
|
||||
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
|
||||
stopped), leaving out parts that never started. Later calls return a
|
||||
copy of the first result.
|
||||
copy of the first result. A Ctrl+C inside a step is logged and the
|
||||
step is False; one between steps is re-raised after the result (parts
|
||||
not reached are False) is stored and the ``atexit`` entry removed.
|
||||
"""
|
||||
global _background_work_result
|
||||
with _background_work_lock:
|
||||
if _background_work_result is not None:
|
||||
return dict(_background_work_result)
|
||||
logger = _teardown_logger()
|
||||
began = _clock()
|
||||
deadline = began + TEARDOWN_SECONDS
|
||||
|
||||
def left(own_bound):
|
||||
return max(0.0, min(own_bound, deadline - _clock()))
|
||||
|
||||
monitor, thumbnails, tracker = (
|
||||
_global_image_monitor,
|
||||
api,
|
||||
_global_prompt_tracker,
|
||||
)
|
||||
result = {}
|
||||
if monitor is not None:
|
||||
# A part whose global is set has started; False until its step says
|
||||
# otherwise, so an interrupt between steps leaves an honest result.
|
||||
started = (
|
||||
("monitor", monitor),
|
||||
("thumbnails", thumbnails),
|
||||
("tracker", tracker),
|
||||
)
|
||||
result = {name: False for name, part in started if part is not None}
|
||||
try:
|
||||
logger = _teardown_logger()
|
||||
began = _clock()
|
||||
deadline = began + TEARDOWN_SECONDS
|
||||
|
||||
def stop_monitor():
|
||||
from .utils.image_monitor import OBSERVER_JOIN_SECONDS
|
||||
def left(own_bound):
|
||||
return max(0.0, min(own_bound, deadline - _clock()))
|
||||
|
||||
# Callbacks first: an image in hand queues no preview into a
|
||||
# closing preview worker. The worker may have read the queue
|
||||
# callback already, so the API refuses new previews too (at
|
||||
# exit, Python 3.12+ refuses to start their worker thread).
|
||||
if thumbnails is not None:
|
||||
thumbnails.refuse_auto_thumbnails()
|
||||
monitor.set_review_recorder(None)
|
||||
monitor.set_thumbnail_queue(None)
|
||||
monitor.set_output_listener(None)
|
||||
return monitor.stop_monitoring(left(OBSERVER_JOIN_SECONDS))
|
||||
if monitor is not None:
|
||||
|
||||
result["monitor"] = _teardown_step(
|
||||
"the image monitor", stop_monitor, logger
|
||||
)
|
||||
if thumbnails is not None:
|
||||
def stop_monitor():
|
||||
from .utils.image_monitor import OBSERVER_JOIN_SECONDS
|
||||
|
||||
def close_previews():
|
||||
from .py.api.thumbnail_auto import CLOSE_WAIT_SECONDS
|
||||
# Callbacks first: an image in hand queues no preview into a
|
||||
# closing preview worker. The worker may have read the queue
|
||||
# callback already, so the API refuses new previews too (at
|
||||
# exit, Python 3.12+ refuses to start their worker thread).
|
||||
if thumbnails is not None:
|
||||
thumbnails.refuse_auto_thumbnails()
|
||||
monitor.set_review_recorder(None)
|
||||
monitor.set_thumbnail_queue(None)
|
||||
monitor.set_output_listener(None)
|
||||
return monitor.stop_monitoring(left(OBSERVER_JOIN_SECONDS))
|
||||
|
||||
return thumbnails.close_auto_thumbnails(
|
||||
final=True, timeout=left(CLOSE_WAIT_SECONDS)
|
||||
result["monitor"] = _teardown_step(
|
||||
"the image monitor", stop_monitor, logger
|
||||
)
|
||||
if thumbnails is not None:
|
||||
|
||||
result["thumbnails"] = _teardown_step(
|
||||
"the preview worker", close_previews, logger
|
||||
)
|
||||
if tracker is not None:
|
||||
def close_previews():
|
||||
from .py.api.thumbnail_auto import CLOSE_WAIT_SECONDS
|
||||
|
||||
def stop_tracker():
|
||||
from .utils.prompt_tracker import TRACKER_JOIN_SECONDS
|
||||
return thumbnails.close_auto_thumbnails(
|
||||
final=True, timeout=left(CLOSE_WAIT_SECONDS)
|
||||
)
|
||||
|
||||
return tracker.shutdown(left(TRACKER_JOIN_SECONDS))
|
||||
result["thumbnails"] = _teardown_step(
|
||||
"the preview worker", close_previews, logger
|
||||
)
|
||||
if tracker is not None:
|
||||
|
||||
result["tracker"] = _teardown_step(
|
||||
"the prompt cleanup thread", stop_tracker, logger
|
||||
)
|
||||
elapsed = _clock() - began
|
||||
failed = [name for name, ok in result.items() if not ok]
|
||||
if failed:
|
||||
logger.warning(
|
||||
f"Background work not stopped in time ({elapsed:.1f} s): "
|
||||
+ ", ".join(failed)
|
||||
)
|
||||
else:
|
||||
parts = " ".join(f"{name}=ok" for name in result)
|
||||
logger.info(f"Background work stopped in {elapsed:.1f} s: {parts}")
|
||||
_background_work_result = result
|
||||
atexit.unregister(_stop_background_work)
|
||||
def stop_tracker():
|
||||
from .utils.prompt_tracker import TRACKER_JOIN_SECONDS
|
||||
|
||||
return tracker.shutdown(left(TRACKER_JOIN_SECONDS))
|
||||
|
||||
result["tracker"] = _teardown_step(
|
||||
"the prompt cleanup thread", stop_tracker, logger
|
||||
)
|
||||
elapsed = _clock() - began
|
||||
failed = [name for name, ok in result.items() if not ok]
|
||||
if failed:
|
||||
logger.warning(
|
||||
f"Background work not stopped in time ({elapsed:.1f} s): "
|
||||
+ ", ".join(failed)
|
||||
)
|
||||
else:
|
||||
parts = " ".join(f"{name}=ok" for name in result)
|
||||
logger.info(f"Background work stopped in {elapsed:.1f} s: {parts}")
|
||||
finally:
|
||||
_background_work_result = result
|
||||
atexit.unregister(_stop_background_work)
|
||||
return dict(result)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ Split into domain-specific mixins for maintainability:
|
||||
- ImageRoutesMixin: gallery, thumbnails, image serving and linking
|
||||
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
|
||||
- LoggingRoutesMixin: log management endpoints
|
||||
- LogCleanupMixin: the reviewed rotated-log cleanup (a maintenance plan)
|
||||
- DiagnosticsReportMixin: the shareable diagnostic report, without secrets or paths
|
||||
- AutotagRoutesMixin: auto-tagging model management and tagging
|
||||
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
|
||||
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
|
||||
@@ -65,6 +67,8 @@ from .duplicate_files import DuplicateFilesMixin
|
||||
from .maintenance_plans import PlanStore
|
||||
from .jobs import JobRegistry
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
from .log_cleanup import LogCleanupMixin
|
||||
from .diagnostics_report import DiagnosticsReportMixin
|
||||
from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
from .lora_import_routes import LoraImportRoutesMixin
|
||||
@@ -291,6 +295,10 @@ async def _gzip_middleware(request, handler):
|
||||
if len(compressed) >= len(body):
|
||||
return response
|
||||
|
||||
# A length the handler declared is the uncompressed one: drop it so the
|
||||
# compressed body's own length is sent (#329: a stale length made the
|
||||
# browser wait for bytes that never came).
|
||||
response.headers.pop("Content-Length", None)
|
||||
response.body = compressed
|
||||
response.headers["Content-Encoding"] = "gzip"
|
||||
response.headers["Vary"] = "Accept-Encoding"
|
||||
@@ -323,6 +331,8 @@ class PromptManagerAPI(
|
||||
WorkflowStorageRoutesMixin,
|
||||
DuplicateFilesMixin,
|
||||
LoggingRoutesMixin,
|
||||
LogCleanupMixin,
|
||||
DiagnosticsReportMixin,
|
||||
AutotagRoutesMixin,
|
||||
LoraIntegrationMixin,
|
||||
LoraImportRoutesMixin,
|
||||
@@ -678,6 +688,7 @@ class PromptManagerAPI(
|
||||
self._register_settings_routes(routes)
|
||||
self._register_job_routes(routes)
|
||||
self._register_system_routes(routes)
|
||||
self._register_diagnostics_report_routes(routes)
|
||||
self._register_library_location_routes(routes)
|
||||
self._register_maintenance_routes(routes)
|
||||
self._register_backup_restore_routes(routes)
|
||||
|
||||
+24
-12
@@ -489,9 +489,29 @@ def _diagnose_dependencies():
|
||||
}
|
||||
|
||||
|
||||
def _diagnose_output_dirs(configured_dirs):
|
||||
def legacy_checks(db_path, public_path=None):
|
||||
"""The four checks of ``GET /diagnostics`` (blocking).
|
||||
|
||||
Shared with the diagnostic report (``py/api/diagnostics_report.py``),
|
||||
which passes its own ``public_path`` so a folder outside ComfyUI is never
|
||||
named.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
return {
|
||||
"database": _diagnose_database(db_path),
|
||||
"dependencies": _diagnose_dependencies(),
|
||||
"comfyui_output": _diagnose_output_dirs(
|
||||
GalleryConfig.MONITORING_DIRECTORIES, public_path
|
||||
),
|
||||
"image_monitor": _diagnose_image_monitor(public_path),
|
||||
}
|
||||
|
||||
|
||||
def _diagnose_output_dirs(configured_dirs, public_path=None):
|
||||
"""Configured gallery roots, ComfyUI's output dir, or relative fallbacks."""
|
||||
_public_path, _ = _public_helpers()
|
||||
_public_path = public_path or _public_path
|
||||
candidates = list(configured_dirs)
|
||||
try:
|
||||
import folder_paths
|
||||
@@ -525,9 +545,10 @@ def _diagnose_output_dirs(configured_dirs):
|
||||
}
|
||||
|
||||
|
||||
def _diagnose_image_monitor():
|
||||
def _diagnose_image_monitor(public_path=None):
|
||||
"""Status of the running image monitor, with public directory names."""
|
||||
_public_path, _public_error = _public_helpers()
|
||||
_public_path = public_path or _public_path
|
||||
try:
|
||||
monitor = _current_image_monitor()
|
||||
if monitor is None:
|
||||
@@ -1055,16 +1076,7 @@ class AdminRoutesMixin:
|
||||
async def run_diagnostics(self, request):
|
||||
"""Run comprehensive system diagnostics and health checks."""
|
||||
try:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
results = {
|
||||
"database": _diagnose_database(self.db.model.db_path),
|
||||
"dependencies": _diagnose_dependencies(),
|
||||
"comfyui_output": _diagnose_output_dirs(
|
||||
GalleryConfig.MONITORING_DIRECTORIES
|
||||
),
|
||||
"image_monitor": _diagnose_image_monitor(),
|
||||
}
|
||||
results = legacy_checks(self.db.model.db_path)
|
||||
return web.json_response({"success": True, "diagnostics": results})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""The shareable diagnostic report (#329 System S12).
|
||||
|
||||
``GET /prompt_manager/system/diagnostics`` answers a report built on the
|
||||
server from an explicit list of fields: the project and Viewer.js versions,
|
||||
Python and the platform, the four checks of the legacy ``GET /diagnostics``
|
||||
(one helper, ``admin.legacy_checks``), the database's public info, source
|
||||
counts, the log configuration and statistics, the last health summary and
|
||||
the configuration keys that only ``config.json`` holds. It never copies
|
||||
``PromptManagerConfig.get_config()``: no path leaf is listed, and the CivitAI
|
||||
key is reported only as ``has_civitai_api_key``.
|
||||
|
||||
Every path in the report goes through ``report_path``: relative to the
|
||||
closest public anchor that is not a filesystem root, else ``"outside
|
||||
ComfyUI"``, never a basename that could be a user name. As a backstop,
|
||||
``scrub`` replaces any string that still looks like an absolute path, holds
|
||||
the saved key or the home folder (with or without its root), and counts each
|
||||
replacement in ``redacted`` (0 for a correct builder).
|
||||
|
||||
The report opens the database, so it takes a registry lease: it is refused
|
||||
(409 ``busy``) while an ``exclusive`` job (a restore) runs, and a restore
|
||||
waits for it. It is built off the event loop.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import platform
|
||||
import posixpath
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import admin as _admin
|
||||
from .job_routes import busy_response
|
||||
from .jobs import JobBusy
|
||||
from .system import health_summary
|
||||
|
||||
try:
|
||||
from ...utils.version import project_version
|
||||
except ImportError: # loaded as the top-level ``py`` package (tests)
|
||||
from utils.version import project_version
|
||||
|
||||
DIAGNOSTICS_LEASE = "Diagnostics"
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
VIEWERJS_PROVENANCE = str(_ROOT / "web" / "lib" / "viewerjs" / "provenance.json")
|
||||
OUTSIDE = "outside ComfyUI"
|
||||
REMOVED = "[removed]"
|
||||
# The CivitAI key is searched for only when it is long enough to be a key.
|
||||
MIN_KEY_LENGTH = 8
|
||||
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def anchored_path(canonical, anchors, home, pathmod=os.path):
|
||||
"""``canonical`` relative to the closest usable anchor, else ``outside ComfyUI``.
|
||||
|
||||
All three are already canonical (``normcase``) in ``pathmod``'s spelling.
|
||||
A filesystem root is never usable, nor is a strict ancestor of ``home``
|
||||
(``/home``, ``C:\\Users``), whose relative paths would start with a user
|
||||
name; so a path inside the home folder needs an anchor at or below it.
|
||||
"""
|
||||
flavor = PureWindowsPath if pathmod is ntpath else PurePosixPath
|
||||
target = flavor(canonical)
|
||||
home_path = flavor(home) if home else None
|
||||
usable = []
|
||||
for anchor in map(flavor, anchors):
|
||||
if anchor.parent == anchor:
|
||||
continue
|
||||
if home_path is not None and home_path != anchor:
|
||||
if home_path.is_relative_to(anchor):
|
||||
continue
|
||||
if target.is_relative_to(anchor):
|
||||
usable.append(anchor)
|
||||
if not usable:
|
||||
return OUTSIDE
|
||||
anchor = max(usable, key=lambda a: len(a.parts))
|
||||
return target.relative_to(anchor).as_posix() or "."
|
||||
|
||||
|
||||
def _canonical(path):
|
||||
return os.path.normcase(os.path.realpath(str(path)))
|
||||
|
||||
|
||||
def report_path(path):
|
||||
"""``path`` relative to a public anchor (POSIX style), else ``outside ComfyUI``."""
|
||||
if not path:
|
||||
return ""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
try:
|
||||
canonical = _canonical(path)
|
||||
anchors = [_canonical(anchor) for anchor in GalleryConfig.path_anchors()]
|
||||
home = os.path.expanduser("~")
|
||||
home = _canonical(home) if home and home != "~" else None
|
||||
except (OSError, ValueError):
|
||||
return OUTSIDE
|
||||
return anchored_path(canonical, anchors, home)
|
||||
|
||||
|
||||
def _looks_absolute(text):
|
||||
return (
|
||||
posixpath.isabs(text)
|
||||
or ntpath.isabs(text)
|
||||
or _WINDOWS_ABSOLUTE.match(text) is not None
|
||||
or text.startswith("\\\\")
|
||||
)
|
||||
|
||||
|
||||
# The home folder is matched only as whole path parts, never inside a word.
|
||||
_PART = r"(?<![\w.-]){}(?![\w.-])"
|
||||
MIN_HOME_NAME = 3
|
||||
|
||||
|
||||
def _home_patterns():
|
||||
"""``(pattern, paths_only)`` for the home folder: in full, rootless, its name.
|
||||
|
||||
Accepted residual risk: only this process's own home folder is known, so
|
||||
another user's home (an output folder under /home/<them> while ComfyUI
|
||||
runs as a service account) can still be named by a report path.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
if not home or home == "~" or len(home) < 4 or home in ("/", "\\"):
|
||||
return ()
|
||||
spellings = {home, home.replace("\\", "/")}
|
||||
relative = os.path.splitdrive(home)[1].strip("\\/")
|
||||
for spelling in (relative, relative.replace("\\", "/")):
|
||||
if "/" in spelling or "\\" in spelling:
|
||||
spellings.add(spelling)
|
||||
patterns = [
|
||||
(re.compile(_PART.format(re.escape(spelling)), re.IGNORECASE), False)
|
||||
for spelling in spellings
|
||||
]
|
||||
name = re.split(r"[\\/]", relative)[-1] if relative else ""
|
||||
if len(name) >= MIN_HOME_NAME:
|
||||
# The bare name only inside a path ("info/x"), not a word ("INFO").
|
||||
patterns.append(
|
||||
(re.compile(_PART.format(re.escape(name)), re.IGNORECASE), True)
|
||||
)
|
||||
return tuple(patterns)
|
||||
|
||||
|
||||
def scrub(report, key):
|
||||
"""``report`` with leaked paths and the key replaced, counted in ``redacted``."""
|
||||
key = key if isinstance(key, str) and len(key) >= MIN_KEY_LENGTH else None
|
||||
homes = _home_patterns()
|
||||
count = 0
|
||||
|
||||
def clean(value):
|
||||
nonlocal count
|
||||
if isinstance(value, str):
|
||||
if (
|
||||
_looks_absolute(value)
|
||||
or (key and key in value)
|
||||
or any(
|
||||
pattern.search(value)
|
||||
for pattern, paths_only in homes
|
||||
if not paths_only or "/" in value or "\\" in value
|
||||
)
|
||||
):
|
||||
count += 1
|
||||
return REMOVED
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {name: clean(item) for name, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [clean(item) for item in value]
|
||||
return value
|
||||
|
||||
cleaned = clean(report)
|
||||
cleaned["redacted"] = int(cleaned.get("redacted") or 0) + count
|
||||
return cleaned
|
||||
|
||||
|
||||
def viewerjs_version(path=None):
|
||||
"""The pinned Viewer.js version from its provenance file, or ``unknown``."""
|
||||
try:
|
||||
with open(path or VIEWERJS_PROVENANCE, encoding="utf-8") as handle:
|
||||
version = json.load(handle).get("version")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return "unknown"
|
||||
return version if isinstance(version, str) and version else "unknown"
|
||||
|
||||
|
||||
def public_config():
|
||||
"""The configuration keys only ``config.json`` holds, from an explicit list."""
|
||||
from ..config import GalleryConfig, IntegrationConfig, PromptManagerConfig
|
||||
|
||||
return {
|
||||
"database": {
|
||||
"enable_duplicate_detection": (
|
||||
PromptManagerConfig.ENABLE_DUPLICATE_DETECTION
|
||||
),
|
||||
"enable_auto_save": PromptManagerConfig.ENABLE_AUTO_SAVE,
|
||||
},
|
||||
"web_ui": {
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
"show_test_button": PromptManagerConfig.SHOW_TEST_BUTTON,
|
||||
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
|
||||
},
|
||||
"performance": {
|
||||
"max_search_results": PromptManagerConfig.MAX_SEARCH_RESULTS,
|
||||
"enable_fuzzy_search": PromptManagerConfig.ENABLE_FUZZY_SEARCH,
|
||||
"auto_backup_interval": PromptManagerConfig.AUTO_BACKUP_INTERVAL,
|
||||
"worker_threads": PromptManagerConfig.WORKER_THREADS,
|
||||
},
|
||||
"gallery": {
|
||||
"processing_delay": GalleryConfig.PROCESSING_DELAY,
|
||||
"prompt_timeout": GalleryConfig.PROMPT_TIMEOUT,
|
||||
"cleanup_interval": GalleryConfig.CLEANUP_INTERVAL,
|
||||
"auto_cleanup_missing_files": GalleryConfig.AUTO_CLEANUP_MISSING_FILES,
|
||||
"max_image_age_days": GalleryConfig.MAX_IMAGE_AGE_DAYS,
|
||||
"max_concurrent_processing": GalleryConfig.MAX_CONCURRENT_PROCESSING,
|
||||
"metadata_extraction_timeout": GalleryConfig.METADATA_EXTRACTION_TIMEOUT,
|
||||
},
|
||||
"integrations": {
|
||||
"lora_manager_enabled": bool(IntegrationConfig.LORA_MANAGER_ENABLED),
|
||||
"has_civitai_api_key": bool(IntegrationConfig.CIVITAI_API_KEY),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _source_counts(sources):
|
||||
configured = sources.get("configured") or []
|
||||
effective = sources.get("effective") or []
|
||||
return {
|
||||
"configured": len(configured),
|
||||
"effective": len(effective),
|
||||
"watching": sum(1 for row in effective if row.get("watch") == "watching"),
|
||||
"unavailable": sum(1 for row in configured if row.get("valid") is False),
|
||||
}
|
||||
|
||||
|
||||
class DiagnosticsReportMixin:
|
||||
"""Mixin providing ``GET /prompt_manager/system/diagnostics``."""
|
||||
|
||||
def _register_diagnostics_report_routes(self, routes):
|
||||
@routes.get("/prompt_manager/system/diagnostics")
|
||||
async def diagnostics_report_route(request):
|
||||
return await self.get_diagnostics_report(request)
|
||||
|
||||
async def get_diagnostics_report(self, request):
|
||||
"""The report, built off the loop under a lease (409 ``busy`` in a restore)."""
|
||||
from ..config import IntegrationConfig
|
||||
|
||||
try:
|
||||
async with self._jobs.lease(DIAGNOSTICS_LEASE):
|
||||
report = await self._run_in_executor(self.build_report)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
except Exception:
|
||||
self.logger.exception("The diagnostic report could not be built")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": None,
|
||||
"error": "The diagnostic report could not be built.",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
report = scrub(report, IntegrationConfig.CIVITAI_API_KEY)
|
||||
return web.json_response(report, headers={"Cache-Control": "no-store"})
|
||||
|
||||
def build_report(self):
|
||||
"""The report's fields (blocking: reads the database and the log folder)."""
|
||||
db = self.db
|
||||
manager = self._get_logger_manager()
|
||||
stats = dict(manager.get_log_stats())
|
||||
stats["log_directory"] = report_path(stats.get("log_directory"))
|
||||
info = dict(db.model.get_database_info() or {})
|
||||
if "database_path" in info:
|
||||
info["database_path"] = report_path(info["database_path"])
|
||||
sources, _monitor = self._source_status()
|
||||
return {
|
||||
"success": True,
|
||||
"generated_at": datetime.datetime.now().replace(microsecond=0).isoformat(),
|
||||
"version": project_version(_ROOT),
|
||||
"python": platform.python_version(),
|
||||
"platform": {"system": platform.system(), "release": platform.release()},
|
||||
"viewerjs": viewerjs_version(VIEWERJS_PROVENANCE),
|
||||
"checks": _admin.legacy_checks(db.model.db_path, report_path),
|
||||
"database_info": info,
|
||||
"sources": _source_counts(sources),
|
||||
"logging": {"config": dict(manager.get_config()), "stats": stats},
|
||||
"health": health_summary(self._current_health_report()),
|
||||
"config": public_config(),
|
||||
"redacted": 0,
|
||||
}
|
||||
+11
-14
@@ -2272,7 +2272,8 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
image_path = Path(image["image_path"])
|
||||
allowed_dirs = list(self._get_all_output_dirs()) + self._lora_image_dirs()
|
||||
lora_dirs = await self._run_in_executor(self._lora_image_dirs)
|
||||
allowed_dirs = list(self._get_all_output_dirs()) + lora_dirs
|
||||
|
||||
denied = _media_access_error(image_path, allowed_dirs)
|
||||
if denied is not None:
|
||||
@@ -2320,7 +2321,10 @@ class ImageRoutesMixin:
|
||||
LoraManager's guarded LoRA roots, sidecar root and example-images
|
||||
root (``lora_manager_layout.media_roots``, #355) and PromptManager's
|
||||
CivitAI download cache. An unusable LoraManager layout gives the
|
||||
default LoRA roots and the cache.
|
||||
default LoRA roots and the cache. The layout comes from the layout
|
||||
cache (``lora_utils.cached_lora_manager_layout``, #362), which reloads
|
||||
when LoraManager's settings files change; blocking, so the routes
|
||||
call it in the executor.
|
||||
"""
|
||||
try:
|
||||
from ..config import IntegrationConfig
|
||||
@@ -2328,25 +2332,18 @@ class ImageRoutesMixin:
|
||||
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
||||
return []
|
||||
from ..lora_utils import (
|
||||
cached_lora_manager_layout,
|
||||
get_lora_image_cache_dir,
|
||||
layout_for_install,
|
||||
lora_manager_layout,
|
||||
)
|
||||
|
||||
lora_root = IntegrationConfig.LORA_MANAGER_PATH or ""
|
||||
if lora_root.strip():
|
||||
configured = IntegrationConfig.LORA_MANAGER_PATH or ""
|
||||
install, layout = cached_lora_manager_layout(configured)
|
||||
if configured.strip() and install is None:
|
||||
# A configured path comes from config.json, which can be hand
|
||||
# edited: only a LoraManager install under custom_nodes may
|
||||
# widen the serving allow-list. An empty path keeps the
|
||||
# auto-detection, which itself only scans custom_nodes.
|
||||
from ..lora_utils import resolve_lora_manager_path
|
||||
|
||||
lora_root = resolve_lora_manager_path(lora_root)
|
||||
if not lora_root:
|
||||
return []
|
||||
layout = layout_for_install(lora_root)
|
||||
else:
|
||||
layout = lora_manager_layout("")
|
||||
return []
|
||||
|
||||
dirs = [Path(d) for d in layout.media_roots]
|
||||
# Named, not made: listing and serving never create the cache.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Reviewed rotated-log cleanup as a maintenance plan (#329 System S12).
|
||||
|
||||
``POST /prompt_manager/maintenance/preview {operation: "rotated_logs",
|
||||
options: {}}`` lists the rotated copies of the active log that Rule R allows
|
||||
(``utils/logging_config.py``: ``prompt_manager.log.<n>`` regular files that
|
||||
are not links, directly in the log folder) and writes nothing. The plan keeps
|
||||
each copy's name, size, ``mtime_ns`` and inode under its token; the response
|
||||
is the maintenance ``Plan`` shape with basenames only, a ``none`` backup
|
||||
policy (no library record changes) and no ``disk`` key.
|
||||
|
||||
``POST /prompt_manager/maintenance/apply {token}`` starts a ``maintenance``
|
||||
job titled "Rotated log copies" with admission ``read``: it may run beside a
|
||||
scan and is refused only beside another maintenance job or an ``exclusive``
|
||||
one. The job deletes exactly the reviewed copies under the logger's files
|
||||
lock and the file handler's lock, or nothing, failing ``plan-changed``, when
|
||||
any of them is missing, is no longer a rotated copy or differs in size,
|
||||
``mtime_ns`` or inode. A copy that cannot be deleted (open in another program
|
||||
on Windows) is an ``errors`` row; the others are deleted.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...utils.logging_config import RotatedChanged, rotated_sort_key
|
||||
except ImportError: # loaded as the top-level ``py`` package (tests)
|
||||
from utils.logging_config import RotatedChanged, rotated_sort_key
|
||||
|
||||
from .jobs import MAINTENANCE_JOB_KIND
|
||||
from .maintenance_plans import ScopeTooLarge
|
||||
|
||||
ROTATED_LOGS = "rotated_logs"
|
||||
LOG_CLEANUP_TITLE = "Rotated log copies"
|
||||
LOGS_CHANGED = (
|
||||
"The log files changed since the review; nothing was deleted. Review again."
|
||||
)
|
||||
LOG_CLEANUP_FAILED = "The log cleanup failed."
|
||||
TOO_MANY_LOG_FILES = "Too many log files to review at once."
|
||||
LOG_CLEANUP_MEDIA = (
|
||||
"Deletes only the listed log files. No image, video or library record changes."
|
||||
)
|
||||
_GROUPS = ("changed", "protected", "errors")
|
||||
|
||||
|
||||
def size_label(size):
|
||||
"""``1000 bytes``, ``2.0 KB``, ``10.0 MB`` or ``1.2 GB`` (1 KB = 1024 bytes)."""
|
||||
size = max(0, int(size or 0))
|
||||
if size < 1024:
|
||||
return f"{size} {'byte' if size == 1 else 'bytes'}"
|
||||
for unit, scale in (("KB", 2**10), ("MB", 2**20)):
|
||||
if size < scale * 1024:
|
||||
return f"{size / scale:.1f} {unit}"
|
||||
return f"{size / 2**30:.1f} GB"
|
||||
|
||||
|
||||
def time_label(mtime_ns):
|
||||
"""The local modification time as ``YYYY-MM-DD HH:MM``."""
|
||||
try:
|
||||
moment = datetime.datetime.fromtimestamp(mtime_ns / 1e9)
|
||||
except (OSError, OverflowError, ValueError): # a time before 1970 on Windows
|
||||
return "unknown time"
|
||||
return moment.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _count(count, singular, plural):
|
||||
return f"{count} {singular if count == 1 else plural}"
|
||||
|
||||
|
||||
def _protected_reasons(rows):
|
||||
"""The plan's Protected reasons: what the cleanup keeps, zero counts left out."""
|
||||
active = sum(1 for row in rows if row["kind"] == "active")
|
||||
links = sum(1 for row in rows if row["kind"] == "link")
|
||||
other = sum(1 for row in rows if row["kind"] == "other")
|
||||
reasons = []
|
||||
if active:
|
||||
reasons.append("Kept: the active log")
|
||||
if links:
|
||||
reasons.append(f"Kept: {_count(links, 'link', 'links')}")
|
||||
if other:
|
||||
reasons.append(f"Kept: {_count(other, 'other file', 'other files')}")
|
||||
return active + links + other, reasons
|
||||
|
||||
|
||||
def _scope_label(files):
|
||||
if not files:
|
||||
return "No rotated log copies"
|
||||
total = sum(row["size"] for row in files)
|
||||
return (
|
||||
f"{_count(len(files), 'rotated log copy', 'rotated log copies')}"
|
||||
f" · {size_label(total)}"
|
||||
)
|
||||
|
||||
|
||||
class LogCleanupMixin:
|
||||
"""The ``rotated_logs`` maintenance plan and its job.
|
||||
|
||||
The logger manager is reached only through ``_get_logger_manager``
|
||||
(``logging_routes.py``), so the tests can point it at a temporary folder.
|
||||
"""
|
||||
|
||||
async def preview_log_cleanup(self):
|
||||
"""The ``rotated_logs`` plan (runs inside the preview's lease)."""
|
||||
from . import maintenance
|
||||
|
||||
manager = self._get_logger_manager()
|
||||
try:
|
||||
rows = await self._run_in_executor(manager.scan_log_folder)
|
||||
except Exception:
|
||||
self.logger.exception("Rotated log review failed")
|
||||
return maintenance._failure("Failed to review maintenance", 500)
|
||||
rotated = sorted(
|
||||
(row for row in rows if row["kind"] == "rotated"),
|
||||
key=rotated_sort_key,
|
||||
)
|
||||
files = [
|
||||
{
|
||||
"name": row["name"],
|
||||
"size": row["size"],
|
||||
"mtime_ns": row["mtime_ns"],
|
||||
"ino": row["ino"],
|
||||
}
|
||||
for row in rotated
|
||||
]
|
||||
protected, reasons = _protected_reasons(rows)
|
||||
store = self._maintenance_plans
|
||||
try:
|
||||
token = store.issue(
|
||||
ROTATED_LOGS,
|
||||
json.dumps(files, sort_keys=True),
|
||||
{"files": files, "protected": protected},
|
||||
(),
|
||||
)
|
||||
except ScopeTooLarge:
|
||||
return maintenance._failure(TOO_MANY_LOG_FILES, 413, "too-large")
|
||||
plan = store.peek(token, ROTATED_LOGS)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"token": token,
|
||||
"operation": ROTATED_LOGS,
|
||||
"expires_at": maintenance._iso(plan.expires_at),
|
||||
"scope": {"count": len(files), "label": _scope_label(files)},
|
||||
"protected": {"count": protected, "reasons": reasons},
|
||||
"backup": {
|
||||
"policy": "none",
|
||||
"destination_label": None,
|
||||
"expected_bytes": None,
|
||||
},
|
||||
"media": {"deletes_files": True, "label": LOG_CLEANUP_MEDIA},
|
||||
"details": [
|
||||
{
|
||||
"label": row["name"],
|
||||
"detail": (
|
||||
f"{size_label(row['size'])} · {time_label(row['mtime_ns'])}"
|
||||
),
|
||||
}
|
||||
for row in files
|
||||
],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
def _start_log_cleanup(self, plan):
|
||||
"""Register the cleanup job (on the loop); raises JobBusy when refused."""
|
||||
return self._jobs.start(
|
||||
MAINTENANCE_JOB_KIND,
|
||||
LOG_CLEANUP_TITLE,
|
||||
admission="read",
|
||||
cancellable=False,
|
||||
scope={"operation": ROTATED_LOGS},
|
||||
groups=_GROUPS,
|
||||
runner=lambda job: self._run_log_cleanup(job, plan),
|
||||
)
|
||||
|
||||
async def _run_log_cleanup(self, job, plan):
|
||||
from . import maintenance
|
||||
|
||||
files = plan.payload["files"]
|
||||
sizes = {row["name"]: row["size"] for row in files}
|
||||
job.update(total=len(files), status_line="Deleting rotated log copies")
|
||||
manager = self._get_logger_manager()
|
||||
try:
|
||||
result = await self._run_in_executor(manager.delete_rotated, files)
|
||||
except RotatedChanged:
|
||||
return maintenance._fail_job(job, "plan-changed", LOGS_CHANGED)
|
||||
except Exception:
|
||||
self.logger.exception("Rotated log cleanup failed")
|
||||
return maintenance._fail_job(job, None, LOG_CLEANUP_FAILED)
|
||||
for name in result["removed"]:
|
||||
detail = f"Deleted · {size_label(sizes.get(name, 0))}"
|
||||
job.record(
|
||||
"changed", {"path": name, "detail": detail}, f"- {name} · {detail}"
|
||||
)
|
||||
for name, error in result["failed"]:
|
||||
detail = self._public_error(error)
|
||||
job.record(
|
||||
"errors", {"path": name, "detail": detail}, f"! {name} · {detail}"
|
||||
)
|
||||
job.record_counted("protected", plan.payload["protected"])
|
||||
removed, failed = len(result["removed"]), len(result["failed"])
|
||||
line = f"Deleted {_count(removed, 'rotated log copy', 'rotated log copies')}"
|
||||
if failed:
|
||||
line += f", {failed} could not be deleted"
|
||||
job.log_line(line)
|
||||
job.update(processed=len(files), progress=100, status_line=line)
|
||||
@@ -1,6 +1,13 @@
|
||||
"""Logging API routes for PromptManager."""
|
||||
"""Logging API routes for PromptManager.
|
||||
|
||||
Every route that names a log file goes through Rule R (#329,
|
||||
``utils/logging_config.py``): a download is served only for a regular,
|
||||
non-link file listed directly in the log folder, and Clear deletes only
|
||||
rotated copies. File work runs off the event loop.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -85,6 +92,41 @@ def _validate_log_config(data):
|
||||
return clean, None
|
||||
|
||||
|
||||
def _read_log_bytes(path):
|
||||
"""The whole file at ``path``, opened without following a link (blocking).
|
||||
|
||||
The file is checked again through the open handle: a regular file with
|
||||
one name and, when ``path`` carries it, the inode it was listed with. A
|
||||
name swapped for a link or another file after it was listed, or a hard
|
||||
link to a file elsewhere, is refused as missing. The handle is closed
|
||||
before this returns.
|
||||
"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
# A FIFO swapped in after the listing must not block the executor thread.
|
||||
flags |= getattr(os, "O_NONBLOCK", 0)
|
||||
try:
|
||||
fd = os.open(str(path), flags)
|
||||
except OSError as error:
|
||||
if getattr(os, "O_NOFOLLOW", 0) and os.path.islink(str(path)):
|
||||
raise FileNotFoundError("Log file not found") from error
|
||||
raise
|
||||
listed_ino = getattr(path, "listed_ino", 0)
|
||||
with os.fdopen(fd, "rb") as handle:
|
||||
opened = os.fstat(handle.fileno())
|
||||
if (
|
||||
not stat.S_ISREG(opened.st_mode)
|
||||
or opened.st_nlink > 1 # a hard link to a file elsewhere
|
||||
or (listed_ino and opened.st_ino and opened.st_ino != listed_ino)
|
||||
):
|
||||
raise FileNotFoundError("Log file not found")
|
||||
return handle.read()
|
||||
|
||||
|
||||
def _download(logger_manager, filename):
|
||||
"""``bytes`` of a downloadable log file; FileNotFoundError otherwise."""
|
||||
return _read_log_bytes(logger_manager.log_file_entry(filename))
|
||||
|
||||
|
||||
class LoggingRoutesMixin:
|
||||
"""Mixin providing logging-related API endpoints."""
|
||||
|
||||
@@ -195,16 +237,16 @@ class LoggingRoutesMixin:
|
||||
{"success": False, "error": "Invalid filename"}, status=400
|
||||
)
|
||||
|
||||
log_file_path = logger_manager.log_dir / filename
|
||||
|
||||
if not log_file_path.exists():
|
||||
try:
|
||||
file_content = await self._run_in_executor(
|
||||
_download, logger_manager, filename
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Missing, a link, a folder or not listed: all read as absent.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Log file not found"}, status=404
|
||||
)
|
||||
|
||||
with open(log_file_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
||||
return web.Response(
|
||||
body=file_content,
|
||||
content_type="text/plain",
|
||||
@@ -224,7 +266,7 @@ class LoggingRoutesMixin:
|
||||
"""Truncate all log files."""
|
||||
try:
|
||||
logger_manager = self._get_logger_manager()
|
||||
results = logger_manager.truncate_logs()
|
||||
results = await self._run_in_executor(logger_manager.truncate_logs)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -266,7 +308,7 @@ class LoggingRoutesMixin:
|
||||
return web.json_response({"success": False, "error": error}, status=400)
|
||||
|
||||
logger_manager = self._get_logger_manager()
|
||||
logger_manager.update_config(clean)
|
||||
await self._run_in_executor(logger_manager.update_config, clean)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -194,8 +194,13 @@ class LoraImportRoutesMixin:
|
||||
return False
|
||||
|
||||
async def replace_lora_imports(self, db, staged, *, expected_ids, allow_empty):
|
||||
"""Swap the collection for ``staged.records`` in one transaction."""
|
||||
return await self._run_in_executor(
|
||||
"""Swap the collection for ``staged.records`` in one transaction.
|
||||
|
||||
Then the image routes' layout cache is dropped (#362): the import may
|
||||
have linked files under a folder LoraManager created after the cache
|
||||
stored its layout (its first example-image download creates one).
|
||||
"""
|
||||
result = await self._run_in_executor(
|
||||
lambda: db.replace_category_prompts(
|
||||
CATEGORY,
|
||||
staged.records,
|
||||
@@ -203,6 +208,8 @@ class LoraImportRoutesMixin:
|
||||
allow_empty=allow_empty,
|
||||
)
|
||||
)
|
||||
lora_utils.clear_layout_cache()
|
||||
return result
|
||||
|
||||
# ── preview ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+19
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
|
||||
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
|
||||
``missing_links``; ``database/maintenance.py``) off the event loop and
|
||||
``missing_links``; ``database/maintenance.py``; ``duplicate_files``,
|
||||
``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies,
|
||||
``py/api/log_cleanup.py``, which has its own apply job) off the event loop and
|
||||
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
|
||||
label}, protected:{count, reasons}, backup:{policy, destination_label},
|
||||
media:{deletes_files, label}, details:[≤50 {label, detail}]}``. The preview
|
||||
@@ -54,6 +56,7 @@ from aiohttp import web
|
||||
from .admin import _prune_safety_backups, _scan_location, _scan_roots
|
||||
from .job_routes import busy_response
|
||||
from .jobs import MAINTENANCE_JOB_KIND, JobBusy
|
||||
from .log_cleanup import ROTATED_LOGS
|
||||
from .maintenance_plans import PlanExpired, PlanUsed, ScopeTooLarge
|
||||
|
||||
try:
|
||||
@@ -83,7 +86,8 @@ MAINTENANCE_PREVIEW_LEASE = "Maintenance preview"
|
||||
DUPLICATE_PREVIEW_BODY_MAX = 1024 * 1024
|
||||
DUPLICATE_FILES = "duplicate_files"
|
||||
WORKFLOW_STORAGE = "workflow_storage" # applied by py/api/workflow_storage.py
|
||||
PREVIEW_OPERATIONS = MAINTENANCE_OPERATIONS + (DUPLICATE_FILES,)
|
||||
# rotated_logs (#329, py/api/log_cleanup.py) reviews log files, not records.
|
||||
PREVIEW_OPERATIONS = MAINTENANCE_OPERATIONS + (DUPLICATE_FILES, ROTATED_LOGS)
|
||||
# Room left over beyond what a step writes, so a disk is never filled to 0.
|
||||
DISK_MARGIN_BYTES = 16 * 2**20
|
||||
# The concept's Maintenance row titles, which name each operation's job.
|
||||
@@ -391,6 +395,8 @@ class MaintenanceRoutesMixin:
|
||||
return await self.preview_duplicate_files(options)
|
||||
if not isinstance(options, dict) or options:
|
||||
return _invalid(f"{operation} takes no options")
|
||||
if operation == ROTATED_LOGS:
|
||||
return await self.preview_log_cleanup()
|
||||
db = self.db
|
||||
identity = self._database_identity(db)
|
||||
try:
|
||||
@@ -492,6 +498,17 @@ class MaintenanceRoutesMixin:
|
||||
if refused is not None:
|
||||
return refused
|
||||
store = self._maintenance_plans
|
||||
if plan.operation == ROTATED_LOGS:
|
||||
# Log files only: no database identity, backup or disk check.
|
||||
try:
|
||||
job = self._start_log_cleanup(plan)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy) # the token stays usable
|
||||
# Same loop turn as the peek and the start: the runner has not run.
|
||||
store.consume(token, plan.operation, job_id=job.id)
|
||||
return web.json_response(
|
||||
{"success": True, "job": job.summary()}, status=202
|
||||
)
|
||||
db = self.db
|
||||
if plan.db_identity != self._database_identity(db):
|
||||
store.discard(token)
|
||||
|
||||
@@ -173,8 +173,8 @@ def _validate_autotag_settings(data):
|
||||
EXISTING_POLICIES,
|
||||
MAX_SUGGESTION_LIMIT,
|
||||
MODEL_TYPES,
|
||||
validate_saved_threshold,
|
||||
validate_template,
|
||||
validate_threshold,
|
||||
)
|
||||
|
||||
updates = {}
|
||||
@@ -198,7 +198,7 @@ def _validate_autotag_settings(data):
|
||||
):
|
||||
if key in data:
|
||||
try:
|
||||
value = validate_threshold(label, data[key])
|
||||
value = validate_saved_threshold(label, data[key])
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{error}.") from None
|
||||
updates[key.upper()] = value
|
||||
|
||||
+4
-2
@@ -573,6 +573,7 @@ try:
|
||||
MAX_INSTRUCTION_CHARS,
|
||||
MODEL_TYPES,
|
||||
WD14_MODEL_TYPES,
|
||||
validate_saved_threshold,
|
||||
validate_template,
|
||||
validate_threshold,
|
||||
)
|
||||
@@ -585,6 +586,7 @@ except ImportError:
|
||||
MAX_INSTRUCTION_CHARS,
|
||||
MODEL_TYPES,
|
||||
WD14_MODEL_TYPES,
|
||||
validate_saved_threshold,
|
||||
validate_template,
|
||||
validate_threshold,
|
||||
)
|
||||
@@ -631,12 +633,12 @@ class AutotagConfig:
|
||||
"existing": ("EXISTING", _valid_existing, "skip"),
|
||||
"general_threshold": (
|
||||
"GENERAL_THRESHOLD",
|
||||
lambda v: validate_threshold("value", v),
|
||||
lambda v: validate_saved_threshold("value", v),
|
||||
0.35,
|
||||
),
|
||||
"character_threshold": (
|
||||
"CHARACTER_THRESHOLD",
|
||||
lambda v: validate_threshold("value", v),
|
||||
lambda v: validate_saved_threshold("value", v),
|
||||
0.85,
|
||||
),
|
||||
"template": ("TEMPLATE", validate_template, DEFAULT_TAG_TEMPLATE),
|
||||
|
||||
+13
-10
@@ -12,9 +12,10 @@ last writes to the database:
|
||||
the sidecars (``discover``). In ``alongside`` mode a sidecar beside its
|
||||
model is kept when ``<base>.safetensors`` exists; in ``centralized`` mode
|
||||
every sidecar under the sidecar root is kept when its ``file_path`` names
|
||||
a model inside a LoRA root. ``fingerprint`` covers the kept sidecars (path,
|
||||
size and modification time) and the layout; ``preview`` adds the counts a
|
||||
review shows. Nothing is written and nothing is requested.
|
||||
a model inside a LoRA root. ``fingerprint`` covers every scanned sidecar,
|
||||
kept or skipped (path, size, modification time, kept or not, #362) and the
|
||||
layout; ``preview`` adds the counts a review shows. Nothing is written
|
||||
and nothing is requested.
|
||||
2. **stage**: read each sidecar and build its record. Its LoraManager preview
|
||||
and example images are linked where they are (``local_media``); only a
|
||||
LoRA without any local image downloads its CivitAI example images into the
|
||||
@@ -69,9 +70,10 @@ def discover(lora_manager_path: Optional[str]) -> Discovery:
|
||||
|
||||
|
||||
def fingerprint(discovery: Discovery) -> str:
|
||||
"""What a review saw: the layout, then each kept sidecar's path, size and
|
||||
modification time (a LoraManager save that changes unrelated settings
|
||||
leaves it as it was)."""
|
||||
"""What a review saw: the layout, then every scanned sidecar, kept or
|
||||
skipped, with its path, size, modification time and whether it was kept
|
||||
(#362). A LoraManager save that changes unrelated settings leaves it as
|
||||
it was."""
|
||||
layout = discovery.layout
|
||||
digest = hashlib.sha256()
|
||||
for value in (
|
||||
@@ -87,13 +89,14 @@ def fingerprint(discovery: Discovery) -> str:
|
||||
digest.update(f"{value}\n".encode("utf-8", "surrogatepass"))
|
||||
for name in discovery.unreadable:
|
||||
digest.update(f"unreadable\0{name}\n".encode("utf-8", "surrogatepass"))
|
||||
for sidecar in discovery.sidecars:
|
||||
path = sidecar.metadata_path
|
||||
kept = {str(sidecar.metadata_path) for sidecar in discovery.sidecars}
|
||||
for path in discovery.scanned_files:
|
||||
flag = 1 if str(path) in kept else 0
|
||||
try:
|
||||
stat = path.stat()
|
||||
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\n"
|
||||
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\0{flag}\n"
|
||||
except OSError:
|
||||
entry = f"{path}\0missing\n"
|
||||
entry = f"{path}\0missing\0{flag}\n"
|
||||
digest.update(entry.encode("utf-8", "surrogatepass"))
|
||||
return "sha256:" + digest.hexdigest()
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""A per-process cache of LoraManager's layout for the image hot paths (#362).
|
||||
|
||||
Every Library list, prompt detail, inspect, reveal and serve request needs the
|
||||
LoRA allow-list (``ImagesMixin._lora_image_dirs``). Building it means finding
|
||||
the LoraManager install, reading its plugin settings file and its settings
|
||||
file, and resolving every folder. ``LayoutCache`` keeps the last result and
|
||||
reuses it while all of these hold:
|
||||
|
||||
- the key (the configured install path, stripped) and the home folder the
|
||||
folder guard compares against are the same;
|
||||
- the entry is younger than ``MAX_AGE_SECONDS`` (monotonic clock);
|
||||
- the plugin settings file and the settings file have the same signature
|
||||
(``st_mtime_ns``, ``st_size``, or ``MISSING``) as when it was stored;
|
||||
- the install and every stored media root still resolve to themselves (a
|
||||
folder swapped for a symlink reloads, so the folder guard runs again).
|
||||
|
||||
A result is stored only when no watched file is "racy" (modified within
|
||||
``RACY_SECONDS`` of the load, git's rule for racily clean index entries),
|
||||
every watched file could be checked, the settings file was readable, and no
|
||||
``clear()`` ran during the load. What each rule covers: signatures catch a
|
||||
settings change on the next request; the racy rule catches a write during the
|
||||
load, a half-written file and coarse timestamps; the maximum age bounds the
|
||||
inputs no signature sees (``extra_model_paths.yaml``, environment, temporary
|
||||
failures); ``clear()`` makes a finished import's new folders servable at once.
|
||||
|
||||
Hits and loads take no lock: a slow ``os.stat`` on a network share holds up
|
||||
only its own request, and two threads that miss together both load (both
|
||||
loads only read). A short lock guards the compare-and-store and the logging
|
||||
state; no file I/O runs under it. Standard library only, so it is tested
|
||||
without ComfyUI or LoraManager.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from .lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical
|
||||
|
||||
# A watched file modified this recently is not trusted: LoraManager writes its
|
||||
# settings in place, and FAT/exFAT store modification times in 2 s steps.
|
||||
RACY_SECONDS = 3
|
||||
# Bounds what no signature sees (unwatched inputs, temporary failures).
|
||||
MAX_AGE_SECONDS = 30
|
||||
|
||||
# The signature of a watched file that does not exist (a valid state).
|
||||
MISSING = object()
|
||||
# ERROR_BAD_NETPATH and ERROR_BAD_NET_NAME, which Windows raises as
|
||||
# FileNotFoundError.
|
||||
_OFFLINE_SHARE_ERRORS = frozenset({53, 67})
|
||||
|
||||
logger = logging.getLogger("prompt_manager.lora_layout_cache")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Entry:
|
||||
key: str
|
||||
home: str
|
||||
install: Optional[str]
|
||||
layout: LoraManagerLayout
|
||||
signatures: Tuple[Tuple[str, object], ...]
|
||||
stored_at: float
|
||||
|
||||
|
||||
class _CannotValidate(Exception):
|
||||
"""A watched file could not be checked (not merely missing)."""
|
||||
|
||||
|
||||
class LayoutCache:
|
||||
"""At most one ``(install, layout)`` for the active LoraManager config."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolve: Callable[[str], Optional[str]],
|
||||
load: Callable[[Optional[str]], LoraManagerLayout],
|
||||
*,
|
||||
wall_ns: Callable[[], int] = time.time_ns,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
stat=os.stat,
|
||||
pathmod=os.path,
|
||||
home: Callable[[], str] = lambda: str(Path.home()),
|
||||
):
|
||||
self._resolve = resolve
|
||||
self._load = load
|
||||
self._wall_ns = wall_ns
|
||||
self._monotonic = monotonic
|
||||
self._stat = stat
|
||||
self._pathmod = pathmod
|
||||
self._home = home
|
||||
self._entry: Optional[_Entry] = None
|
||||
self._generation = 0
|
||||
self._lock = threading.Lock()
|
||||
# What was last logged; survives clear() so a reload of the same
|
||||
# layout stays quiet.
|
||||
self._logged_layout: Optional[LoraManagerLayout] = None
|
||||
self._logged_refused: Tuple[str, ...] = ()
|
||||
|
||||
def get(self, configured: str) -> Tuple[Optional[str], LoraManagerLayout]:
|
||||
"""``(install, layout)`` for the configured path ("" auto-detects)."""
|
||||
key = (configured or "").strip()
|
||||
home = self._home()
|
||||
entry = self._entry
|
||||
if entry is not None and self._valid(entry, key, home):
|
||||
return entry.install, entry.layout
|
||||
with self._lock:
|
||||
if entry is not None and self._entry is entry:
|
||||
self._entry = None
|
||||
generation = self._generation
|
||||
install = self._resolve(key)
|
||||
layout = self._load(install)
|
||||
self._log_refused(layout)
|
||||
try:
|
||||
signatures = tuple(
|
||||
(path, self._signature(path)) for path in self._watched(install, layout)
|
||||
)
|
||||
except _CannotValidate:
|
||||
return install, layout
|
||||
if layout.error == UNREADABLE or self._racy(signatures, self._wall_ns()):
|
||||
return install, layout
|
||||
stored = _Entry(key, home, install, layout, signatures, self._monotonic())
|
||||
changed = False
|
||||
with self._lock:
|
||||
if self._generation == generation:
|
||||
self._entry = stored
|
||||
changed = layout != self._logged_layout
|
||||
if changed:
|
||||
self._logged_layout = layout
|
||||
if changed:
|
||||
logger.info(
|
||||
f"LoraManager layout changed: {layout.source or 'no'} settings, "
|
||||
f"{layout.mode} storage, {len(layout.media_roots)} media folders"
|
||||
)
|
||||
return install, layout
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop the entry; a load that started before this stores nothing."""
|
||||
with self._lock:
|
||||
self._entry = None
|
||||
self._generation += 1
|
||||
|
||||
def _log_refused(self, layout: LoraManagerLayout) -> None:
|
||||
"""One warning per default LoRA folder newly refused by the guard."""
|
||||
refused = tuple(layout.refused_defaults)
|
||||
with self._lock:
|
||||
if refused == self._logged_refused:
|
||||
return
|
||||
new = [folder for folder in refused if folder not in self._logged_refused]
|
||||
self._logged_refused = refused
|
||||
for folder in new:
|
||||
logger.warning(
|
||||
f"ComfyUI LoRA folder '{folder}' is neither scanned nor served: "
|
||||
"it is a filesystem root, the home folder or a folder that "
|
||||
"contains it"
|
||||
)
|
||||
|
||||
# ── validation ──────────────────────────────────────────────────
|
||||
|
||||
def _valid(self, entry: _Entry, key: str, home: str) -> bool:
|
||||
if entry.key != key or entry.home != home:
|
||||
return False
|
||||
if self._monotonic() - entry.stored_at >= MAX_AGE_SECONDS:
|
||||
return False
|
||||
try:
|
||||
for path, signature in entry.signatures:
|
||||
if self._signature(path) != signature:
|
||||
return False
|
||||
except _CannotValidate:
|
||||
return False
|
||||
folders = entry.layout.media_roots
|
||||
if entry.install is not None:
|
||||
folders = (entry.install,) + tuple(folders)
|
||||
for folder in folders:
|
||||
try:
|
||||
if _canonical(folder, self._pathmod) != folder:
|
||||
return False
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _watched(self, install, layout) -> Tuple[str, ...]:
|
||||
"""The plugin settings file, then the settings file, each once."""
|
||||
if install is None:
|
||||
return ()
|
||||
paths = [self._pathmod.join(install, "settings.json")]
|
||||
if layout.settings_path is not None:
|
||||
paths.append(str(layout.settings_path))
|
||||
watched, seen = [], set()
|
||||
for path in paths:
|
||||
identity = self._pathmod.normcase(self._pathmod.abspath(path))
|
||||
if identity not in seen:
|
||||
seen.add(identity)
|
||||
watched.append(path)
|
||||
return tuple(watched)
|
||||
|
||||
def _signature(self, path: str):
|
||||
try:
|
||||
result = self._stat(path)
|
||||
except (FileNotFoundError, NotADirectoryError) as error:
|
||||
# Windows reports an offline share as "not found": that is a
|
||||
# temporary failure, not a missing file.
|
||||
if getattr(error, "winerror", None) in _OFFLINE_SHARE_ERRORS:
|
||||
raise _CannotValidate(path) from error
|
||||
return MISSING
|
||||
except (OSError, ValueError) as error:
|
||||
raise _CannotValidate(path) from error
|
||||
return (result.st_mtime_ns, result.st_size)
|
||||
|
||||
@staticmethod
|
||||
def _racy(signatures, now_ns: int) -> bool:
|
||||
threshold = now_ns - RACY_SECONDS * 10**9
|
||||
return any(
|
||||
signature is not MISSING and signature[0] > threshold
|
||||
for _path, signature in signatures
|
||||
)
|
||||
+65
-14
@@ -12,8 +12,10 @@ server share one definition:
|
||||
plugin file, or the user config folder, in LoraManager's order), keeps only
|
||||
the seven keys the import needs, and resolves the LoRA roots, the sidecar
|
||||
root (centralized storage), the example-images root and the active
|
||||
LoraManager library. Every folder from the settings passes the folder
|
||||
guard (exists, not a filesystem root, not the home folder itself).
|
||||
LoraManager library. Every folder from the settings, and each of
|
||||
ComfyUI's default LoRA folders, passes the folder guard: it must exist and
|
||||
must not be a filesystem root, the home folder or a folder that contains
|
||||
it. Default folders the guard refuses are listed in ``refused_defaults``.
|
||||
- ``discover_sidecars`` finds every sidecar whose model file exists inside a
|
||||
LoRA root: beside the models in ``alongside`` mode, under the sidecar root
|
||||
(matched to its model by ``file_path``) in ``centralized`` mode.
|
||||
@@ -25,6 +27,7 @@ it is testable outside ComfyUI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
@@ -98,7 +101,12 @@ NOT_OBJECT = "the settings file is not a JSON object"
|
||||
TOO_LARGE = "the settings file is larger than 5 MB"
|
||||
NO_PLATFORMDIRS = "platformdirs is not available"
|
||||
NO_SIDECAR_ROOT = "the sidecar folder does not exist"
|
||||
BAD_SIDECAR_ROOT = "the sidecar folder is a filesystem root or the home folder"
|
||||
BAD_SIDECAR_ROOT = (
|
||||
"the sidecar folder is a filesystem root, the home folder or a folder "
|
||||
"that contains it"
|
||||
)
|
||||
|
||||
logger = logging.getLogger("prompt_manager.lora_manager_layout")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -114,6 +122,9 @@ class LoraManagerLayout:
|
||||
example_images_root: Optional[str]
|
||||
library: str
|
||||
media_roots: Tuple[str, ...]
|
||||
# ComfyUI's default LoRA folders that exist but that the folder guard
|
||||
# refused (canonical): the layout cache logs them (#362).
|
||||
refused_defaults: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -126,13 +137,20 @@ class Sidecar:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discovery:
|
||||
"""The sidecars kept, how many metadata files were seen, and those unread."""
|
||||
"""The sidecars kept, every metadata file seen, and those unread."""
|
||||
|
||||
layout: LoraManagerLayout
|
||||
sidecars: List[Sidecar]
|
||||
scanned: int
|
||||
# Every ``*.metadata.json`` seen, kept or skipped: resolved, once each,
|
||||
# sorted by ``str(path)``. The import's fingerprint covers all of them.
|
||||
scanned_files: Tuple[Path, ...]
|
||||
unreadable: List[str]
|
||||
|
||||
@property
|
||||
def scanned(self) -> int:
|
||||
"""How many metadata files were seen."""
|
||||
return len(self.scanned_files)
|
||||
|
||||
|
||||
class _SettingsError(Exception):
|
||||
"""The settings file cannot be used, for the reason it carries."""
|
||||
@@ -315,6 +333,23 @@ def _guarded(paths, home: str) -> Tuple[str, ...]:
|
||||
return tuple(kept)
|
||||
|
||||
|
||||
def _refused(paths, home: str) -> Tuple[str, ...]:
|
||||
"""The folders in ``paths`` that exist but that the guard refuses."""
|
||||
refused = []
|
||||
for path in paths:
|
||||
absolute = _absolute(path)
|
||||
if not absolute or not os.path.isdir(absolute):
|
||||
continue
|
||||
if _guard_folder(absolute, home) is None:
|
||||
try:
|
||||
canonical = _canonical(absolute)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if canonical not in refused:
|
||||
refused.append(canonical)
|
||||
return tuple(refused)
|
||||
|
||||
|
||||
def _example_images_root(
|
||||
settings: Mapping, library: str, libraries: int, home: str
|
||||
) -> Optional[str]:
|
||||
@@ -351,7 +386,9 @@ def load_layout(
|
||||
default LoRA roots, so serving and trigger words keep working.
|
||||
"""
|
||||
home = str(Path.home())
|
||||
defaults = _guarded(default_roots(), home)
|
||||
default_list = list(default_roots())
|
||||
defaults = _guarded(default_list, home)
|
||||
refused_defaults = _refused(default_list, home)
|
||||
fallback = LoraManagerLayout(
|
||||
settings_path=None,
|
||||
source=None,
|
||||
@@ -362,6 +399,7 @@ def load_layout(
|
||||
example_images_root=None,
|
||||
library="default",
|
||||
media_roots=defaults,
|
||||
refused_defaults=refused_defaults,
|
||||
)
|
||||
if not lora_manager_path:
|
||||
return fallback
|
||||
@@ -377,10 +415,14 @@ def load_layout(
|
||||
except _SettingsError as error:
|
||||
return replace(fallback, error=str(error))
|
||||
settings = _settings_subset(raw or {})
|
||||
return _layout_from_settings(settings, settings_path, source, defaults, home)
|
||||
return _layout_from_settings(
|
||||
settings, settings_path, source, defaults, home, refused_defaults
|
||||
)
|
||||
|
||||
|
||||
def _layout_from_settings(settings, settings_path, source, defaults, home):
|
||||
def _layout_from_settings(
|
||||
settings, settings_path, source, defaults, home, refused_defaults=()
|
||||
):
|
||||
library, entry, libraries = _active_library(settings)
|
||||
lora_roots = _media_roots(
|
||||
defaults,
|
||||
@@ -400,6 +442,7 @@ def _layout_from_settings(settings, settings_path, source, defaults, home):
|
||||
example_images_root=example_root,
|
||||
library=library,
|
||||
media_roots=_media_roots(lora_roots, [example_root]),
|
||||
refused_defaults=refused_defaults,
|
||||
)
|
||||
if mode != MODE_CENTRALIZED:
|
||||
return layout
|
||||
@@ -482,13 +525,13 @@ def discover_sidecars(layout: LoraManagerLayout) -> Discovery:
|
||||
cannot see every folder must not replace the collection with part of it.
|
||||
"""
|
||||
if layout.error is not None:
|
||||
return Discovery(layout, [], 0, [])
|
||||
return Discovery(layout, [], (), [])
|
||||
if layout.mode == MODE_CENTRALIZED:
|
||||
folders = [layout.sidecar_root]
|
||||
else:
|
||||
folders = list(layout.lora_roots)
|
||||
seen = set()
|
||||
scanned = 0
|
||||
scanned: List[Path] = []
|
||||
unreadable: List[str] = []
|
||||
sidecars = []
|
||||
for folder in folders:
|
||||
@@ -498,12 +541,13 @@ def discover_sidecars(layout: LoraManagerLayout) -> Discovery:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
scanned += 1
|
||||
scanned.append(resolved)
|
||||
model = _model_for(resolved, layout, unreadable)
|
||||
if model is not None:
|
||||
sidecars.append(Sidecar(resolved, Path(os.path.realpath(model))))
|
||||
sidecars.sort(key=lambda sidecar: str(sidecar.metadata_path))
|
||||
return Discovery(layout, sidecars, scanned, unreadable)
|
||||
scanned.sort(key=str)
|
||||
return Discovery(layout, sidecars, tuple(scanned), unreadable)
|
||||
|
||||
|
||||
# ── images ──────────────────────────────────────────────────────────
|
||||
@@ -526,8 +570,15 @@ def _example_files(folder: str) -> List[str]:
|
||||
def _preview_candidates(sidecar: Sidecar, metadata: Dict) -> Iterator[str]:
|
||||
"""The paths a preview is looked for at, in LoraManager's order."""
|
||||
url = metadata.get("preview_url")
|
||||
if isinstance(url, str) and url.strip() and os.path.isabs(url.strip()):
|
||||
yield url.strip()
|
||||
if isinstance(url, str) and url.strip():
|
||||
if os.path.isabs(url.strip()):
|
||||
yield url.strip()
|
||||
else:
|
||||
# LoraManager writes absolute paths; it would resolve a relative
|
||||
# one against its working directory, not the sidecar folder.
|
||||
logger.debug(
|
||||
"Relative preview_url skipped in %s", sidecar.metadata_path.name
|
||||
)
|
||||
base = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)]
|
||||
for suffix in PREVIEW_EXTENSIONS:
|
||||
yield str(sidecar.metadata_path.parent / f"{base}{suffix}")
|
||||
|
||||
+40
-6
@@ -30,6 +30,7 @@ except ImportError:
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from .lora_layout_cache import LayoutCache
|
||||
from .lora_manager_layout import (
|
||||
MODE_ALONGSIDE,
|
||||
METADATA_SUFFIX,
|
||||
@@ -295,10 +296,35 @@ def layout_for_install(install: Optional[str]) -> LoraManagerLayout:
|
||||
|
||||
|
||||
def lora_manager_layout(lora_manager_path: Optional[str]) -> LoraManagerLayout:
|
||||
"""Where LoraManager keeps its files (``py/lora_manager_layout.py``)."""
|
||||
"""Where LoraManager keeps its files (``py/lora_manager_layout.py``).
|
||||
|
||||
A fresh read: the import review, Start, the legacy scan and the trigger
|
||||
word cache use it. The image hot paths use ``cached_lora_manager_layout``.
|
||||
"""
|
||||
return layout_for_install(lora_manager_install(lora_manager_path))
|
||||
|
||||
|
||||
# The image hot paths' layout cache (#362). The lambdas look the functions up
|
||||
# when called, so a test that patches them here is seen by the cache.
|
||||
_layout_cache = LayoutCache(
|
||||
lambda path: lora_manager_install(path),
|
||||
lambda install: layout_for_install(install),
|
||||
)
|
||||
|
||||
|
||||
def cached_lora_manager_layout(
|
||||
configured: Optional[str],
|
||||
) -> Tuple[Optional[str], LoraManagerLayout]:
|
||||
"""``(install, layout)`` for the configured path, from the layout cache
|
||||
while LoraManager's settings files are unchanged (``py/lora_layout_cache.py``)."""
|
||||
return _layout_cache.get(configured or "")
|
||||
|
||||
|
||||
def clear_layout_cache() -> None:
|
||||
"""Drop the cached layout (after an import linked files in new folders)."""
|
||||
_layout_cache.clear()
|
||||
|
||||
|
||||
def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]:
|
||||
"""Parse extra_model_paths.yaml for additional LoRA directories."""
|
||||
results = []
|
||||
@@ -342,7 +368,7 @@ def read_lora_metadata(metadata_path: Path) -> Optional[Dict]:
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
except (ValueError, OSError) as e: # JSONDecodeError, UnicodeDecodeError
|
||||
logger.debug(f"Failed to read {metadata_path}: {e}")
|
||||
return None
|
||||
|
||||
@@ -857,11 +883,13 @@ class TriggerWordCache:
|
||||
"""
|
||||
try:
|
||||
new_cache = self._build(lora_manager_path)
|
||||
except Exception as error:
|
||||
except OSError as error:
|
||||
logger.warning(f"Trigger word cache not refreshed: {error}")
|
||||
with self._lock:
|
||||
self._failed_at = time.monotonic()
|
||||
return len(self._cache)
|
||||
return self._failed()
|
||||
except Exception:
|
||||
# Not a disk error: keep the traceback (#362).
|
||||
logger.exception("Trigger word cache not refreshed")
|
||||
return self._failed()
|
||||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
@@ -873,6 +901,12 @@ class TriggerWordCache:
|
||||
)
|
||||
return len(new_cache)
|
||||
|
||||
def _failed(self) -> int:
|
||||
"""Keep the previous map and note when this load failed."""
|
||||
with self._lock:
|
||||
self._failed_at = time.monotonic()
|
||||
return len(self._cache)
|
||||
|
||||
@staticmethod
|
||||
def _build(lora_manager_path: str) -> Dict[str, List[str]]:
|
||||
layout = lora_manager_layout(lora_manager_path)
|
||||
|
||||
@@ -39,6 +39,25 @@ def validate_threshold(name: str, value: Any) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
# Every page shows the thresholds in steps of 0.05 (the concept's System rows,
|
||||
# the Tag image dialogs and the legacy sliders), so a saved one must be on that
|
||||
# grid (#369). Per-request thresholds keep their own checks.
|
||||
THRESHOLD_STEP = 0.05
|
||||
_STEPS_PER_UNIT = round(1 / THRESHOLD_STEP)
|
||||
# A computed float can miss the grid by a hair: (0.1 + 0.2) * 20 is 6.000000000000001.
|
||||
_STEP_TOLERANCE = 1e-12 # in steps; refuses 0.35000000001
|
||||
|
||||
|
||||
def validate_saved_threshold(name: str, value: Any) -> float:
|
||||
"""A saved WD14 threshold: in [0, 1] and a multiple of ``THRESHOLD_STEP``,
|
||||
returned exactly on the grid; else ValueError naming ``name``."""
|
||||
value = validate_threshold(name, value)
|
||||
steps = value * _STEPS_PER_UNIT
|
||||
if abs(steps - round(steps)) > _STEP_TOLERANCE:
|
||||
raise ValueError(f"{name} must be a multiple of {THRESHOLD_STEP}")
|
||||
return round(steps) / _STEPS_PER_UNIT
|
||||
|
||||
|
||||
def validate_template(value: Any, name: str = "instructions") -> str:
|
||||
"""Non-empty language-model instructions within the character and byte
|
||||
caps, or ValueError naming ``name``."""
|
||||
|
||||
@@ -45,6 +45,10 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
from tests.browser.workspace_review_fixture import register_review_commands
|
||||
from tests.browser.workspace_diagnostics_fixture import (
|
||||
register_diagnostics_commands,
|
||||
repoint_logs,
|
||||
)
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -199,6 +203,8 @@ def create_app(root: Path) -> web.Application:
|
||||
directory.mkdir()
|
||||
setattr(folder_paths, f"get_{name}_directory", lambda path=directory: str(path))
|
||||
sys.modules["folder_paths"] = folder_paths
|
||||
# S12: the logger writes into this server's root before any record is written.
|
||||
repoint_logs(root)
|
||||
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
|
||||
@@ -223,6 +229,7 @@ def create_app(root: Path) -> web.Application:
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
register_integrations_commands(app, routes, api, root)
|
||||
reset_logs = register_diagnostics_commands(app, routes, api, root)
|
||||
request_holds = register_request_holds(app, routes)
|
||||
slow_reset_watch.install(app, api)
|
||||
|
||||
@@ -415,6 +422,7 @@ def create_app(root: Path) -> web.Application:
|
||||
# No preview queued by the last test may land in the new fixture.
|
||||
await api._run_in_executor(api.close_auto_thumbnails)
|
||||
library_editor_commands.reset()
|
||||
await api._run_in_executor(reset_logs)
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.mjs';
|
||||
|
||||
// System › Diagnostics & logs (#249 System S12, #329) on the concept-shaped fixture, with the logger writing into this
|
||||
// server's own root (workspace_diagnostics_fixture.py): the meta-grid's live values, Run checks, the report, Inspect and
|
||||
// the test link (D110); the log viewer, statistics, downloads and runtime drafts (D109); and the reviewed rotated-log
|
||||
// cleanup (D100) and Clear all logs, which delete only inside that temporary log folder.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const REPORT_URL = '/prompt_manager/system/diagnostics';
|
||||
const LOGS_URL = '/prompt_manager/logs';
|
||||
const CONFIG_URL = '/prompt_manager/logs/config';
|
||||
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
|
||||
const APPLY_URL = '/prompt_manager/maintenance/apply';
|
||||
const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
|
||||
const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.';
|
||||
const KEY = 'sk-s12-browser-SECRET-0123456789';
|
||||
const LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/integrations/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
await expect(await request.post('/__test__/logs/reset')).toBeOK();
|
||||
page.systemErrors = [];
|
||||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/release');
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/logs/reset');
|
||||
await request.post('/__test__/integrations/reset');
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.systemErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||||
const dialog = page => page.locator('dialog#modal[open]');
|
||||
const surfaceOf = (page, title) => layoutOf(page).locator(':scope > section.surface').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: title, exact: true }) });
|
||||
const cell = (page, term) => surfaceOf(page, 'Diagnostics').locator('.meta-grid > div').filter({
|
||||
has: page.locator(`dt:text-is("${term}")`) }).locator('dd');
|
||||
const lines = page => page.locator('#log-lines > code');
|
||||
const operation = (page, title) => layoutOf(page).locator('.operation').filter({ has: page.locator(`h3:text-is("${title}")`) });
|
||||
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
|
||||
const status = page => page.locator('#settings-status');
|
||||
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
|
||||
|
||||
async function serverJson(request, url) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function seedLogs(request, data = {}) {
|
||||
await expect(await request.post('/__test__/logs/seed', { data })).toBeOK();
|
||||
}
|
||||
|
||||
const names = async request => (await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name);
|
||||
|
||||
async function openDiagnostics(page) {
|
||||
if (page.url().endsWith('#/system/diagnostics')) await page.reload();
|
||||
else await page.goto(`${workspace}#/system/diagnostics`);
|
||||
await expect(layoutOf(page).locator(':scope > section.surface')).toHaveCount(2);
|
||||
await expect(lines(page)).not.toHaveText('Loading…');
|
||||
await expect(cell(page, 'Database')).not.toHaveText('Loading…');
|
||||
// The renderer line settles first: its text can move the rows below.
|
||||
await expect(cell(page, 'Host renderers')).not.toHaveText('Loading…');
|
||||
await expect(page.locator('#log-stats')).not.toHaveText('Loading…');
|
||||
return layoutOf(page);
|
||||
}
|
||||
|
||||
async function openFiles(page) {
|
||||
const details = surfaceOf(page, 'Logs').locator('details');
|
||||
if (!(await details.evaluate(node => node.open))) await details.locator('summary').click();
|
||||
await expect(details.locator(':scope > p.small').first()).not.toHaveText('Loading…');
|
||||
return details;
|
||||
}
|
||||
|
||||
/** The levels of the viewer's lines, in order. */
|
||||
const shownLevels = async page => (await lines(page).textContent()).split('\n').filter(Boolean)
|
||||
.map(line => line.split(/ +/)[1]);
|
||||
|
||||
async function ended(request, id) {
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
|
||||
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
|
||||
}
|
||||
|
||||
/** A second page of this origin that serves a fake ComfyUI window on the send bridge. */
|
||||
async function fakeComfyWindow(context, renderer) {
|
||||
const host = await context.newPage();
|
||||
await host.goto(`${workspace}#/overview`);
|
||||
await host.evaluate(async drawn => {
|
||||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.fakeHost = createBridgeHost({ namespace: '/', origin: location.origin, label: 'PromptManager — ComfyUI',
|
||||
adapter: { listTargets: () => [{ id: '7', label: 'Prompt Manager', type: 'PromptManager', roles: ['positive'],
|
||||
renderer: drawn }], preview() { throw new Error('unused'); }, apply() { throw new Error('unused'); } } });
|
||||
}, renderer);
|
||||
return host;
|
||||
}
|
||||
|
||||
async function readBlob(download) {
|
||||
const chunks = await (await download.createReadStream()).toArray();
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
// ── Task 4: the Diagnostics surface ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('meta-grid shows real database and source status, the pinned Viewer.js version and the host renderers',
|
||||
async ({ page, request, context }) => {
|
||||
const mutations = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||||
await openDiagnostics(page);
|
||||
const report = await serverJson(request, REPORT_URL);
|
||||
const surface = surfaceOf(page, 'Diagnostics');
|
||||
await expect(surface.locator(':scope > .panel-heading > h2')).toHaveText('Diagnostics');
|
||||
await expect(surface.locator(':scope > .panel-heading > button')).toHaveText('Run checks');
|
||||
await expect(surface.locator('.meta-grid dt')).toHaveText(['Database', 'Sources', 'Viewer.js', 'Host renderers']);
|
||||
const images = report.checks.database.image_count;
|
||||
await expect(cell(page, 'Database')).toHaveText(new RegExp(`^Readable · [\\d.]+ (bytes|KB|MB|GB) · ${images.toLocaleString('en-US')} images?$`));
|
||||
// The size is the database's real size (`database_size_bytes`), never a missing key read as 0.
|
||||
expect(report.database_info.database_size_bytes).toBeGreaterThan(0);
|
||||
await expect(cell(page, 'Database')).not.toContainText('· 0 bytes ·');
|
||||
const { configured, watching } = report.sources;
|
||||
await expect(cell(page, 'Sources')).toHaveText(configured
|
||||
? `${configured} source${configured === 1 ? '' : 's'} · ${watching} watching`
|
||||
: `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`);
|
||||
await expect(cell(page, 'Viewer.js')).toHaveText('1.15.2 · pinned vendor');
|
||||
await expect(cell(page, 'Host renderers')).toHaveText('No ComfyUI window is open in this browser, so its renderer is not known.');
|
||||
// A ComfyUI window with a Classic node: the line Integrations shows (D106).
|
||||
const host = await fakeComfyWindow(context, 'classic');
|
||||
await openDiagnostics(page);
|
||||
await expect(cell(page, 'Host renderers')).toHaveText('Detected: Classic (1 ComfyUI window).');
|
||||
await host.close();
|
||||
// A refused read names the running job (D57's naming).
|
||||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) }));
|
||||
await openDiagnostics(page);
|
||||
await expect(cell(page, 'Database')).toHaveText('Busy: Restore is running.');
|
||||
await page.unroute(`**${REPORT_URL}`);
|
||||
await expect(await request.post('/__test__/system/fail', { data: { path: REPORT_URL, times: 1 } })).toBeOK();
|
||||
await openDiagnostics(page);
|
||||
await expect(cell(page, 'Sources')).toHaveText('Could not be read.');
|
||||
expect(mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test('Run checks opens the shared Health report', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
await openDiagnostics(page);
|
||||
const run = surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Run checks', exact: true });
|
||||
await run.click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Health report');
|
||||
await expect(run).toHaveText('Run checks');
|
||||
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(run).toBeFocused();
|
||||
// The same report Overview reads: Overview's metric names it.
|
||||
expect((await serverJson(request, '/prompt_manager/system/health')).report).toBeTruthy();
|
||||
expect((await serverJson(request, '/__test__/system/digest')).rows).toEqual(before.rows);
|
||||
});
|
||||
|
||||
test('report download goes through the api client and holds no secret or absolute path', async ({ page, request }) => {
|
||||
const { revision } = await serverJson(request, '/prompt_manager/settings');
|
||||
await expect(await request.post('/prompt_manager/lora/enable', { data: { enabled: false, trigger_words_enabled: false, path: '',
|
||||
civitai_api_key: KEY, expected_revision: revision } })).toBeOK();
|
||||
await openDiagnostics(page);
|
||||
const reads = [];
|
||||
page.on('request', item => { if (item.url().endsWith(REPORT_URL)) reads.push(item.url()); });
|
||||
const download = page.waitForEvent('download');
|
||||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toMatch(/^prompt-manager-diagnostics-\d{8}-\d{4}\.json$/);
|
||||
await expect(toast(page)).toHaveText('Diagnostic report downloaded.');
|
||||
expect(reads).toHaveLength(1);
|
||||
const text = await readBlob(file);
|
||||
const report = JSON.parse(text);
|
||||
expect(report.host_renderers).toBe('No ComfyUI window is open in this browser, so its renderer is not known.');
|
||||
expect(report.config.integrations.has_civitai_api_key).toBe(true);
|
||||
expect(report.redacted).toBe(0);
|
||||
expect(report.viewerjs).toBe('1.15.2');
|
||||
expect(Object.keys(report.checks)).toEqual(['database', 'dependencies', 'comfyui_output', 'image_monitor']);
|
||||
expect(text).not.toContain(KEY);
|
||||
expect(text).not.toContain('pm-browser-');
|
||||
const strings = [];
|
||||
const walk = value => {
|
||||
if (typeof value === 'string') strings.push(value);
|
||||
else if (value && typeof value === 'object') Object.values(value).forEach(walk);
|
||||
};
|
||||
walk(report);
|
||||
expect(strings.filter(value => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(value))).toEqual([]);
|
||||
// A refused report names the job and saves nothing.
|
||||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'busy', error: 'busy', running: { id: 'a'.repeat(32), title: 'Restore' } }) }));
|
||||
let saved = false;
|
||||
page.on('download', () => { saved = true; });
|
||||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Busy: Restore is running. Try again when it ends.');
|
||||
await page.unroute(`**${REPORT_URL}`);
|
||||
await page.route(`**${REPORT_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'The diagnostic report could not be built.' }) }));
|
||||
await surfaceOf(page, 'Diagnostics').getByRole('button', { name: 'Download diagnostic report', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The diagnostic report could not be downloaded. Nothing was saved to this device.');
|
||||
expect(saved).toBe(false);
|
||||
});
|
||||
|
||||
test('Inspect opens the Inspect a file page and writes nothing', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
const mutations = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||||
await openDiagnostics(page);
|
||||
const row = operation(page, 'Image-link diagnostics');
|
||||
await expect(row.locator('p')).toHaveText('Check a file-to-prompt association without changing it.');
|
||||
await row.getByRole('button', { name: 'Inspect', exact: true }).click();
|
||||
await expect(page).toHaveURL(`${workspace}#/inspect`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Inspect a file');
|
||||
expect(mutations).toEqual([]);
|
||||
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
|
||||
});
|
||||
|
||||
test('Create test link confirms first and names the prompt', async ({ page, request }) => {
|
||||
const [prompt] = (await serverJson(request, '/prompt_manager/recent?limit=1')).results;
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.url().endsWith(TEST_LINK_URL)) posts.push(item.postDataJSON()); });
|
||||
await openDiagnostics(page);
|
||||
const row = operation(page, 'Test image link');
|
||||
await expect(row.locator('p')).toHaveText('Write a placeholder image record to check that linking works.');
|
||||
const create = row.getByRole('button', { name: 'Create test link…', exact: true });
|
||||
await expect(create).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await create.click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Create test link');
|
||||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(new RegExp('^Writes a placeholder image record '
|
||||
+ `\\(prompt_manager_test_link\\.png\\) linked to prompt #${prompt.id} “.+”\\. Maintenance › Missing-file links removes it later\\.$`));
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(posts).toEqual([]);
|
||||
await create.click();
|
||||
await dialog(page).getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(/^Test link created \(image record \d+\)\.$/);
|
||||
expect(posts).toEqual([{ prompt_id: prompt.id }]);
|
||||
// A failed write says nothing was changed.
|
||||
await page.route(`**${TEST_LINK_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'Test link failed' }) }));
|
||||
await create.click();
|
||||
await dialog(page).getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The test link could not be created. Nothing was changed.');
|
||||
// With no prompt to link, the button is unavailable and says why.
|
||||
await page.route('**/prompt_manager/recent?limit=1', route => route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, results: [] }) }));
|
||||
await openDiagnostics(page);
|
||||
await expect(operation(page, 'Test image link').locator('p'))
|
||||
.toHaveText('Write a placeholder image record to check that linking works. Add a prompt first.');
|
||||
await expect(operation(page, 'Test image link').getByRole('button')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
// ── Task 5: the log viewer, statistics and runtime settings ─────────────────────────────────────────────────────────
|
||||
|
||||
test('level filter is minimum-level over all six choices', async ({ page, request }) => {
|
||||
await seedLogs(request);
|
||||
const urls = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); });
|
||||
await openDiagnostics(page);
|
||||
const level = page.locator('#log-level');
|
||||
await expect(level.locator('option')).toHaveText(['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']);
|
||||
await expect(page.locator('label[for="log-level"]')).toHaveText('Level');
|
||||
// All levels: the concept's four lines first, newest first, in its format.
|
||||
await expect(lines(page)).toContainText('09:42:18 INFO Scan completed: 12 enriched, 0 duplicate links\n'
|
||||
+ '09:42:18 WARNING 3 images have ambiguous prompt metadata\n09:39:04 INFO Database backup verified\n'
|
||||
+ '09:38:52 INFO Source monitor started: output');
|
||||
for (const line of (await lines(page).textContent()).split('\n')) expect(line).toMatch(/^\d\d:\d\d:\d\d [A-Z]+ +\S/);
|
||||
expect(urls.at(-1)).toBe('?limit=100');
|
||||
for (const minimum of LEVELS) {
|
||||
await level.selectOption(minimum);
|
||||
await expect.poll(() => urls.at(-1)).toBe(`?limit=100&level=${minimum}`);
|
||||
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
|
||||
const shown = await shownLevels(page);
|
||||
expect(shown.every(name => LEVELS.indexOf(name) >= LEVELS.indexOf(minimum)), `${minimum}: ${shown}`).toBe(true);
|
||||
if (minimum === 'DEBUG') await expect(lines(page)).toContainText('DEBUG Thumbnail cache lookup');
|
||||
if (minimum === 'WARNING') {
|
||||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||||
await expect(lines(page)).toContainText('ERROR A source folder could not be read');
|
||||
}
|
||||
}
|
||||
await level.selectOption('');
|
||||
await expect.poll(() => urls.at(-1)).toBe('?limit=100');
|
||||
});
|
||||
|
||||
test('Lines accepts 1 to 500 and sends nothing when invalid', async ({ page, request }) => {
|
||||
await seedLogs(request, { filler: 30 });
|
||||
const urls = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname === LOGS_URL) urls.push(new URL(item.url()).search); });
|
||||
await openDiagnostics(page);
|
||||
const limit = page.locator('#log-limit');
|
||||
await expect(page.locator('label[for="log-limit"]')).toHaveText('Lines');
|
||||
await expect(limit).toHaveValue('100');
|
||||
expect(await limit.evaluate(node => [node.type, node.min, node.max, node.title]))
|
||||
.toEqual(['number', '1', '500', 'Lines to show: a whole number from 1 to 500.']);
|
||||
await limit.fill('20');
|
||||
await limit.dispatchEvent('change');
|
||||
await expect.poll(() => urls.at(-1)).toBe('?limit=20');
|
||||
await expect.poll(async () => (await lines(page).textContent()).split('\n').length).toBe(20);
|
||||
const sent = urls.length;
|
||||
for (const value of ['0', '501', '2.5', '']) {
|
||||
await limit.fill(value);
|
||||
await limit.dispatchEvent('change');
|
||||
await expect(limit).toHaveAttribute('aria-invalid', 'true');
|
||||
}
|
||||
// A Level change while Lines is invalid sends nothing either.
|
||||
await page.locator('#log-level').selectOption('WARNING');
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect(urls.length).toBe(sent);
|
||||
await limit.fill('500');
|
||||
await limit.dispatchEvent('change');
|
||||
await expect(limit).not.toHaveAttribute('aria-invalid');
|
||||
await expect.poll(() => urls.at(-1)).toBe('?limit=500&level=WARNING');
|
||||
});
|
||||
|
||||
test('logs refresh handles empty, error and stale answers', async ({ page, request }) => {
|
||||
await seedLogs(request);
|
||||
await openDiagnostics(page);
|
||||
const level = page.locator('#log-level');
|
||||
const refresh = surfaceOf(page, 'Logs').getByRole('button', { name: 'Refresh', exact: true });
|
||||
// Empty: nothing at this level once the buffer is cleared.
|
||||
await expect(await request.post('/__test__/logs/reset')).toBeOK();
|
||||
await level.selectOption('CRITICAL');
|
||||
await expect(lines(page)).toHaveText('No log entries at this level.');
|
||||
// Error.
|
||||
await page.route(`**${LOGS_URL}?*`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'boom', logs: [] }) }));
|
||||
await refresh.click();
|
||||
await expect(lines(page)).toHaveText('The log could not be read.');
|
||||
await page.unroute(`**${LOGS_URL}?*`);
|
||||
// Stale: an older answer that arrives after a newer request never paints.
|
||||
await seedLogs(request);
|
||||
await expect(await request.post('/__test__/hold', { data: { path: LOGS_URL } })).toBeOK();
|
||||
await level.selectOption('DEBUG');
|
||||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(1);
|
||||
await level.selectOption('ERROR');
|
||||
await expect.poll(async () => (await serverJson(request, '/__test__/hold')).held[LOGS_URL] ?? 0).toBe(2);
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect(lines(page)).toContainText('CRITICAL The database could not be opened');
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect((await shownLevels(page)).every(name => ['ERROR', 'CRITICAL'].includes(name))).toBe(true);
|
||||
});
|
||||
|
||||
test('log statistics show entries, errors, warnings, files and size', async ({ page, request }) => {
|
||||
await seedLogs(request, { rotated: [2048, 1024], other: true, link: true, active: 512 });
|
||||
await openDiagnostics(page);
|
||||
const stats = page.getByRole('status', { name: 'Log statistics', exact: true });
|
||||
await expect(stats).toHaveAttribute('id', 'log-stats');
|
||||
const text = await stats.textContent();
|
||||
const match = /^([\d,]+) entr(?:y|ies) in memory · (\d+) errors? · (\d+) warnings? · (\d+) files? · (.+) on disk$/.exec(text);
|
||||
expect(match, text).not.toBeNull();
|
||||
const [, entries, errors, warnings, files, size] = match;
|
||||
expect(Number(entries.replace(/,/g, ''))).toBeGreaterThanOrEqual(7);
|
||||
expect(Number(errors)).toBeGreaterThanOrEqual(2);
|
||||
expect(Number(warnings)).toBeGreaterThanOrEqual(1);
|
||||
// The active log, two rotated copies and the .bak; the link is not counted (Rule R).
|
||||
expect(Number(files)).toBe(4);
|
||||
expect(size).toMatch(/^[\d.]+ (bytes|KB|MB)$/);
|
||||
});
|
||||
|
||||
test('Download log saves the active log', async ({ page, request }) => {
|
||||
await seedLogs(request, { active: 300 });
|
||||
await openDiagnostics(page);
|
||||
const download = page.waitForEvent('download');
|
||||
await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click();
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toBe('prompt_manager.log');
|
||||
expect((await readBlob(file)).length).toBeGreaterThanOrEqual(300);
|
||||
await page.route('**/prompt_manager/logs/download/**', route => route.fulfill({ status: 404, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'Log file not found' }) }));
|
||||
await surfaceOf(page, 'Logs').getByRole('button', { name: 'Download log', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The log could not be downloaded.');
|
||||
});
|
||||
|
||||
test('runtime level (including CRITICAL) and console logging save and say they apply until the server restarts',
|
||||
async ({ page, request }) => {
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith(CONFIG_URL)) posts.push(item.postDataJSON()); });
|
||||
await openDiagnostics(page);
|
||||
const level = settingOf(page, 'Runtime log level');
|
||||
await expect(level.locator('p')).toHaveText('How much the server writes to the log. Applied until the server restarts.');
|
||||
await expect(level.locator('option')).toHaveText(['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']);
|
||||
await expect(level.locator('select')).toHaveValue('INFO');
|
||||
const consoleRow = settingOf(page, 'Console logging');
|
||||
await expect(consoleRow.locator('p')).toHaveText('Print Prompt Manager’s warnings and errors in the ComfyUI console '
|
||||
+ '(everything at DEBUG). Applied until the server restarts.');
|
||||
const toggle = consoleRow.locator('input[type="checkbox"]');
|
||||
await expect(toggle).toBeChecked();
|
||||
await level.locator('select').selectOption('CRITICAL');
|
||||
await toggle.uncheck();
|
||||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||||
await save(page).click();
|
||||
await expect(status(page)).toHaveText('Applied until the server restarts.');
|
||||
expect(posts).toEqual([{ level: 'CRITICAL', console_logging: false }]);
|
||||
const { config } = await serverJson(request, CONFIG_URL);
|
||||
expect([config.level, config.console_logging]).toEqual(['CRITICAL', false]);
|
||||
// Read again on a fresh page: the server's values.
|
||||
await openDiagnostics(page);
|
||||
await expect(settingOf(page, 'Runtime log level').locator('select')).toHaveValue('CRITICAL');
|
||||
await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toBeChecked();
|
||||
// Only the changed key is sent.
|
||||
await settingOf(page, 'Runtime log level').locator('select').selectOption('DEBUG');
|
||||
await save(page).click();
|
||||
await expect(status(page)).toHaveText('Applied until the server restarts.');
|
||||
expect(posts.at(-1)).toEqual({ level: 'DEBUG' });
|
||||
});
|
||||
|
||||
test('a refused runtime value is marked invalid', async ({ page }) => {
|
||||
await page.route(`**${CONFIG_URL}`, route => (route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Invalid log level' }) })
|
||||
: route.continue()));
|
||||
await openDiagnostics(page);
|
||||
const select = settingOf(page, 'Runtime log level').locator('select');
|
||||
await select.selectOption('WARNING');
|
||||
await save(page).click();
|
||||
await expect(status(page)).toHaveText('Some changes were not saved. Review the highlighted settings.');
|
||||
await expect(select).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(settingOf(page, 'Console logging').locator('input[type="checkbox"]')).not.toHaveAttribute('aria-invalid');
|
||||
// The draft stays: an edit clears the mark.
|
||||
await expect(select).toHaveValue('WARNING');
|
||||
await select.selectOption('ERROR');
|
||||
await expect(select).not.toHaveAttribute('aria-invalid');
|
||||
});
|
||||
|
||||
test('runtime values that cannot be read stay disabled and say so', async ({ page }) => {
|
||||
await page.route(`**${CONFIG_URL}`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'boom' }) }));
|
||||
await openDiagnostics(page);
|
||||
for (const label of ['Runtime log level', 'Console logging']) {
|
||||
const row = settingOf(page, label);
|
||||
await expect(row.locator('p.small')).toHaveText('The current value could not be read.');
|
||||
await expect(row.locator('select, input[type="checkbox"]')).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Task 6: log files, the reviewed cleanup and Clear all logs ─────────────────────────────────────────────────────
|
||||
|
||||
const LOG_FOLDER = { rotated: [3000, 2048, 1024], other: true, link: true, active: 100 };
|
||||
const LINKS = process.platform === 'win32' ? 0 : 1;
|
||||
|
||||
test('Review log cleanup lists only rotated copies and keeps the rest', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
const before = await names(request);
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await openDiagnostics(page);
|
||||
const details = await openFiles(page);
|
||||
await expect(details.locator(':scope > p.small').first()).toHaveText(/^prompt_manager\.log · [\d.]+ (bytes|KB) · rotated copies: 3$/);
|
||||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review maintenance plan');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||||
expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]);
|
||||
const grid = await dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||||
expect(grid.map(([term]) => term)).toEqual(['Scope', 'Protected', 'Backup', 'Media on disk']);
|
||||
expect(grid[0][1]).toMatch(/^3 rotated log copies · 5\.9 KB$/);
|
||||
expect(grid[1][1]).toBe(['Kept: the active log', ...(LINKS ? ['Kept: 1 link'] : []), 'Kept: 1 other file'].join(' · '));
|
||||
expect(grid[2][1]).toBe('Not needed: no library record changes');
|
||||
expect(grid[3][1]).toBe('Deletes only the listed log files. No image, video or library record changes.');
|
||||
await expect(dialog(page).locator('details > summary')).toHaveText('Listed files (3)');
|
||||
await expect(dialog(page).locator('details pre')).toHaveText(/^prompt_manager\.log\.1 · 2\.9 KB · .+\nprompt_manager\.log\.2 · 2\.0 KB · .+\nprompt_manager\.log\.3 · 1\.0 KB · .+$/);
|
||||
// No D55 backup box: nothing in the library changes.
|
||||
await expect(dialog(page).locator('.notice')).toHaveCount(0);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Back up now' })).toHaveCount(0);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await names(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('Apply deletes exactly the listed files and Activity lists them', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
await openDiagnostics(page);
|
||||
const details = await openFiles(page);
|
||||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const job = await ended(request, page.url().split('/').pop());
|
||||
expect([job.kind, job.state, job.title, job.counters.changed]).toEqual(['maintenance', 'finished', 'Rotated log copies', 3]);
|
||||
await expect(toast(page)).toHaveText(/Rotated log copies (started|finished · 3 changed)/);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('h1')).toHaveText('Rotated log copies');
|
||||
for (const name of ['prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']) await expect(main).toContainText(name);
|
||||
// Only those three are gone: the active log, the user's .bak and the link are kept.
|
||||
expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']);
|
||||
});
|
||||
|
||||
test('a rollover after the review deletes nothing and Activity offers Review again', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
await openDiagnostics(page);
|
||||
const details = await openFiles(page);
|
||||
await details.getByRole('button', { name: 'Review log cleanup', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||||
await expect(await request.post('/__test__/logs/rotate')).toBeOK();
|
||||
const rolled = await serverJson(request, '/__test__/logs/state');
|
||||
await dialog(page).getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
const job = await ended(request, page.url().split('/').pop());
|
||||
expect([job.state, job.scope.failure]).toEqual(['failed', 'plan-changed']);
|
||||
await expect(page.locator('#workspace-main')).toContainText(CHANGED);
|
||||
expect((await serverJson(request, '/__test__/logs/state')).entries.map(entry => entry.name))
|
||||
.toEqual(rolled.entries.map(entry => entry.name));
|
||||
const previews = [];
|
||||
page.on('request', item => { if (item.url().endsWith(PREVIEW_URL)) previews.push(item.postDataJSON()); });
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review again', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Rotated log copies');
|
||||
expect(previews).toEqual([{ operation: 'rotated_logs', options: {} }]);
|
||||
});
|
||||
|
||||
test('Clear all logs states both counts, confirms first and reads everything again', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
const truncates = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/prompt_manager/logs/truncate')) truncates.push(item.url()); });
|
||||
await openDiagnostics(page);
|
||||
await expect(lines(page)).toContainText('Database backup verified');
|
||||
const details = await openFiles(page);
|
||||
const clearRow = operation(page, 'Clear all logs');
|
||||
await expect(clearRow.locator('p')).toHaveText('Empty the active log and delete its rotated copies.');
|
||||
const clear = clearRow.getByRole('button', { name: 'Clear all logs…', exact: true });
|
||||
await expect(clear).toHaveClass(/\bdanger\b/);
|
||||
await clear.click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Clear all logs');
|
||||
await expect(dialog(page).locator('#modal-body > p')).toHaveText('Empties prompt_manager.log, deletes 3 rotated copies (5.9 KB) '
|
||||
+ 'and clears the lines shown here. The other files in the log folder are kept. This cannot be undone.');
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(truncates).toEqual([]);
|
||||
await clear.click();
|
||||
await dialog(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Logs cleared · 3 rotated copies deleted.');
|
||||
expect(truncates).toHaveLength(1);
|
||||
// Read again: the viewer, the file line and the list.
|
||||
await expect(lines(page)).not.toContainText('Database backup verified');
|
||||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 0$/);
|
||||
const kept = ['prompt_manager.log', 'prompt_manager.log.bak', ...(LINKS ? ['prompt_manager.log.4'] : [])];
|
||||
// The server lists newest first: compare as a set.
|
||||
await expect.poll(async () => (await details.locator('.operation-list h3').allTextContents()).sort()).toEqual([...kept].sort());
|
||||
expect(await names(request)).toEqual(['prompt_manager.log', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak']);
|
||||
});
|
||||
|
||||
test('Clear all logs reads the files again before it states what it deletes', async ({ page, request }) => {
|
||||
await seedLogs(request, { rotated: [1024], active: 100 });
|
||||
await openDiagnostics(page);
|
||||
const details = await openFiles(page);
|
||||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 1$/);
|
||||
// The log rolls over while the page stays open: Clear names what it deletes now, not the count shown.
|
||||
await seedLogs(request, { records: false, rotated: [1024, 1024, 1024] });
|
||||
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes 3 rotated copies \(3\.0 KB\) /);
|
||||
await expect(details.locator(':scope > p.small').first()).toHaveText(/rotated copies: 3$/);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
// A list that cannot be read gives the general sentence, never an old count.
|
||||
await page.route('**/prompt_manager/logs/files', route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, error: 'boom', files: [] }) }));
|
||||
await operation(page, 'Clear all logs').getByRole('button', { name: 'Clear all logs…', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-body > p')).toHaveText(/^Empties prompt_manager\.log, deletes its rotated copies and clears/);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
expect(await names(request)).toEqual(['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3']);
|
||||
});
|
||||
|
||||
test('each listed file downloads by its returned name and a link has no Download', async ({ page, request }) => {
|
||||
await seedLogs(request, LOG_FOLDER);
|
||||
await openDiagnostics(page);
|
||||
const details = await openFiles(page);
|
||||
await expect(details.locator(':scope > p.small').nth(1))
|
||||
.toHaveText('Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries');
|
||||
const list = details.locator('.operation-list');
|
||||
expect((await list.locator('h3').allTextContents()).sort()).toEqual(['prompt_manager.log', 'prompt_manager.log.1',
|
||||
'prompt_manager.log.2', 'prompt_manager.log.3', ...(LINKS ? ['prompt_manager.log.4'] : []), 'prompt_manager.log.bak'].sort());
|
||||
const item = name => list.locator('.operation').filter({ has: page.locator(`h3:text-is("${name}")`) });
|
||||
await expect(item('prompt_manager.log.1').locator('p')).toHaveText(/^2\.9 KB · \d{4}-\d\d-\d\d \d\d:\d\d · Rotated copy$/);
|
||||
await expect(item('prompt_manager.log.bak').locator('p')).toHaveText(/^0 bytes · .+ · Other file$/);
|
||||
if (LINKS) {
|
||||
await expect(item('prompt_manager.log.4').locator('p')).toHaveText(/ · Link, not downloaded$/);
|
||||
await expect(item('prompt_manager.log.4').getByRole('button')).toHaveCount(0);
|
||||
}
|
||||
for (const name of ['prompt_manager.log.1', 'prompt_manager.log.bak']) {
|
||||
const download = page.waitForEvent('download');
|
||||
await item(name).getByRole('button', { name: `Download ${name}`, exact: true }).click();
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toBe(name);
|
||||
if (name === 'prompt_manager.log.1') expect((await readBlob(file)).length).toBe(3000);
|
||||
}
|
||||
});
|
||||
@@ -429,7 +429,9 @@ test('Rebuild all is explicit and the plan line says every preview is regenerate
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
expect(seen.starts).toEqual([{ mode: 'rebuild' }]);
|
||||
const id = page.url().split('/').pop();
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||||
// Rebuild regenerates all 24 previews with real image work: on a busy CI
|
||||
// runner that took over 5 s (12 of 24 done at the default poll budget).
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state, { timeout: 30000 }).toBe('finished');
|
||||
});
|
||||
|
||||
test('a plan that cannot be read says so; Start still starts', async ({ page }) => {
|
||||
|
||||
@@ -188,6 +188,35 @@ test('a refused value shows the server\'s message', async ({ page, request }) =>
|
||||
await expect(actionOf(page, 'Preview tagging review')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('a value off the 0.05 step is refused and never reaches the Tag image dialog (#369)', async ({ page, request }) => {
|
||||
const before = (await serverJson(request, SETTINGS_URL)).settings;
|
||||
const STEP = 'General-tag confidence must be a multiple of 0.05.';
|
||||
await selectThenOpen(page, FIRST);
|
||||
await page.locator('#threshold-general').fill('0.37');
|
||||
await page.locator('#threshold-general').press('Tab');
|
||||
// Preview tagging review explains instead of opening a dialog whose Generate the browser would block.
|
||||
await expect(actionOf(page, 'Preview tagging review')).toHaveAttribute('aria-disabled', 'true');
|
||||
await actionOf(page, 'Preview tagging review').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText(STEP);
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
// The server refuses the save with the same sentence and writes nothing.
|
||||
await save(page).click();
|
||||
await expect(status(page)).toHaveText(STEP);
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_general_threshold).toBe(before.autotag_general_threshold);
|
||||
// A value on the step opens the dialog, and its Generate passes the browser's own check and sends.
|
||||
await page.locator('#threshold-general').fill('0.4');
|
||||
await page.locator('#threshold-general').press('Tab');
|
||||
await expect(actionOf(page, 'Preview tagging review')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await actionOf(page, 'Preview tagging review').click();
|
||||
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.4');
|
||||
expect(await tagDialog(page).locator('#image-general-threshold').evaluate(input => input.checkValidity())).toBe(true);
|
||||
const suggested = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
const answer = await suggested;
|
||||
expect(answer.request().postDataJSON().options.general_threshold).toBe(0.4);
|
||||
});
|
||||
|
||||
test('Download starts the job and Activity shows it without Cancel', async ({ page, request }) => {
|
||||
await unavailable(request, ['wd14-vit']);
|
||||
await fakeDownload(request, 'hold');
|
||||
|
||||
@@ -412,15 +412,16 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
|
||||
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
|
||||
await expect.poll(held).toBe(2);
|
||||
// Diagnostics still hands off (S12), so its body is one known panel.
|
||||
// Diagnostics & logs is native (S12): its body is its two surfaces.
|
||||
await nav.getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||||
await expect(layout.locator(':scope > section.surface')).toHaveCount(2);
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect.poll(held).toBe(0);
|
||||
// Both late answers reached a section that had left: Diagnostics keeps its own body.
|
||||
await expect(layout.locator(':scope > section.handoff')).toBeVisible();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(layout.locator('.summary-metrics')).toHaveCount(0);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => child.className))).toEqual(['surface handoff', 'save-row']);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual(['section.surface', 'section.surface', 'div.save-row']);
|
||||
// Back on Overview, one fresh read paints one set of metrics.
|
||||
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
|
||||
await expect(layout.locator('.summary-metrics')).toHaveCount(1);
|
||||
|
||||
@@ -18,7 +18,7 @@ const routes = [
|
||||
['tags', 'Tags & categories', null],
|
||||
// #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page.
|
||||
['inspect', 'Inspect a file', null],
|
||||
// #249 System S2: native System; its undelivered sections hand off per section (D94, asserted below).
|
||||
// #249 System S2: native System; every section native since S12 (D94 retired, asserted below).
|
||||
['system', 'System', null],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
@@ -395,7 +395,7 @@ const SURFACE = {
|
||||
intro: { fontSize: '12px', marginTop: '7px' },
|
||||
};
|
||||
|
||||
for (const width of [1600, 390]) test(`overview and handoff render with concept surfaces ${width}`, async ({ page }) => {
|
||||
for (const width of [1600, 390]) test(`overview renders with concept surfaces and no System section hands off ${width}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const main = page.locator('#workspace-main');
|
||||
@@ -412,23 +412,23 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
// System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94).
|
||||
// Every System section is native (S12 retired D94): the last one, Diagnostics & logs, shows concept surfaces.
|
||||
await main.locator('.section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
await expect(handoff.locator('a[href="/prompt_manager/admin"]')).toBeVisible();
|
||||
const logs = main.locator('.settings-layout > section.surface').last();
|
||||
await expect(logs.locator(':scope > h2')).toHaveText('Logs');
|
||||
expect(await logs.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
await expect(main.locator('.handoff')).toHaveCount(0);
|
||||
await expect(main.locator('.panel')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/<section>`, every section a D94
|
||||
// hand-off until its PR, and the Save row's truthful status (D92).
|
||||
// #249 System S2 Task 5: the concept's renderSystem shell (app.js 698–706) at `#/system/<section>`, every section native
|
||||
// (S12 retired the D94 hand-off), and the Save row's truthful status (D92).
|
||||
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
|
||||
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
|
||||
test('System is native: the concept shell, section navigation, sections and the Save row', async ({ page }) => {
|
||||
const mutations = [];
|
||||
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) mutations.push(item.url()); });
|
||||
await page.goto(`${workspace}#/system`);
|
||||
@@ -466,7 +466,8 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser), Integrations (S10
|
||||
// Task 27, workspace-system-integrations.spec.mjs), Maintenance (S7 Task 19,
|
||||
// workspace-system-maintenance.spec.mjs), Backup & export (S9 Task 24, workspace-system-backups.spec.mjs) and
|
||||
// Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs); every other section hands off.
|
||||
// Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs) and Diagnostics & logs (S12,
|
||||
// workspace-system-diagnostics.spec.mjs): none hands off.
|
||||
if (hash === 'tagging') await expect(layout.locator('#tag-model')).toBeVisible();
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
|
||||
@@ -476,13 +477,9 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
integrations: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
|
||||
backups: ['div.two-col', 'section.surface', 'div.save-row'],
|
||||
tagging: ['section.surface', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups'].includes(hash)) continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
.toEqual(['/prompt_manager/admin']);
|
||||
tagging: ['section.surface', 'div.save-row'],
|
||||
diagnostics: ['section.surface', 'section.surface', 'div.save-row'] }[hash]);
|
||||
await expect(layout.locator('.handoff')).toHaveCount(0);
|
||||
await expect(layout.locator('#settings-status')).toHaveText('No unsaved changes.');
|
||||
await expect(page.locator('aside.sidebar a[href="#/system"]')).toHaveAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""System › Diagnostics & logs test hooks for the browser tests (#249 System S12).
|
||||
|
||||
The logger writes into ``<server root>/logs`` (``repoint_logs``, called by
|
||||
``create_app`` before any record is written), so no test reads or deletes a
|
||||
real log folder. Every route refuses a log folder outside the server root.
|
||||
|
||||
- ``POST /__test__/logs/reset`` restores the default logging configuration,
|
||||
empties the active log, removes every other entry of the log folder (a
|
||||
link is removed, never followed) and clears the in-memory buffer. The
|
||||
global ``/__test__/reset`` calls it too;
|
||||
- ``POST /__test__/logs/seed`` ``{records, filler, rotated, active, link,
|
||||
other}`` writes buffer records with fixed times (``records``: the concept's
|
||||
four lines, then one DEBUG, ERROR and CRITICAL record, older; ``filler``
|
||||
more INFO records older still), ``active`` bytes into the active log,
|
||||
rotated copies ``prompt_manager.log.<n>`` of the listed sizes (fixed
|
||||
times), on POSIX a symlink ``prompt_manager.log.<n+1>`` to a file outside
|
||||
the folder (``link``) and a user's ``prompt_manager.log.bak`` (``other``);
|
||||
- ``POST /__test__/logs/rotate`` rolls the copies over as the handler does
|
||||
(``.2`` → ``.3``, ``.1`` → ``.2``, a new ``.1``), so a reviewed plan no
|
||||
longer matches;
|
||||
- ``GET /__test__/logs/state`` answers the folder's entries (name, size, link)
|
||||
and the buffer's length.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
ACTIVE = "prompt_manager.log"
|
||||
SEED_DAY = datetime.datetime(2026, 10, 26)
|
||||
# The concept's four lines (app.js 695), oldest first: the viewer shows them
|
||||
# newest first.
|
||||
CONCEPT_RECORDS = (
|
||||
("09:38:52", logging.INFO, "Source monitor started: output"),
|
||||
("09:39:04", logging.INFO, "Database backup verified"),
|
||||
("09:42:18", logging.WARNING, "3 images have ambiguous prompt metadata"),
|
||||
("09:42:18", logging.INFO, "Scan completed: 12 enriched, 0 duplicate links"),
|
||||
)
|
||||
# Older than the concept's lines, so they come after them: the levels the
|
||||
# concept does not show.
|
||||
LEVEL_RECORDS = (
|
||||
("09:30:01", logging.DEBUG, "Thumbnail cache lookup"),
|
||||
("09:30:02", logging.ERROR, "A source folder could not be read"),
|
||||
("09:30:03", logging.CRITICAL, "The database could not be opened"),
|
||||
)
|
||||
ROTATED_TIME = datetime.datetime(2026, 10, 25, 14, 2)
|
||||
MAX_SEED_BYTES = 64 * 1024 * 1024
|
||||
DEFAULT_CONFIG = {
|
||||
"level": "INFO",
|
||||
"max_file_size": 10 * 1024 * 1024,
|
||||
"backup_count": 5,
|
||||
"console_logging": True,
|
||||
"file_logging": True,
|
||||
"buffer_size": 1000,
|
||||
}
|
||||
|
||||
|
||||
def _manager():
|
||||
from utils.logging_config import get_logger_manager
|
||||
|
||||
return get_logger_manager()
|
||||
|
||||
|
||||
def repoint_logs(root: Path) -> Path:
|
||||
"""Point the logger at ``<root>/logs`` before anything is logged.
|
||||
|
||||
The logger is built while ``logs_dir`` answers that folder, so its first
|
||||
handler never opens a log file anywhere else (a node folder with a legacy
|
||||
database would otherwise name a real log folder).
|
||||
"""
|
||||
from utils import library_location
|
||||
|
||||
folder = Path(root) / "logs"
|
||||
folder.mkdir(exist_ok=True)
|
||||
original = library_location.logs_dir
|
||||
library_location.logs_dir = lambda **_kwargs: str(folder)
|
||||
try:
|
||||
manager = _manager()
|
||||
finally:
|
||||
library_location.logs_dir = original
|
||||
with manager._files_lock:
|
||||
manager.log_dir = folder
|
||||
manager._setup_loggers()
|
||||
return folder
|
||||
|
||||
|
||||
def _require_inside(root: Path, folder: Path) -> None:
|
||||
resolved = Path(os.path.realpath(folder))
|
||||
if Path(os.path.realpath(root)) not in resolved.parents:
|
||||
raise web.HTTPForbidden(text="The log folder is outside the test server root")
|
||||
|
||||
|
||||
def _record(time_text, level, message, index):
|
||||
record = logging.LogRecord(
|
||||
"prompt_manager.test", level, __file__, index, message, None, None
|
||||
)
|
||||
hours, minutes, seconds = (int(part) for part in time_text.split(":"))
|
||||
moment = SEED_DAY.replace(hour=hours, minute=minutes, second=seconds)
|
||||
# Equal times keep their order: each later record is a microsecond newer.
|
||||
record.created = moment.timestamp() + index / 1e6
|
||||
return record
|
||||
|
||||
|
||||
def _touch(path: Path, size: int, moment: datetime.datetime) -> None:
|
||||
# A link at this name is replaced, never written through.
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"x" * size)
|
||||
stamp = moment.timestamp()
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def _bounded(value, name, top=MAX_SEED_BYTES):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= top:
|
||||
raise web.HTTPBadRequest(text=f"{name} must be an integer 0..{top}")
|
||||
return value
|
||||
|
||||
|
||||
def register_diagnostics_commands(app, routes, api, root: Path):
|
||||
"""Register the hooks; returns ``reset()`` for the global reset."""
|
||||
|
||||
def folder() -> Path:
|
||||
path = Path(_manager().log_dir)
|
||||
_require_inside(root, path)
|
||||
return path
|
||||
|
||||
def reset() -> None:
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
if manager.get_config() != DEFAULT_CONFIG:
|
||||
manager.update_config(dict(DEFAULT_CONFIG))
|
||||
with manager._files_lock:
|
||||
held = manager._acquire_file_handlers()
|
||||
try:
|
||||
with os.scandir(log_dir) as entries:
|
||||
found = list(entries)
|
||||
for entry in found:
|
||||
path = Path(entry.path)
|
||||
if entry.name == ACTIVE and entry.is_file(follow_symlinks=False):
|
||||
manager._truncate_main_log(path)
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.unlink(path)
|
||||
finally:
|
||||
manager._release_handlers(held)
|
||||
with manager._buffer_lock:
|
||||
manager._log_buffer.clear()
|
||||
|
||||
def seed(data) -> None:
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
with_records = data.get("records", True) is not False
|
||||
filler = _bounded(data.get("filler", 0), "filler", 900)
|
||||
older = [
|
||||
(f"08:{index // 60:02}:{index % 60:02}", logging.INFO, f"Filler {index}")
|
||||
for index in range(filler)
|
||||
]
|
||||
named = list(LEVEL_RECORDS) + list(CONCEPT_RECORDS) if with_records else []
|
||||
ordered = older + named
|
||||
for index, (time_text, level, message) in enumerate(ordered):
|
||||
record = _record(time_text, level, message, index)
|
||||
manager.add_to_buffer(record, record.getMessage())
|
||||
active = _bounded(data.get("active", 0), "active")
|
||||
if active:
|
||||
with open(log_dir / ACTIVE, "ab") as handle:
|
||||
handle.write(b"x" * active)
|
||||
rotated = data.get("rotated", [])
|
||||
if not isinstance(rotated, list) or len(rotated) > 20:
|
||||
raise web.HTTPBadRequest(text="rotated must be a list of sizes")
|
||||
for number, size in enumerate(rotated, start=1):
|
||||
moment = ROTATED_TIME - datetime.timedelta(hours=number)
|
||||
_touch(log_dir / f"{ACTIVE}.{number}", _bounded(size, "size"), moment)
|
||||
if data.get("link") and os.name != "nt":
|
||||
target = Path(root) / "outside-log-target.txt"
|
||||
target.write_bytes(b"not a log of this folder")
|
||||
os.symlink(target, log_dir / f"{ACTIVE}.{len(rotated) + 1}")
|
||||
if data.get("other"):
|
||||
_touch(log_dir / f"{ACTIVE}.bak", 0, ROTATED_TIME)
|
||||
|
||||
def rotate() -> None:
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
with manager._files_lock:
|
||||
held = manager._acquire_file_handlers()
|
||||
try:
|
||||
for number in (2, 1):
|
||||
source = log_dir / f"{ACTIVE}.{number}"
|
||||
if source.is_file() and not source.is_symlink():
|
||||
os.replace(source, log_dir / f"{ACTIVE}.{number + 1}")
|
||||
_touch(log_dir / f"{ACTIVE}.1", 4096, datetime.datetime.now())
|
||||
finally:
|
||||
manager._release_handlers(held)
|
||||
|
||||
def state():
|
||||
manager = _manager()
|
||||
log_dir = folder()
|
||||
entries = []
|
||||
with os.scandir(log_dir) as listing:
|
||||
found = sorted(listing, key=lambda item: item.name)
|
||||
for entry in found:
|
||||
info = entry.stat(follow_symlinks=False)
|
||||
entries.append(
|
||||
{"name": entry.name, "size": info.st_size, "link": entry.is_symlink()}
|
||||
)
|
||||
with manager._buffer_lock:
|
||||
buffered = len(manager._log_buffer)
|
||||
return {"entries": entries, "buffer": buffered, "config": manager.get_config()}
|
||||
|
||||
async def body(request):
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
return data
|
||||
|
||||
@routes.post("/__test__/logs/reset")
|
||||
async def logs_reset(request: web.Request) -> web.Response:
|
||||
await api._run_in_executor(reset)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/logs/seed")
|
||||
async def logs_seed(request: web.Request) -> web.Response:
|
||||
data = await body(request)
|
||||
await api._run_in_executor(seed, data)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/logs/rotate")
|
||||
async def logs_rotate(request: web.Request) -> web.Response:
|
||||
await api._run_in_executor(rotate)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/logs/state")
|
||||
async def logs_state(request: web.Request) -> web.Response:
|
||||
return web.json_response(await api._run_in_executor(state))
|
||||
|
||||
return reset
|
||||
@@ -292,7 +292,8 @@ async def image_monitor(request: web.Request) -> web.Response:
|
||||
config.MONITORING_ENABLED = True
|
||||
monitor.start_monitoring([folder_paths.get_output_directory()])
|
||||
else:
|
||||
# Joins the image worker and the observer (up to 5 s each): off the loop.
|
||||
# Joins the image worker and the observer (one shared 5 s deadline),
|
||||
# so it runs on a worker thread, not on the event loop.
|
||||
await asyncio.to_thread(monitor.stop_monitoring)
|
||||
config.MONITORING_ENABLED = False
|
||||
return web.json_response({"monitoring": monitor.is_monitoring})
|
||||
|
||||
@@ -23,7 +23,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy
|
||||
assert.equal(routeTitle(route), route.label);
|
||||
}
|
||||
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
||||
// System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route.
|
||||
// System is native from S2 (#249 System), and every section of it from S12 (D94 retired), not the route.
|
||||
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'review', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// System › Diagnostics & logs (#249 System S12, #329): the concept's two surfaces (app.js 694–695) with live values: the
|
||||
// meta-grid texts, the report's name and merge (D110), the log viewer's lines, Lines validation, statistics, retention
|
||||
// and file rows, and the Clear all logs copy (D109).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const diagnostics = import('../../web/js/workspace/system-diagnostics.mjs');
|
||||
|
||||
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
|
||||
const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-diagnostics.mjs'), 'utf8');
|
||||
const SYSTEM = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system.mjs'), 'utf8');
|
||||
|
||||
const REPORT = {
|
||||
checks: { database: { status: 'ok', prompt_count: 4210, has_images_table: true, image_count: 19956 } },
|
||||
database_info: { database_path: 'user/__promptmanager/prompts.db', database_size_bytes: 1717986918 },
|
||||
sources: { configured: 2, effective: 2, watching: 2, unavailable: 0 },
|
||||
viewerjs: '1.15.2',
|
||||
};
|
||||
|
||||
test('the section keeps the concept surfaces, terms, rows and labels; no sample copy ships', () => {
|
||||
const start = CONCEPT.indexOf('case "diagnostics":');
|
||||
const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
|
||||
const terms = [...body.matchAll(/<dt>([^<]+)<\/dt>/g)].map(([, term]) => term);
|
||||
assert.deepEqual(terms, ['Database', 'Sources', 'Viewer.js', 'Host renderers']);
|
||||
for (const text of ["'Diagnostics'", "'Logs'", "'Run checks'", "'Refresh'", "'Level'", "'Log files & retention'",
|
||||
"'Review log cleanup'", "'Runtime log level'", "'Console logging'", "'Image-link diagnostics'"]) {
|
||||
assert.ok(SOURCE.includes(text), text);
|
||||
}
|
||||
for (const id of ['runtime-level', 'console-log']) assert.ok(body.includes(`setting("${id}"`) && SOURCE.includes(`id: '${id}'`), id);
|
||||
assert.ok(SOURCE.includes("'toolbar spaced-16'") && body.includes('class="toolbar spaced-16"'));
|
||||
// Prototype copy is never shipped.
|
||||
for (const sample of ['sample', 'Sample']) assert.ok(!SOURCE.includes(`'${sample}`) && !/Download sample|Inspect sample/.test(SOURCE), sample);
|
||||
});
|
||||
|
||||
test('D94 is retired: Diagnostics & logs is a delivered section and nothing hands off', () => {
|
||||
assert.ok(SYSTEM.includes('diagnostics: mountDiagnostics'));
|
||||
assert.ok(!SYSTEM.includes('SECTION_HANDOFFS'));
|
||||
assert.ok(!SYSTEM.includes('handoff('));
|
||||
assert.ok(!SYSTEM.includes('NOT_DELIVERED'));
|
||||
});
|
||||
|
||||
test('database text covers readable, not readable, busy and loading', async () => {
|
||||
const { databaseText, LOADING, UNREADABLE } = await diagnostics;
|
||||
assert.equal(databaseText({ report: REPORT }), 'Readable · 1.6 GB · 19,956 images');
|
||||
assert.equal(databaseText({ report: { ...REPORT, checks: { database: { status: 'ok', image_count: 1 } },
|
||||
database_info: { database_size_bytes: 2048 } } }), 'Readable · 2.0 KB · 1 image');
|
||||
assert.equal(databaseText({ report: { checks: { database: { status: 'error', message: 'Database error: locked' } } } }),
|
||||
'Not readable: Database error: locked');
|
||||
assert.equal(databaseText({ loading: true }), LOADING);
|
||||
assert.equal(LOADING, 'Loading…');
|
||||
assert.equal(databaseText({ error: { code: 'busy', running: { id: 'j1', title: 'Restore' } } }), 'Busy: Restore is running.');
|
||||
assert.equal(databaseText({ error: { code: 'busy' } }), 'Busy: another job is running.');
|
||||
assert.equal(databaseText({ error: new Error('offline') }), UNREADABLE);
|
||||
assert.equal(UNREADABLE, 'Could not be read.');
|
||||
});
|
||||
|
||||
test('sources text follows the precedence table', async () => {
|
||||
const { sourcesText } = await diagnostics;
|
||||
const of = sources => sourcesText({ report: { sources } });
|
||||
assert.equal(of({ configured: 0, effective: 1, watching: 1, unavailable: 0 }), 'ComfyUI output folder · watching');
|
||||
assert.equal(of({ configured: 0, effective: 1, watching: 0, unavailable: 0 }), 'ComfyUI output folder · manual scan');
|
||||
// Unavailable wins over watching.
|
||||
assert.equal(of({ configured: 3, effective: 2, watching: 2, unavailable: 1 }), '3 sources · 1 not available');
|
||||
assert.equal(of({ configured: 2, effective: 2, watching: 2, unavailable: 0 }), '2 sources · 2 watching');
|
||||
assert.equal(of({ configured: 1, effective: 1, watching: 0, unavailable: 0 }), '1 source · 0 watching');
|
||||
assert.equal(of(undefined), 'ComfyUI output folder · manual scan');
|
||||
});
|
||||
|
||||
test('Viewer.js reads the pinned vendor version', async () => {
|
||||
const { viewerText } = await diagnostics;
|
||||
assert.equal(viewerText({ report: REPORT }), '1.15.2 · pinned vendor');
|
||||
assert.equal(viewerText({ report: {} }), 'unknown · pinned vendor');
|
||||
});
|
||||
|
||||
test('report filename uses local time', async () => {
|
||||
const { reportFilename } = await diagnostics;
|
||||
assert.equal(reportFilename(new Date(2026, 9, 6, 7, 5)), 'prompt-manager-diagnostics-20261006-0705.json');
|
||||
assert.equal(reportFilename(new Date(2026, 11, 31, 23, 59)), 'prompt-manager-diagnostics-20261231-2359.json');
|
||||
});
|
||||
|
||||
test('merged report adds only host_renderers', async () => {
|
||||
const { mergeReport } = await diagnostics;
|
||||
const merged = mergeReport(REPORT, 'Detected: Classic (1 ComfyUI window).');
|
||||
assert.deepEqual(Object.keys(merged).sort(), [...Object.keys(REPORT), 'host_renderers'].sort());
|
||||
assert.equal(merged.host_renderers, 'Detected: Classic (1 ComfyUI window).');
|
||||
assert.equal(mergeReport(REPORT, null).host_renderers, null);
|
||||
assert.equal(mergeReport(REPORT, undefined).host_renderers, null);
|
||||
// The server's fields are not changed.
|
||||
assert.equal(merged.sources, REPORT.sources);
|
||||
});
|
||||
|
||||
test('sizes read as the server labels them (1 KB = 1024 bytes)', async () => {
|
||||
const { sizeText } = await diagnostics;
|
||||
assert.equal(sizeText(0), '0 bytes');
|
||||
assert.equal(sizeText(1), '1 byte');
|
||||
assert.equal(sizeText(1000), '1,000 bytes');
|
||||
assert.equal(sizeText(2048), '2.0 KB');
|
||||
assert.equal(sizeText(10 * 2 ** 20), '10.0 MB');
|
||||
assert.equal(sizeText(1717986918), '1.6 GB');
|
||||
assert.equal(sizeText(undefined), '0 bytes');
|
||||
});
|
||||
|
||||
test("log line uses the concept's format", async () => {
|
||||
const { logLine } = await diagnostics;
|
||||
assert.equal(logLine({ timestamp: '2026-10-26T09:42:18.123456', level: 'INFO', message: 'Database backup verified' }),
|
||||
'09:42:18 INFO Database backup verified');
|
||||
assert.equal(logLine({ timestamp: '2026-10-26T09:42:18', level: 'WARNING', message: '3 images have ambiguous prompt metadata' }),
|
||||
'09:42:18 WARNING 3 images have ambiguous prompt metadata');
|
||||
assert.match(logLine({ timestamp: '2026-10-26T09:42:18', level: 'ERROR', message: 'x' }), /^\d\d:\d\d:\d\d [A-Z]+ +\S/);
|
||||
// The concept's sample lines read the same way.
|
||||
assert.ok(CONCEPT.includes('09:42:18 INFO Scan completed'));
|
||||
});
|
||||
|
||||
test('Lines accepts whole numbers 1 to 500 only', async () => {
|
||||
const { validLimit, logsUrl } = await diagnostics;
|
||||
for (const [value, expected] of [['1', 1], ['500', 500], [' 20 ', 20], ['100', 100]]) assert.equal(validLimit(value), expected, value);
|
||||
for (const value of ['0', '501', '2.5', '', ' ', '-1', '1e2', 'abc', undefined]) assert.equal(validLimit(value), null, String(value));
|
||||
assert.equal(logsUrl('', 20), '/prompt_manager/logs?limit=20');
|
||||
assert.equal(logsUrl('WARNING', 20), '/prompt_manager/logs?limit=20&level=WARNING');
|
||||
});
|
||||
|
||||
test('the level filter offers all six minimum levels; the runtime level adds CRITICAL after the concept four', async () => {
|
||||
const { LEVEL_OPTIONS, RUNTIME_LEVELS } = await diagnostics;
|
||||
assert.deepEqual(LEVEL_OPTIONS.map(([, label]) => label), ['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']);
|
||||
assert.equal(LEVEL_OPTIONS[0][0], '');
|
||||
assert.deepEqual(RUNTIME_LEVELS, ['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']);
|
||||
assert.ok(CONCEPT.includes('["INFO", "WARNING", "DEBUG", "ERROR"]'));
|
||||
});
|
||||
|
||||
test('statistics text uses singular forms for one', async () => {
|
||||
const { statsText } = await diagnostics;
|
||||
assert.equal(statsText({ buffer_count: 1000, level_counts: { INFO: 980, WARNING: 12, ERROR: 2, CRITICAL: 1 },
|
||||
log_files_count: 5, total_log_size: 43201024 }), '1,000 entries in memory · 3 errors · 12 warnings · 5 files · 41.2 MB on disk');
|
||||
assert.equal(statsText({ buffer_count: 1, level_counts: { WARNING: 1, CRITICAL: 1 }, log_files_count: 1, total_log_size: 1 }),
|
||||
'1 entry in memory · 1 error · 1 warning · 1 file · 1 byte on disk');
|
||||
assert.equal(statsText({}), '0 entries in memory · 0 errors · 0 warnings · 0 files · 0 bytes on disk');
|
||||
});
|
||||
|
||||
test('retention, file line and file rows read the server values', async () => {
|
||||
const { retentionText, fileLine, fileDetail } = await diagnostics;
|
||||
assert.equal(retentionText({ max_file_size: 10485760, backup_count: 5, file_logging: true, buffer_size: 1000 }),
|
||||
'Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries');
|
||||
assert.equal(retentionText({ max_file_size: 1572864, backup_count: 1, file_logging: false, buffer_size: 1 }),
|
||||
'Rotates at 1.5 MB · keeps 1 rotated copy · file logging off · memory buffer 1 entry');
|
||||
const files = [
|
||||
{ filename: 'prompt_manager.log', kind: 'active', size: 1468006, modified: '2026-10-26T09:42:18' },
|
||||
{ filename: 'prompt_manager.log.1', kind: 'rotated', size: 10485760, modified: '2026-10-25T14:02:00' },
|
||||
{ filename: 'prompt_manager.log.4', kind: 'link', size: 20, modified: '2026-10-25T14:02:00' },
|
||||
{ filename: 'prompt_manager.log.bak', kind: 'other', size: 0, modified: '2026-10-24T08:00:00' },
|
||||
];
|
||||
assert.equal(fileLine(files), 'prompt_manager.log · 1.4 MB · rotated copies: 1');
|
||||
assert.equal(fileLine([]), 'prompt_manager.log · not written yet · rotated copies: 0');
|
||||
assert.equal(fileDetail(files[1]), '10.0 MB · 2026-10-25 14:02 · Rotated copy');
|
||||
assert.equal(fileDetail(files[2]), '20 bytes · 2026-10-25 14:02 · Link, not downloaded');
|
||||
assert.equal(fileDetail(files[3]), '0 bytes · 2026-10-24 08:00 · Other file');
|
||||
assert.equal(fileDetail(files[0]), '1.4 MB · 2026-10-26 09:42 · Active log');
|
||||
});
|
||||
|
||||
test('Clear all logs states both counts and its toast says what was done', async () => {
|
||||
const { clearSentence, clearToast } = await diagnostics;
|
||||
const rotated = size => ({ kind: 'rotated', size });
|
||||
const files = [{ kind: 'active', size: 10 }, rotated(10 * 2 ** 20), rotated(10 * 2 ** 20), rotated(8.4 * 2 ** 20), { kind: 'other', size: 5 }];
|
||||
assert.equal(clearSentence(files), 'Empties prompt_manager.log, deletes 3 rotated copies (28.4 MB) and clears the lines shown '
|
||||
+ 'here. The other files in the log folder are kept. This cannot be undone.');
|
||||
assert.equal(clearSentence([{ kind: 'active', size: 1 }]), 'Empties prompt_manager.log and clears the lines shown here. '
|
||||
+ 'The other files in the log folder are kept. This cannot be undone.');
|
||||
assert.match(clearSentence(null), /^Empties prompt_manager.log, deletes its rotated copies and clears/);
|
||||
assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2', 'prompt_manager.log.3'],
|
||||
errors: [] }), 'Logs cleared · 3 rotated copies deleted.');
|
||||
assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1', 'prompt_manager.log.2'],
|
||||
errors: ['Failed to truncate prompt_manager.log.3: in use'] }), 'Logs cleared · 2 rotated copies deleted · 1 could not be deleted.');
|
||||
assert.equal(clearToast({ truncated: ['prompt_manager.log', 'prompt_manager.log.1'], errors: [] }), 'Logs cleared · 1 rotated copy deleted.');
|
||||
assert.equal(clearToast(undefined), 'Logs cleared · 0 rotated copies deleted.');
|
||||
});
|
||||
|
||||
test('the test-link dialog names the prompt and how Missing-file links removes the record', async () => {
|
||||
const { testLinkSentence, promptName } = await diagnostics;
|
||||
assert.equal(testLinkSentence({ id: 812, text: 'Misty harbor' }), 'Writes a placeholder image record '
|
||||
+ '(prompt_manager_test_link.png) linked to prompt #812 “Misty harbor”. Maintenance › Missing-file links removes it later.');
|
||||
assert.equal(promptName({ text: ' a\n b ' }), 'a b');
|
||||
assert.equal(promptName({ text: 'x'.repeat(80) }).length, 60);
|
||||
});
|
||||
|
||||
test('runtime values and body map config.level to runtime_level and back', async () => {
|
||||
const { runtimeValues, runtimeBody } = await diagnostics;
|
||||
assert.deepEqual(runtimeValues({ level: 'INFO', console_logging: true, max_file_size: 1 }), { runtime_level: 'INFO', console_logging: true });
|
||||
assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL' }), { level: 'CRITICAL' });
|
||||
assert.deepEqual(runtimeBody({ console_logging: false }), { console_logging: false });
|
||||
assert.deepEqual(runtimeBody({ runtime_level: 'DEBUG', console_logging: true }), { level: 'DEBUG', console_logging: true });
|
||||
assert.deepEqual(runtimeBody({}), {});
|
||||
});
|
||||
@@ -169,3 +169,44 @@ test('a plan with nothing to do offers no Apply (S7 live test)', async () => {
|
||||
assert.equal(hasWork({ ...PLAN, scope: {} }), true);
|
||||
assert.equal(NOTHING_TO_APPLY, 'There is nothing to apply; nothing was changed.');
|
||||
});
|
||||
|
||||
// S12 (#329): the rotated-log cleanup plan changes no library record: its Backup reads "Not needed", the review shows no
|
||||
// D55 backup box, and its details are files.
|
||||
const LOG_PLAN = { operation: 'rotated_logs', token: 't', expires_at: '2026-10-26T23:15:00',
|
||||
scope: { count: 3, label: '3 rotated log copies · 28.4 MB' },
|
||||
protected: { count: 3, reasons: ['Kept: the active log', 'Kept: 1 link', 'Kept: 1 other file'] },
|
||||
backup: { policy: 'none', destination_label: null, expected_bytes: null },
|
||||
media: { deletes_files: true, label: 'Deletes only the listed log files. No image, video or library record changes.' },
|
||||
details: [{ label: 'prompt_manager.log.1', detail: '10.0 MB · 2026-10-25 14:02' }] };
|
||||
|
||||
test('a plan without a database change reads Not needed and shows no backup box', async () => {
|
||||
const { planGrid, backupText, backupBox, BACKUP_COPY, OPERATION_TITLES } = await review;
|
||||
assert.equal(backupText({ policy: 'none' }), 'Not needed: no library record changes');
|
||||
assert.deepEqual(planGrid(LOG_PLAN), [
|
||||
['Scope', '3 rotated log copies · 28.4 MB'],
|
||||
['Protected', 'Kept: the active log · Kept: 1 link · Kept: 1 other file'],
|
||||
['Backup', 'Not needed: no library record changes'],
|
||||
['Media on disk', 'Deletes only the listed log files. No image, video or library record changes.'],
|
||||
]);
|
||||
const onBackup = () => {};
|
||||
assert.equal(backupBox(LOG_PLAN, onBackup), null);
|
||||
// Every database plan keeps its D55 box.
|
||||
assert.deepEqual(backupBox({ ...LOG_PLAN, operation: 'optimize', backup: { policy: 'required' } }, onBackup), { copy: BACKUP_COPY, onBackup });
|
||||
assert.deepEqual(backupBox({ operation: 'missing_links' }, onBackup), { copy: BACKUP_COPY, onBackup });
|
||||
// Its title heads the dialog and lets Activity offer Review again.
|
||||
assert.equal(OPERATION_TITLES.rotated_logs, 'Rotated log copies');
|
||||
});
|
||||
|
||||
test('rotated_logs details read Listed files', async () => {
|
||||
const { detailsSummary, detailLines } = await review;
|
||||
assert.equal(detailsSummary(LOG_PLAN, 3), 'Listed files (3)');
|
||||
assert.equal(detailsSummary({ ...LOG_PLAN, scope: { count: 60 } }, 50), 'Listed files (50 of 60)');
|
||||
assert.equal(detailsSummary({ operation: 'missing_links', scope: { count: 2 } }, 2), 'Listed records (2)');
|
||||
assert.deepEqual(detailLines(LOG_PLAN), ['prompt_manager.log.1 · 10.0 MB · 2026-10-25 14:02']);
|
||||
});
|
||||
|
||||
test('a rotated-log cleanup the log overtook offers Review again (S12)', async () => {
|
||||
const { reviewAgainOf } = await jobView;
|
||||
assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }),
|
||||
{ operation: 'rotated_logs' });
|
||||
});
|
||||
|
||||
@@ -420,3 +420,12 @@ test('a partial host save keeps what saved, adopts its revision and fails only t
|
||||
assert.equal((await drafts.save()).host, 'failed');
|
||||
assert.equal(drafts.statusText(), SETTINGS_STATUS.partial);
|
||||
});
|
||||
|
||||
// S12 (#329): System's runtime domain is the running server's log level and console setting (`/logs/config`).
|
||||
test('runtime adapter maps config.level to runtime_level and back', async () => {
|
||||
const { runtimeValues, runtimeBody } = await import('../../web/js/workspace/system-diagnostics.mjs');
|
||||
assert.deepEqual(runtimeValues({ level: 'WARNING', console_logging: false, backup_count: 5 }),
|
||||
{ runtime_level: 'WARNING', console_logging: false });
|
||||
assert.deepEqual(runtimeBody({ runtime_level: 'WARNING' }), { level: 'WARNING' });
|
||||
assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL', console_logging: true }), { level: 'CRITICAL', console_logging: true });
|
||||
});
|
||||
|
||||
@@ -307,6 +307,16 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 1.5 }),
|
||||
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be between 0 and 1.' });
|
||||
assert.equal(draftProblem({ ...HOST, autotag_character_threshold: '' }).key, 'autotag_character_threshold');
|
||||
// #369: every page shows the thresholds in steps of 0.05, so a value off that grid cannot be saved or previewed.
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 0.37 }),
|
||||
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be a multiple of 0.05.' });
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_character_threshold: 0.33 }),
|
||||
{ key: 'autotag_character_threshold', message: 'Character-tag confidence must be a multiple of 0.05.' });
|
||||
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0.15, autotag_character_threshold: 0.95 }), null);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0, autotag_character_threshold: 1 }), null);
|
||||
// A computed value a hair off the grid passes and is handed on snapped to it, as the server stores it.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }), null);
|
||||
assert.equal(taggingPreset({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }).options.general_threshold, 0.3);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 2.5 }).key, 'autotag_suggestion_limit');
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 0 }, { limit: false }), null);
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: ' ' }),
|
||||
@@ -342,6 +352,11 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, ['General-tag confidence must be between 0 and 1.']);
|
||||
assert.equal(calls.opened.length, 1);
|
||||
byId('threshold-general').value = '0.37'; await byId('threshold-general').dispatch('change');
|
||||
assert.equal(button('Preview tagging review').getAttribute('aria-disabled'), 'true');
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
assert.deepEqual(calls.toasts.at(-1), 'General-tag confidence must be a multiple of 0.05.');
|
||||
assert.equal(calls.opened.length, 1);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
@@ -361,6 +376,8 @@ test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', as
|
||||
assert.equal(existingLabel('odd'), 'odd');
|
||||
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
// #369: a threshold a hair off the grid is sent snapped to it.
|
||||
assert.equal(batchBody({ ...HOST, autotag_character_threshold: 0.1 + 0.2 }).options.character_threshold, 0.3);
|
||||
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } });
|
||||
assert.equal(batchCloseNote(PLAN), null);
|
||||
assert.equal(batchCloseNote({ ...PLAN, review_required: true }), REPLACE_NOTE);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the core API module (py/api/__init__.py): helpers and base routes."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -165,8 +166,19 @@ class TestGzipMiddleware(AioHTTPTestCase):
|
||||
await resp.write_eof()
|
||||
return resp
|
||||
|
||||
async def declared_length(request):
|
||||
# The log download (#329) declares its length before the middleware
|
||||
# compresses it.
|
||||
body = b"x" * 3000
|
||||
return web.Response(
|
||||
body=body,
|
||||
content_type="text/plain",
|
||||
headers={"Content-Length": str(len(body))},
|
||||
)
|
||||
|
||||
app = web.Application(middlewares=[api_module._gzip_middleware])
|
||||
app.router.add_get("/prompt_manager/big", big_json)
|
||||
app.router.add_get("/prompt_manager/declared", declared_length)
|
||||
app.router.add_get("/prompt_manager/small", small_json)
|
||||
app.router.add_get("/prompt_manager/encoded", already_encoded)
|
||||
app.router.add_get("/prompt_manager/binary", binary)
|
||||
@@ -193,6 +205,18 @@ class TestGzipMiddleware(AioHTTPTestCase):
|
||||
|
||||
self.assertIn(b'"data"', gzip.decompress(await resp.read()))
|
||||
|
||||
async def test_compressed_body_declares_its_own_length(self):
|
||||
# A stale Content-Length (the uncompressed size) makes a browser wait
|
||||
# for bytes that never come (#329: Download log hung on any log > 1 KB).
|
||||
resp = await self._get("/prompt_manager/declared")
|
||||
self.assertEqual(resp.headers.get("Content-Encoding"), "gzip")
|
||||
# Bounded: a stale length would otherwise wait for the missing bytes.
|
||||
body = await asyncio.wait_for(resp.read(), timeout=10)
|
||||
self.assertEqual(int(resp.headers["Content-Length"]), len(body))
|
||||
import gzip
|
||||
|
||||
self.assertEqual(gzip.decompress(body), b"x" * 3000)
|
||||
|
||||
async def test_small_body_not_compressed(self):
|
||||
resp = await self._get("/prompt_manager/small")
|
||||
self.assertIsNone(resp.headers.get("Content-Encoding"))
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""The shareable diagnostic report (#329, py/api/diagnostics_report.py).
|
||||
|
||||
``GET /prompt_manager/system/diagnostics`` builds the report on the server
|
||||
from an explicit list of fields: the project version, the four legacy checks,
|
||||
public paths only and no secret. A scrub replaces anything that still looks
|
||||
like an absolute path or holds the CivitAI key and counts it in ``redacted``;
|
||||
these tests require ``redacted == 0`` so a builder mistake fails instead of
|
||||
being hidden.
|
||||
"""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api import admin, diagnostics_report # noqa: E402
|
||||
from py.api.diagnostics_report import OUTSIDE, report_path, scrub # noqa: E402
|
||||
from py.config import (
|
||||
GalleryConfig,
|
||||
IntegrationConfig,
|
||||
PromptManagerConfig,
|
||||
) # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.test_api_log_cleanup import LogCleanupTestCase # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_log_cleanup import LogCleanupTestCase # noqa: E402
|
||||
|
||||
REPORT = "/prompt_manager/system/diagnostics"
|
||||
KEY = "civitai-secret-0123456789abcdef"
|
||||
SEEDED_PATHS = (
|
||||
"/home/someone/private/LoraManager",
|
||||
"/srv/someone/renders",
|
||||
"C:\\Users\\someone\\Documents\\renders",
|
||||
"D:/Users/someone/prompts.db",
|
||||
"\\\\nas\\share\\someone\\renders",
|
||||
)
|
||||
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
TOP_KEYS = {
|
||||
"success",
|
||||
"generated_at",
|
||||
"version",
|
||||
"python",
|
||||
"platform",
|
||||
"viewerjs",
|
||||
"checks",
|
||||
"database_info",
|
||||
"sources",
|
||||
"logging",
|
||||
"health",
|
||||
"config",
|
||||
"redacted",
|
||||
}
|
||||
CONFIG = {
|
||||
"database": {"enable_duplicate_detection", "enable_auto_save"},
|
||||
"web_ui": {
|
||||
"result_timeout",
|
||||
"show_test_button",
|
||||
"webui_display_mode",
|
||||
"infinite_scroll",
|
||||
},
|
||||
"performance": {
|
||||
"max_search_results",
|
||||
"enable_fuzzy_search",
|
||||
"auto_backup_interval",
|
||||
"worker_threads",
|
||||
},
|
||||
"gallery": {
|
||||
"processing_delay",
|
||||
"prompt_timeout",
|
||||
"cleanup_interval",
|
||||
"auto_cleanup_missing_files",
|
||||
"max_image_age_days",
|
||||
"max_concurrent_processing",
|
||||
"metadata_extraction_timeout",
|
||||
},
|
||||
"integrations": {"lora_manager_enabled", "has_civitai_api_key"},
|
||||
}
|
||||
|
||||
|
||||
def _strings(value):
|
||||
if isinstance(value, str):
|
||||
yield value
|
||||
elif isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
yield str(key)
|
||||
yield from _strings(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
yield from _strings(item)
|
||||
|
||||
|
||||
class DiagnosticsReportTestCase(LogCleanupTestCase):
|
||||
"""The admin app with its logger manager on a temporary log folder."""
|
||||
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
saved = IntegrationConfig.get_config()
|
||||
self.addCleanup(IntegrationConfig.update_config, saved)
|
||||
return app
|
||||
|
||||
async def report(self, status=200):
|
||||
response = await self.client.request("GET", REPORT)
|
||||
text = await response.text()
|
||||
self.assertEqual(response.status, status, text)
|
||||
return json.loads(text), text
|
||||
|
||||
def seed_secrets(self):
|
||||
IntegrationConfig.update_config(
|
||||
{
|
||||
"lora_manager": {
|
||||
"enabled": True,
|
||||
"path": SEEDED_PATHS[0],
|
||||
"civitai_api_key": KEY,
|
||||
}
|
||||
}
|
||||
)
|
||||
GalleryConfig.MONITORING_DIRECTORIES = list(SEEDED_PATHS[1:3]) + [
|
||||
SEEDED_PATHS[4]
|
||||
]
|
||||
PromptManagerConfig.update_config(
|
||||
{"database": {"default_path": SEEDED_PATHS[3]}}
|
||||
)
|
||||
|
||||
def assert_no_paths_or_key(self, report, text):
|
||||
self.assertNotIn(KEY, text)
|
||||
self.assertNotIn(self.tmpdir, text)
|
||||
self.assertNotIn(self.tmpdir.replace("\\", "/"), text)
|
||||
for seeded in SEEDED_PATHS:
|
||||
self.assertNotIn(seeded, text)
|
||||
self.assertNotIn(json.dumps(seeded)[1:-1], text)
|
||||
self.assertNotIn("someone", text)
|
||||
for value in _strings(report):
|
||||
self.assertFalse(posixpath.isabs(value), value)
|
||||
self.assertFalse(ntpath.isabs(value), value)
|
||||
self.assertIsNone(_WINDOWS_ABSOLUTE.match(value), value)
|
||||
self.assertFalse(value.startswith("\\\\"), value)
|
||||
|
||||
|
||||
class TestReport(DiagnosticsReportTestCase):
|
||||
async def test_report_has_version_viewerjs_and_the_four_checks(self):
|
||||
report, _text = await self.report()
|
||||
self.assertEqual(set(report), TOP_KEYS)
|
||||
self.assertTrue(report["success"])
|
||||
with open(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "pyproject.toml"),
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
version = re.search(r'version\s*=\s*["\']([^"\']+)["\']', handle.read())
|
||||
self.assertEqual(report["version"], version.group(1))
|
||||
self.assertRegex(report["viewerjs"], r"^\d+\.\d+\.\d+$")
|
||||
self.assertRegex(report["python"], r"^3\.\d+\.\d+")
|
||||
self.assertEqual(set(report["platform"]), {"system", "release"})
|
||||
self.assertEqual(
|
||||
set(report["checks"]),
|
||||
{"database", "dependencies", "comfyui_output", "image_monitor"},
|
||||
)
|
||||
self.assertEqual(report["checks"]["database"]["status"], "ok")
|
||||
self.assertEqual(
|
||||
set(report["sources"]),
|
||||
{"configured", "effective", "watching", "unavailable"},
|
||||
)
|
||||
self.assertEqual(set(report["logging"]), {"config", "stats"})
|
||||
self.assertIsNone(report["health"])
|
||||
self.assertEqual(report["redacted"], 0)
|
||||
self.assertRegex(report["generated_at"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d$")
|
||||
|
||||
async def test_report_has_no_secret_or_absolute_path(self):
|
||||
self.seed_secrets()
|
||||
with mock.patch.object(
|
||||
admin.sqlite3,
|
||||
"connect",
|
||||
side_effect=sqlite3.DatabaseError("file is not a database"),
|
||||
):
|
||||
report, text = await self.report()
|
||||
self.assertEqual(report["checks"]["database"]["status"], "error")
|
||||
self.assertTrue(report["config"]["integrations"]["has_civitai_api_key"])
|
||||
self.assertTrue(report["config"]["integrations"]["lora_manager_enabled"])
|
||||
self.assertEqual(report["sources"]["configured"], 3)
|
||||
self.assert_no_paths_or_key(report, text)
|
||||
self.assertEqual(report["redacted"], 0)
|
||||
|
||||
async def test_report_leaves_get_config_unchanged(self):
|
||||
self.seed_secrets()
|
||||
before = json.dumps(PromptManagerConfig.get_config(), sort_keys=True)
|
||||
directories = GalleryConfig.MONITORING_DIRECTORIES
|
||||
await self.report()
|
||||
self.assertEqual(
|
||||
json.dumps(PromptManagerConfig.get_config(), sort_keys=True), before
|
||||
)
|
||||
self.assertIs(GalleryConfig.MONITORING_DIRECTORIES, directories)
|
||||
self.assertEqual(IntegrationConfig.CIVITAI_API_KEY, KEY)
|
||||
|
||||
def test_outside_path_reads_outside_comfyui(self):
|
||||
inside = os.path.join(str(self.comfy_dir), "user", "__promptmanager", "logs")
|
||||
self.assertEqual(report_path(inside), "user/__promptmanager/logs")
|
||||
self.assertEqual(
|
||||
report_path(os.path.join(str(self.outside_dir), "vito")), "outside ComfyUI"
|
||||
)
|
||||
self.assertEqual(report_path(""), "")
|
||||
self.assertEqual(report_path(None), "")
|
||||
|
||||
def test_a_filesystem_root_anchor_never_makes_a_path_public(self):
|
||||
# --output-directory /renders makes "/" an anchor; a path outside
|
||||
# ComfyUI must still read "outside ComfyUI", never "home/<user>/...".
|
||||
root = os.path.abspath(os.sep)
|
||||
anchors = [str(self.comfy_dir), root]
|
||||
with mock.patch.object(GalleryConfig, "path_anchors", return_value=anchors):
|
||||
self.assertEqual(
|
||||
report_path(os.path.join(str(self.outside_dir), "x")), OUTSIDE
|
||||
)
|
||||
self.assertEqual(
|
||||
report_path(os.path.join(str(self.comfy_dir), "output")), "output"
|
||||
)
|
||||
|
||||
def test_scrub_removes_the_home_folder_without_its_root(self):
|
||||
home = os.path.expanduser("~")
|
||||
relative = os.path.splitdrive(home)[1].lstrip("\\/").replace("\\", "/")
|
||||
cleaned = scrub({"a": f"{relative}/area/prompts.db", "redacted": 0}, None)
|
||||
self.assertEqual(cleaned, {"a": "[removed]", "redacted": 1})
|
||||
|
||||
def test_the_home_folder_is_matched_as_a_whole_path_part(self):
|
||||
with mock.patch.object(
|
||||
diagnostics_report.os.path, "expanduser", return_value="/root"
|
||||
):
|
||||
cleaned = scrub(
|
||||
{
|
||||
"a": "output/rootfiles",
|
||||
"b": "output/root/x",
|
||||
"c": "see /root/x",
|
||||
"redacted": 0,
|
||||
},
|
||||
None,
|
||||
)
|
||||
self.assertEqual(cleaned["a"], "output/rootfiles")
|
||||
# The home folder's name is a whole path part here: removed.
|
||||
self.assertEqual(cleaned["b"], "[removed]")
|
||||
self.assertEqual(cleaned["c"], "[removed]")
|
||||
self.assertEqual(cleaned["redacted"], 2)
|
||||
|
||||
async def test_report_reads_only_the_temporary_log_folder(self):
|
||||
self.write("prompt_manager.log.1", b"x" * 1234)
|
||||
report, _text = await self.report()
|
||||
stats = report["logging"]["stats"]
|
||||
self.assertEqual(stats["log_files_count"], 2)
|
||||
self.assertEqual(
|
||||
stats["total_log_size"],
|
||||
1234 + os.path.getsize(self.path("prompt_manager.log")),
|
||||
)
|
||||
|
||||
def test_an_ancestor_of_home_is_never_an_anchor_on_linux(self):
|
||||
anchored = diagnostics_report.anchored_path
|
||||
home = "/home/someone"
|
||||
anchors = ["/home", "/opt/ComfyUI"]
|
||||
self.assertEqual(
|
||||
anchored("/home/someone/renders/a.png", anchors, home, posixpath), OUTSIDE
|
||||
)
|
||||
self.assertEqual(
|
||||
anchored("/home/other/renders", anchors, home, posixpath), OUTSIDE
|
||||
)
|
||||
self.assertEqual(
|
||||
anchored("/opt/ComfyUI/output", anchors, home, posixpath), "output"
|
||||
)
|
||||
|
||||
def test_an_ancestor_of_home_is_never_an_anchor_on_windows(self):
|
||||
anchored = diagnostics_report.anchored_path
|
||||
home = ntpath.normcase("C:\\Users\\someone")
|
||||
anchors = [ntpath.normcase(a) for a in ("C:\\Users", "C:\\", "D:\\ComfyUI")]
|
||||
path = ntpath.normcase("C:\\Users\\someone\\renders\\a.png")
|
||||
self.assertEqual(anchored(path, anchors, home, ntpath), OUTSIDE)
|
||||
inside = ntpath.normcase("D:\\ComfyUI\\output\\x")
|
||||
self.assertEqual(anchored(inside, anchors, home, ntpath), "output/x")
|
||||
|
||||
def test_an_anchor_that_is_home_names_no_user(self):
|
||||
anchored = diagnostics_report.anchored_path
|
||||
home = "/home/someone"
|
||||
self.assertEqual(
|
||||
anchored("/home/someone/ComfyUI/output", [home], home, posixpath),
|
||||
"ComfyUI/output",
|
||||
)
|
||||
self.assertEqual(
|
||||
anchored(
|
||||
"/home/someone/ComfyUI/output",
|
||||
[home, "/home/someone/ComfyUI"],
|
||||
home,
|
||||
posixpath,
|
||||
),
|
||||
"output",
|
||||
)
|
||||
|
||||
def test_the_home_folder_name_is_removed_only_from_paths(self):
|
||||
with mock.patch.object(
|
||||
diagnostics_report.os.path, "expanduser", return_value="/home/info"
|
||||
):
|
||||
cleaned = scrub({"level": "INFO", "p": "logs/info/x", "redacted": 0}, None)
|
||||
self.assertEqual(cleaned, {"level": "INFO", "p": "[removed]", "redacted": 1})
|
||||
|
||||
def test_scrub_removes_the_home_folder_name(self):
|
||||
with mock.patch.object(
|
||||
diagnostics_report.os.path, "expanduser", return_value="/home/someone"
|
||||
):
|
||||
cleaned = scrub(
|
||||
{"a": "someone/renders", "b": "someones", "redacted": 0}, None
|
||||
)
|
||||
self.assertEqual(cleaned["a"], "[removed]")
|
||||
self.assertEqual(cleaned["b"], "someones")
|
||||
|
||||
def test_scrub_removes_a_leaked_path_and_counts_it(self):
|
||||
leaked = {
|
||||
"a": "/home/someone/x",
|
||||
"b": ["C:\\Users\\someone", "D:/data", "\\\\nas\\share", "ok"],
|
||||
"c": {"d": f"token {KEY} here", "e": 3, "f": None, "g": "relative/path"},
|
||||
"redacted": 0,
|
||||
}
|
||||
cleaned = scrub(leaked, KEY)
|
||||
self.assertEqual(cleaned["redacted"], 5)
|
||||
self.assertEqual(cleaned["a"], "[removed]")
|
||||
self.assertEqual(cleaned["b"], ["[removed]"] * 3 + ["ok"])
|
||||
self.assertEqual(
|
||||
cleaned["c"], {"d": "[removed]", "e": 3, "f": None, "g": "relative/path"}
|
||||
)
|
||||
# A short key is not searched for (it would match ordinary words).
|
||||
self.assertEqual(scrub({"x": "abc", "redacted": 0}, "abc")["redacted"], 0)
|
||||
|
||||
async def test_report_refused_while_exclusive_job_runs(self):
|
||||
async with self.api._jobs.hold(
|
||||
"restore", "Database restore", admission="exclusive"
|
||||
) as restore:
|
||||
report, _text = await self.report(status=409)
|
||||
self.assertEqual(report["code"], "busy")
|
||||
self.assertEqual(
|
||||
report["running"], {"id": restore.id, "title": "Database restore"}
|
||||
)
|
||||
|
||||
async def test_report_lists_config_only_keys(self):
|
||||
self.seed_secrets()
|
||||
report, _text = await self.report()
|
||||
self.assertEqual({k: set(v) for k, v in report["config"].items()}, CONFIG)
|
||||
self.assertNotIn("default_path", json.dumps(report["config"]))
|
||||
self.assertNotIn("directories", json.dumps(report["config"]))
|
||||
self.assertNotIn(
|
||||
"civitai_api_key",
|
||||
json.dumps(report["config"]).replace("has_civitai_api_key", ""),
|
||||
)
|
||||
|
||||
async def test_report_logging_has_public_directory(self):
|
||||
report, _text = await self.report()
|
||||
stats = report["logging"]["stats"]
|
||||
self.assertFalse(posixpath.isabs(stats["log_directory"]))
|
||||
self.assertIn("level", report["logging"]["config"])
|
||||
|
||||
async def test_report_survives_an_unreadable_viewerjs_provenance(self):
|
||||
with mock.patch.object(
|
||||
diagnostics_report,
|
||||
"VIEWERJS_PROVENANCE",
|
||||
os.path.join(self.tmpdir, "nope.json"),
|
||||
):
|
||||
report, _text = await self.report()
|
||||
self.assertEqual(report["viewerjs"], "unknown")
|
||||
|
||||
async def test_a_build_failure_is_coded_without_details(self):
|
||||
with mock.patch.object(
|
||||
self.api, "build_report", side_effect=RuntimeError(self.tmpdir)
|
||||
):
|
||||
with self.assertLogs("prompt_manager", "ERROR"):
|
||||
report, text = await self.report(status=500)
|
||||
self.assertNotIn(self.tmpdir, text)
|
||||
self.assertFalse(report["success"])
|
||||
|
||||
|
||||
class TestLegacyDiagnostics(DiagnosticsReportTestCase):
|
||||
async def test_legacy_diagnostics_keeps_its_four_checks(self):
|
||||
response = await self.client.request("GET", "/prompt_manager/diagnostics")
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(set(data), {"success", "diagnostics"})
|
||||
self.assertEqual(
|
||||
set(data["diagnostics"]),
|
||||
{"database", "dependencies", "comfyui_output", "image_monitor"},
|
||||
)
|
||||
report, _text = await self.report()
|
||||
self.assertEqual(report["checks"]["database"], data["diagnostics"]["database"])
|
||||
self.assertEqual(
|
||||
report["checks"]["dependencies"], data["diagnostics"]["dependencies"]
|
||||
)
|
||||
|
||||
async def test_both_routes_build_their_checks_with_one_helper(self):
|
||||
with mock.patch.object(
|
||||
admin, "legacy_checks", wraps=admin.legacy_checks
|
||||
) as spy:
|
||||
await self.client.request("GET", "/prompt_manager/diagnostics")
|
||||
await self.report()
|
||||
self.assertEqual(spy.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+93
-18
@@ -9,9 +9,11 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
@@ -36,6 +38,8 @@ from PIL import Image # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
import py.api.images as images_module
|
||||
from py.api import gallery_listing # noqa: E402
|
||||
from py import lora_utils # noqa: E402
|
||||
from py import lora_manager_layout as layout_module # noqa: E402
|
||||
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from py.api.prompts import MAX_PAGE_LIMIT # noqa: E402
|
||||
@@ -111,13 +115,25 @@ class ImageAPITestCase(AioHTTPTestCase):
|
||||
self.assertGreater(image_id, 0, "fixture: image link failed")
|
||||
return image_id
|
||||
|
||||
def _make_symlink(self, target, link):
|
||||
def _make_symlink(self, target, link, target_is_directory=False):
|
||||
"""Create a symlink or skip the test when the platform refuses."""
|
||||
try:
|
||||
os.symlink(str(target), str(link))
|
||||
os.symlink(str(target), str(link), target_is_directory=target_is_directory)
|
||||
except (OSError, NotImplementedError, AttributeError) as exc:
|
||||
self.skipTest(f"symlinks unavailable: {exc}")
|
||||
|
||||
def _spy_executor(self):
|
||||
"""Record the __name__ of every callable sent to _run_in_executor."""
|
||||
original = self.api._run_in_executor
|
||||
calls = []
|
||||
|
||||
async def spy(func, *args, **kwargs):
|
||||
calls.append(getattr(func, "__name__", repr(func)))
|
||||
return await original(func, *args, **kwargs)
|
||||
|
||||
self.api._run_in_executor = spy
|
||||
return calls
|
||||
|
||||
|
||||
class TestServeOutputImage(ImageAPITestCase):
|
||||
"""GET /prompt_manager/images/serve/{filepath}"""
|
||||
@@ -388,18 +404,6 @@ class ThumbnailTestCase(ImageAPITestCase):
|
||||
|
||||
PROGRESS_URL = "/prompt_manager/images/generate-thumbnails/progress"
|
||||
|
||||
def _spy_executor(self):
|
||||
"""Record the __name__ of every callable sent to _run_in_executor."""
|
||||
original = self.api._run_in_executor
|
||||
calls = []
|
||||
|
||||
async def spy(func, *args, **kwargs):
|
||||
calls.append(getattr(func, "__name__", repr(func)))
|
||||
return await original(func, *args, **kwargs)
|
||||
|
||||
self.api._run_in_executor = spy
|
||||
return calls
|
||||
|
||||
def _thumb_path(self, name, suffix=".webp"):
|
||||
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
||||
|
||||
@@ -1216,6 +1220,13 @@ def _raise(*_args, **_kwargs):
|
||||
class ImageRouteCoverageCase(ImageAPITestCase):
|
||||
"""Helpers for driving success and failure branches of each route."""
|
||||
|
||||
def setUp(self):
|
||||
# #362: the LoraManager layout cache is per process; every test here
|
||||
# starts and ends with it empty (runs before the subclasses' setUpAsync).
|
||||
super().setUp()
|
||||
lora_utils.clear_layout_cache()
|
||||
self.addCleanup(lora_utils.clear_layout_cache)
|
||||
|
||||
def _break_db(self, db_method):
|
||||
setattr(self.api.db, db_method, _raise)
|
||||
|
||||
@@ -1327,6 +1338,9 @@ class TestServeErrorBranches(ImageRouteCoverageCase):
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
# The cache does not watch default_lora_roots (#362): without a
|
||||
# clear the next request would reuse the folders above.
|
||||
lora_utils.clear_layout_cache()
|
||||
with (
|
||||
patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True),
|
||||
patch.object(IntegrationConfig, "LORA_MANAGER_PATH", ""),
|
||||
@@ -1419,32 +1433,39 @@ class TestLoraManagerMediaServing(ImageRouteCoverageCase):
|
||||
self.cache = root / "cache"
|
||||
for folder in (self.sidecars, self.examples, self.cache):
|
||||
folder.mkdir()
|
||||
self.root = root
|
||||
self.write_settings(str(self.sidecars))
|
||||
self.enabled = patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True)
|
||||
self.default_roots = patch("py.lora_utils.default_lora_roots", return_value=[])
|
||||
for item in (
|
||||
self.enabled,
|
||||
patch.object(IntegrationConfig, "LORA_MANAGER_PATH", str(self.lm)),
|
||||
patch(
|
||||
"py.lora_utils.custom_nodes_directories", return_value=[custom_nodes]
|
||||
),
|
||||
patch("py.lora_utils.default_lora_roots", return_value=[]),
|
||||
self.default_roots,
|
||||
patch("py.lora_utils.get_lora_image_cache_dir", return_value=self.cache),
|
||||
patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": str(self.settings)}),
|
||||
):
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def write_settings(self, sidecar_root):
|
||||
(self.settings / "settings.json").write_text(
|
||||
def write_settings(self, sidecar_root, examples=None):
|
||||
path = self.settings / "settings.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sidecar_storage_mode": "centralized",
|
||||
"sidecar_storage_path": sidecar_root,
|
||||
"example_images_path": str(self.examples),
|
||||
"example_images_path": str(examples or self.examples),
|
||||
"civitai_api_key": "never-served",
|
||||
}
|
||||
)
|
||||
)
|
||||
# Ten seconds old, so the layout cache (real clock here) stores the
|
||||
# first load instead of treating the file as being written (#362).
|
||||
stamp = time.time_ns() - 10 * 10**9
|
||||
os.utime(path, ns=(stamp, stamp))
|
||||
|
||||
async def _status(self, image_id):
|
||||
resp = await self.client.request(
|
||||
@@ -1464,11 +1485,65 @@ class TestLoraManagerMediaServing(ImageRouteCoverageCase):
|
||||
|
||||
for image_id in ids:
|
||||
self.assertEqual(await self._status(image_id), 200)
|
||||
# A warm layout cache does not outlive the integration (#362).
|
||||
self.assertIsNotNone(lora_utils._layout_cache._entry)
|
||||
self.enabled.stop()
|
||||
for image_id in ids:
|
||||
self.assertEqual(await self._status(image_id), 403)
|
||||
self.enabled.start()
|
||||
|
||||
def _example(self, folder=None, name="1.mp4"):
|
||||
path = (folder or self.examples) / "ab12" / name
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_bytes(b"video")
|
||||
return path
|
||||
|
||||
async def test_unchanged_settings_reuse_the_cached_layout(self):
|
||||
image_id = self._link_image(self._example())
|
||||
self.assertEqual(await self._status(image_id), 200)
|
||||
self.assertIsNotNone(lora_utils._layout_cache._entry)
|
||||
self.assertEqual(await self._status(image_id), 200)
|
||||
self.assertEqual(lora_utils.default_lora_roots.call_count, 1)
|
||||
|
||||
async def test_a_settings_change_is_seen_without_a_clear(self):
|
||||
image_id = self._link_image(self._example())
|
||||
self.assertEqual(await self._status(image_id), 200)
|
||||
self.assertIsNotNone(lora_utils._layout_cache._entry)
|
||||
moved = self.root / "examples-moved"
|
||||
moved.mkdir()
|
||||
self.write_settings(str(self.sidecars), examples=moved)
|
||||
self.assertEqual(await self._status(image_id), 403)
|
||||
|
||||
async def test_a_cached_folder_swapped_for_a_symlink_to_home_is_refused(self):
|
||||
# CUJ 7: the example-images folder becomes a symlink to a folder that
|
||||
# holds the home folder; the same recorded file is refused.
|
||||
outer = self.root / "outer"
|
||||
(outer / "home").mkdir(parents=True)
|
||||
self._example(outer)
|
||||
image_id = self._link_image(self._example())
|
||||
with patch.object(layout_module.Path, "home", return_value=outer / "home"):
|
||||
self.assertEqual(await self._status(image_id), 200)
|
||||
self.assertIsNotNone(lora_utils._layout_cache._entry)
|
||||
# A skip after the rmtree is safe: setUp builds a fresh tree.
|
||||
shutil.rmtree(self.examples)
|
||||
self._make_symlink(outer, self.examples, target_is_directory=True)
|
||||
self.assertEqual(await self._status(image_id), 403)
|
||||
|
||||
async def test_a_configured_path_outside_custom_nodes_adds_nothing(self):
|
||||
from py.config import IntegrationConfig
|
||||
|
||||
self.assertEqual(await self._status(self._link_image(self._example())), 200)
|
||||
self.assertIsNotNone(lora_utils._layout_cache._entry)
|
||||
with patch.object(IntegrationConfig, "LORA_MANAGER_PATH", str(self.sidecars)):
|
||||
self.assertEqual(self.api._lora_image_dirs(), [])
|
||||
# The warm entry for the real install was never handed out for it.
|
||||
self.assertIsNone(lora_utils.cached_lora_manager_layout(str(self.sidecars))[0])
|
||||
|
||||
async def test_serve_image_builds_the_allow_list_in_the_executor(self):
|
||||
calls = self._spy_executor()
|
||||
self.assertEqual(await self._status(self._link_image(self._example())), 200)
|
||||
self.assertIn("_lora_image_dirs", calls)
|
||||
|
||||
async def test_a_filesystem_root_as_sidecar_folder_adds_nothing(self):
|
||||
self.write_settings(os.path.abspath(os.sep))
|
||||
dirs = self.api._lora_image_dirs()
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Reviewed rotated-log cleanup as a maintenance plan (#329, py/api/log_cleanup.py).
|
||||
|
||||
``POST /maintenance/preview {operation: "rotated_logs"}`` lists exactly the
|
||||
rotated copies Rule R allows and writes nothing; ``POST /maintenance/apply``
|
||||
deletes exactly those as a ``maintenance`` job (admission ``read``), or
|
||||
nothing, failing ``plan-changed``, when any of them changed after the review.
|
||||
|
||||
Every test runs on a real ``PromptManagerLogger`` whose log folder is a
|
||||
temporary folder, never the real one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import unittest.mock as mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api.maintenance_plans import PlanStore # noqa: E402
|
||||
from utils import logging_config # noqa: E402
|
||||
from utils.logging_config import PromptManagerLogger, get_logger_manager # noqa: E402
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
from tests.test_api_maintenance import MaintenanceAPITestCase # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
from test_api_maintenance import MaintenanceAPITestCase # noqa: E402
|
||||
|
||||
WAIT = 10
|
||||
PREVIEW = "/prompt_manager/maintenance/preview"
|
||||
APPLY = "/prompt_manager/maintenance/apply"
|
||||
CHANGED = "The log files changed since the review; nothing was deleted. Review again."
|
||||
MEDIA = "Deletes only the listed log files. No image, video or library record changes."
|
||||
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _strings(value):
|
||||
if isinstance(value, str):
|
||||
yield value
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values():
|
||||
yield from _strings(item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
yield from _strings(item)
|
||||
|
||||
|
||||
class LogCleanupTestCase(MaintenanceAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
package = os.path.join(self.tmpdir, "pkg")
|
||||
os.makedirs(os.path.join(package, "utils"))
|
||||
fake_file = os.path.join(package, "utils", "logging_config.py")
|
||||
with mock.patch.object(logging_config, "__file__", fake_file):
|
||||
self.manager = object.__new__(PromptManagerLogger) # bypass singleton
|
||||
self.manager.__init__()
|
||||
self.addCleanup(self._restore_global_logging)
|
||||
self.manager.update_config({"console_logging": False})
|
||||
self._only_this_thread()
|
||||
self.log_dir = str(self.manager.log_dir)
|
||||
self.app_logger = logging.getLogger("prompt_manager.log_cleanup_test")
|
||||
self.app_logger.info("active log line")
|
||||
self.api._get_logger_manager = lambda: self.manager
|
||||
self.outside = os.path.join(self.tmpdir, "link-targets")
|
||||
os.makedirs(self.outside)
|
||||
return app
|
||||
|
||||
def _only_this_thread(self):
|
||||
"""Only this test's records reach the temporary log (not other threads')."""
|
||||
this_thread = threading.get_ident()
|
||||
for handler in self.manager.logger.handlers:
|
||||
handler.filters.clear()
|
||||
handler.addFilter(lambda record: record.thread == this_thread)
|
||||
|
||||
def _restore_global_logging(self):
|
||||
for handler in logging.getLogger("prompt_manager").handlers[:]:
|
||||
handler.close()
|
||||
assert_closed(self, self.log_dir)
|
||||
get_logger_manager()._setup_loggers()
|
||||
|
||||
def path(self, name):
|
||||
return os.path.join(self.log_dir, name)
|
||||
|
||||
def write(self, name, data=b"rotated\n"):
|
||||
with open(self.path(name), "wb") as handle:
|
||||
handle.write(data)
|
||||
|
||||
def symlink(self, name):
|
||||
target = os.path.join(self.outside, "secret.txt")
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(b"secret " * 100)
|
||||
try:
|
||||
os.symlink(target, self.path(name))
|
||||
except (OSError, NotImplementedError): # pragma: no cover - no privilege
|
||||
return False
|
||||
return True
|
||||
|
||||
def names(self):
|
||||
return sorted(os.listdir(self.log_dir))
|
||||
|
||||
def snapshot(self):
|
||||
"""Every entry but the active log, with its bytes (None for a folder)."""
|
||||
state = {}
|
||||
for name in self.names():
|
||||
if name == "prompt_manager.log":
|
||||
continue
|
||||
path = self.path(name)
|
||||
if os.path.islink(path):
|
||||
state[name] = ("link", os.readlink(path))
|
||||
elif os.path.isdir(path):
|
||||
state[name] = None
|
||||
else:
|
||||
with open(path, "rb") as handle:
|
||||
state[name] = handle.read()
|
||||
return state
|
||||
|
||||
def seed(self):
|
||||
"""CUJ 5's folder: three rotated copies, a .bak, a folder and a link."""
|
||||
self.write("prompt_manager.log.1", b"a" * 1000)
|
||||
self.write("prompt_manager.log.2", b"b" * 2000)
|
||||
self.write("prompt_manager.log.3", b"c" * 3000)
|
||||
self.write("prompt_manager.log.bak", b"mine")
|
||||
os.makedirs(self.path("prompt_manager.log.9"))
|
||||
return self.symlink("prompt_manager.log.4")
|
||||
|
||||
async def preview(self, status=200, **body):
|
||||
return await self.post(
|
||||
PREVIEW, {"operation": "rotated_logs", "options": {}, **body}, status
|
||||
)
|
||||
|
||||
async def apply(self, token, status=202):
|
||||
return await self.post(APPLY, {"token": token}, status)
|
||||
|
||||
async def run_apply(self, plan):
|
||||
body = await self.apply(plan["token"])
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
|
||||
return body["job"], job
|
||||
|
||||
|
||||
class TestPreview(LogCleanupTestCase):
|
||||
async def test_preview_lists_only_rotated_copies(self):
|
||||
linked = self.seed()
|
||||
plan = await self.preview()
|
||||
self.assertEqual(
|
||||
set(plan),
|
||||
{
|
||||
"success",
|
||||
"token",
|
||||
"operation",
|
||||
"expires_at",
|
||||
"scope",
|
||||
"protected",
|
||||
"backup",
|
||||
"media",
|
||||
"details",
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan["operation"], "rotated_logs")
|
||||
self.assertEqual(
|
||||
plan["scope"], {"count": 3, "label": "3 rotated log copies · 5.9 KB"}
|
||||
)
|
||||
self.assertEqual(
|
||||
[row["label"] for row in plan["details"]],
|
||||
["prompt_manager.log.1", "prompt_manager.log.2", "prompt_manager.log.3"],
|
||||
)
|
||||
self.assertRegex(
|
||||
plan["details"][2]["detail"], r"^2\.9 KB · \d{4}-\d\d-\d\d \d\d:\d\d$"
|
||||
)
|
||||
reasons = ["Kept: the active log"]
|
||||
if linked:
|
||||
reasons.append("Kept: 1 link")
|
||||
reasons.append("Kept: 2 other files")
|
||||
self.assertEqual(
|
||||
plan["protected"], {"count": len(reasons) + 1, "reasons": reasons}
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["backup"],
|
||||
{"policy": "none", "destination_label": None, "expected_bytes": None},
|
||||
)
|
||||
self.assertEqual(plan["media"], {"deletes_files": True, "label": MEDIA})
|
||||
self.assertTrue(plan["expires_at"].endswith("+00:00"))
|
||||
self.assertNotIn("mtime_ns", json.dumps(plan))
|
||||
|
||||
async def test_preview_writes_nothing(self):
|
||||
self.seed()
|
||||
before = self.snapshot()
|
||||
await self.preview()
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
async def test_preview_has_no_paths(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
for text in _strings(plan):
|
||||
self.assertFalse(posixpath.isabs(text), text)
|
||||
self.assertFalse(ntpath.isabs(text), text)
|
||||
self.assertIsNone(_WINDOWS_ABSOLUTE.match(text), text)
|
||||
|
||||
async def test_preview_rejects_non_empty_options(self):
|
||||
for options in ({"all": True}, [], "x"):
|
||||
with self.subTest(options=options):
|
||||
reply = await self.preview(status=400, options=options)
|
||||
self.assertEqual(reply["code"], "invalid-request")
|
||||
|
||||
async def test_preview_too_large_is_413(self):
|
||||
self.seed()
|
||||
self.api._maintenance_plans = PlanStore(max_bytes=10)
|
||||
reply = await self.preview(status=413)
|
||||
self.assertEqual(
|
||||
reply,
|
||||
{
|
||||
"success": False,
|
||||
"code": "too-large",
|
||||
"error": "Too many log files to review at once.",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_empty_folder_plan_has_scope_zero(self):
|
||||
plan = await self.preview()
|
||||
self.assertEqual(plan["scope"], {"count": 0, "label": "No rotated log copies"})
|
||||
self.assertEqual(plan["details"], [])
|
||||
self.assertEqual(
|
||||
plan["protected"], {"count": 1, "reasons": ["Kept: the active log"]}
|
||||
)
|
||||
|
||||
async def test_preview_refused_while_an_exclusive_job_runs(self):
|
||||
async with self.api._jobs.hold(
|
||||
"restore", "Database restore", admission="exclusive"
|
||||
):
|
||||
reply = await self.preview(status=409)
|
||||
self.assertEqual(reply["code"], "busy")
|
||||
|
||||
|
||||
class TestApply(LogCleanupTestCase):
|
||||
async def test_apply_deletes_exactly_the_reviewed_files(self):
|
||||
linked = self.seed()
|
||||
plan = await self.preview()
|
||||
summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(summary["kind"], "maintenance")
|
||||
self.assertEqual(summary["title"], "Rotated log copies")
|
||||
self.assertEqual(summary["admission"], "read")
|
||||
self.assertFalse(summary["cancellable"])
|
||||
self.assertEqual(summary["scope"], {"operation": "rotated_logs"})
|
||||
self.assertEqual(job.state, "finished", job.log)
|
||||
self.assertEqual(
|
||||
job.counters,
|
||||
{"changed": 3, "protected": plan["protected"]["count"], "errors": 0},
|
||||
)
|
||||
expected = [
|
||||
"prompt_manager.log",
|
||||
"prompt_manager.log.9",
|
||||
"prompt_manager.log.bak",
|
||||
]
|
||||
if linked:
|
||||
expected.append("prompt_manager.log.4")
|
||||
self.assertEqual(self.names(), sorted(expected))
|
||||
self.assertEqual(job.status_line, "Deleted 3 rotated log copies")
|
||||
report = json.dumps(job.report())
|
||||
self.assertNotIn(self.tmpdir, report)
|
||||
|
||||
async def test_apply_rows_name_each_file(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(
|
||||
job.outcomes["changed"],
|
||||
[
|
||||
{
|
||||
"path": "prompt_manager.log.1",
|
||||
"root": "",
|
||||
"root_index": None,
|
||||
"detail": "Deleted · 1000 bytes",
|
||||
},
|
||||
{
|
||||
"path": "prompt_manager.log.2",
|
||||
"root": "",
|
||||
"root_index": None,
|
||||
"detail": "Deleted · 2.0 KB",
|
||||
},
|
||||
{
|
||||
"path": "prompt_manager.log.3",
|
||||
"root": "",
|
||||
"root_index": None,
|
||||
"detail": "Deleted · 2.9 KB",
|
||||
},
|
||||
],
|
||||
)
|
||||
log = "\n".join(job.log)
|
||||
for n in (1, 2, 3):
|
||||
self.assertIn(f"prompt_manager.log.{n}", log)
|
||||
|
||||
async def test_apply_after_rollover_fails_plan_changed_and_deletes_nothing(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
handler = next(
|
||||
h
|
||||
for h in self.manager.logger.handlers
|
||||
if isinstance(h, logging.FileHandler)
|
||||
)
|
||||
handler.acquire()
|
||||
try:
|
||||
handler.doRollover() # every listed name now holds another file
|
||||
finally:
|
||||
handler.release()
|
||||
before = self.snapshot()
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(job.scope["failure"], "plan-changed")
|
||||
self.assertEqual(job.status_line, CHANGED)
|
||||
self.assertEqual(job.counters["changed"], 0)
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
|
||||
async def test_apply_reports_a_file_in_use(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
real_unlink = os.unlink
|
||||
held = self.path("prompt_manager.log.2")
|
||||
|
||||
def unlink(path, *args, **kwargs):
|
||||
if os.fspath(path) == held:
|
||||
error = PermissionError(
|
||||
13,
|
||||
"The process cannot access the file because it is being "
|
||||
"used by another process",
|
||||
held,
|
||||
)
|
||||
error.winerror = 32
|
||||
raise error
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(logging_config.os, "unlink", unlink):
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(job.counters["changed"], 2)
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
[row] = job.outcomes["errors"]
|
||||
self.assertEqual(row["path"], "prompt_manager.log.2")
|
||||
self.assertIn("being used by another process", row["detail"])
|
||||
self.assertNotIn(self.tmpdir, json.dumps(job.report()))
|
||||
self.assertIn("prompt_manager.log.2", self.names())
|
||||
self.assertEqual(
|
||||
job.status_line, "Deleted 2 rotated log copies, 1 could not be deleted"
|
||||
)
|
||||
|
||||
async def test_token_is_single_use(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
summary, _job = await self.run_apply(plan)
|
||||
again = await self.apply(plan["token"], 409)
|
||||
self.assertEqual(again["code"], "plan-used")
|
||||
self.assertEqual(
|
||||
again["job"], {"id": summary["id"], "title": "Rotated log copies"}
|
||||
)
|
||||
|
||||
async def test_apply_runs_beside_a_scan(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
async with self.api._jobs.hold("scan", "Metadata scan", admission="records"):
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(job.state, "finished", job.log)
|
||||
self.assertNotIn("prompt_manager.log.1", self.names())
|
||||
|
||||
async def test_apply_refused_while_restore_runs(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
async with self.api._jobs.hold(
|
||||
"restore", "Database restore", admission="exclusive"
|
||||
) as restore:
|
||||
busy = await self.apply(plan["token"], 409)
|
||||
self.assertEqual(busy["code"], "busy")
|
||||
self.assertEqual(
|
||||
busy["running"], {"id": restore.id, "title": "Database restore"}
|
||||
)
|
||||
self.assertIn("prompt_manager.log.1", self.names())
|
||||
# The refused apply kept the token.
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(job.state, "finished", job.log)
|
||||
|
||||
async def test_apply_refused_beside_another_maintenance_job(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
async with self.api._jobs.hold(
|
||||
"maintenance", "Optimize database", admission="records"
|
||||
):
|
||||
busy = await self.apply(plan["token"], 409)
|
||||
self.assertEqual(busy["code"], "busy")
|
||||
|
||||
async def test_active_log_survives_and_keeps_growing(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
await self.run_apply(plan)
|
||||
self.app_logger.info("written after the cleanup")
|
||||
with open(self.path("prompt_manager.log"), "rb") as handle:
|
||||
content = handle.read()
|
||||
self.assertIn(b"active log line", content)
|
||||
self.assertIn(b"written after the cleanup", content)
|
||||
|
||||
async def test_an_unexpected_failure_fails_the_job_without_details(self):
|
||||
self.seed()
|
||||
plan = await self.preview()
|
||||
with mock.patch.object(
|
||||
self.manager, "delete_rotated", side_effect=RuntimeError(self.tmpdir)
|
||||
):
|
||||
with self.assertLogs("prompt_manager", "ERROR"):
|
||||
_summary, job = await self.run_apply(plan)
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertNotIn(self.tmpdir, json.dumps(job.report()))
|
||||
self.assertEqual(job.status_line, "The log cleanup failed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+183
-11
@@ -5,9 +5,10 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
@@ -20,7 +21,9 @@ from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from py.api import logging_routes # noqa: E402
|
||||
from py.api.logging_routes import LOG_CONFIG_KEYS # noqa: E402
|
||||
from utils import logging_config # noqa: E402
|
||||
|
||||
|
||||
class FakeLoggerManager:
|
||||
@@ -38,11 +41,16 @@ class FakeLoggerManager:
|
||||
}
|
||||
self.update_calls = []
|
||||
self.logs = []
|
||||
self.threads = {}
|
||||
|
||||
def update_config(self, new_config):
|
||||
self.threads["update_config"] = threading.current_thread()
|
||||
self.update_calls.append(dict(new_config))
|
||||
self.config.update(new_config)
|
||||
|
||||
def log_file_entry(self, name):
|
||||
return logging_config.log_file_entry(self.log_dir, name)
|
||||
|
||||
def get_config(self):
|
||||
return dict(self.config)
|
||||
|
||||
@@ -54,23 +62,36 @@ class FakeLoggerManager:
|
||||
|
||||
def get_log_files(self):
|
||||
files = []
|
||||
for path in sorted(self.log_dir.glob("*.log*")):
|
||||
for row in sorted(
|
||||
logging_config.scan_log_folder(self.log_dir), key=lambda r: r["name"]
|
||||
):
|
||||
if row["_dir"]:
|
||||
continue
|
||||
files.append(
|
||||
{
|
||||
"filename": path.name,
|
||||
"path": str(path),
|
||||
"size": path.stat().st_size,
|
||||
"filename": row["name"],
|
||||
"path": str(self.log_dir / row["name"]),
|
||||
"size": row["size"],
|
||||
"modified": "2026-10-07T00:00:00",
|
||||
"is_main": path.name == "prompt_manager.log",
|
||||
"is_main": row["kind"] == "active",
|
||||
"kind": row["kind"],
|
||||
}
|
||||
)
|
||||
return files
|
||||
|
||||
def truncate_logs(self):
|
||||
"""Rule R: empty the active log, delete rotated copies, keep the rest."""
|
||||
self.threads["truncate_logs"] = threading.current_thread()
|
||||
truncated = []
|
||||
for path in self.log_dir.glob("prompt_manager.log*"):
|
||||
path.write_text("")
|
||||
truncated.append(path.name)
|
||||
for row in logging_config.scan_log_folder(self.log_dir):
|
||||
path = self.log_dir / row["name"]
|
||||
if row["kind"] == "active":
|
||||
path.write_text("")
|
||||
elif row["kind"] == "rotated":
|
||||
path.unlink()
|
||||
else:
|
||||
continue
|
||||
truncated.append(row["name"])
|
||||
return {"truncated": truncated, "errors": []}
|
||||
|
||||
def get_log_stats(self):
|
||||
@@ -378,12 +399,129 @@ class TestLogFilesAndDownload(LoggingAPITestCase):
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_download_read_failure_is_500(self):
|
||||
(self.log_dir / "dir.log").mkdir()
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/download/dir.log")
|
||||
# Rewritten (#329): a directory is now a 404; inject a read error.
|
||||
(self.log_dir / "prompt_manager.log").write_bytes(b"x")
|
||||
secret = os.path.join(self.tmpdir, "logs", "prompt_manager.log")
|
||||
|
||||
def fail(path):
|
||||
raise PermissionError(13, "Permission denied", secret)
|
||||
|
||||
with patch.object(logging_routes, "_read_log_bytes", fail):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log"
|
||||
)
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 500)
|
||||
self.assertNotIn(self.tmpdir, body)
|
||||
|
||||
async def test_directory_download_is_404(self):
|
||||
(self.log_dir / "dir.log").mkdir()
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/download/dir.log")
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
async def test_download_refuses_a_symlink(self):
|
||||
secret = Path(self.tmpdir) / "secret.txt"
|
||||
secret.write_text("the key")
|
||||
try:
|
||||
os.symlink(secret, self.log_dir / "prompt_manager.log.3")
|
||||
except (OSError, NotImplementedError): # pragma: no cover
|
||||
self.skipTest("this account cannot create symlinks")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log.3"
|
||||
)
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertNotIn("the key", body)
|
||||
|
||||
async def test_download_refuses_a_hard_link(self):
|
||||
secret = Path(self.tmpdir) / "secret.txt"
|
||||
secret.write_text("the key")
|
||||
try:
|
||||
os.link(secret, self.log_dir / "prompt_manager.log.3")
|
||||
except (OSError, NotImplementedError): # pragma: no cover
|
||||
self.skipTest("this filesystem cannot create hard links")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log.3"
|
||||
)
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertNotIn("the key", await resp.text())
|
||||
|
||||
async def test_download_refuses_a_file_swapped_after_it_was_listed(self):
|
||||
(self.log_dir / "prompt_manager.log.2").write_text("listed")
|
||||
other = Path(self.tmpdir) / "other.txt"
|
||||
other.write_text("swapped in")
|
||||
real_entry = self.manager.log_file_entry
|
||||
|
||||
def entry_then_swap(name):
|
||||
path = real_entry(name)
|
||||
os.replace(other, path) # another file now has the listed name
|
||||
return path
|
||||
|
||||
self.manager.log_file_entry = entry_then_swap
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log.2"
|
||||
)
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertNotIn("swapped in", await resp.text())
|
||||
|
||||
@unittest.skipUnless(hasattr(os, "mkfifo"), "FIFOs are POSIX only")
|
||||
async def test_download_refuses_a_fifo_without_blocking(self):
|
||||
os.mkfifo(self.log_dir / "pipe.log")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/pipe.log"
|
||||
)
|
||||
self.assertEqual(resp.status, 404)
|
||||
|
||||
async def test_download_reads_off_the_loop(self):
|
||||
(self.log_dir / "prompt_manager.log").write_bytes(b"line\n")
|
||||
seen = []
|
||||
real = logging_routes._read_log_bytes
|
||||
|
||||
def read(path):
|
||||
seen.append(threading.current_thread())
|
||||
return real(path)
|
||||
|
||||
with patch.object(logging_routes, "_read_log_bytes", read):
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log"
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(len(seen), 1)
|
||||
self.assertIsNot(seen[0], threading.current_thread())
|
||||
|
||||
async def test_download_keeps_its_response_shape(self):
|
||||
(self.log_dir / "prompt_manager.log.2").write_bytes(b"a\nb\n")
|
||||
resp = await self.client.request(
|
||||
"GET", "/prompt_manager/logs/download/prompt_manager.log.2"
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.content_type, "text/plain")
|
||||
self.assertEqual(
|
||||
resp.headers["Content-Disposition"],
|
||||
'attachment; filename="prompt_manager.log.2"',
|
||||
)
|
||||
self.assertEqual(resp.headers["Content-Length"], "4")
|
||||
self.assertEqual(await resp.read(), b"a\nb\n")
|
||||
|
||||
async def test_log_files_rows_carry_their_kind(self):
|
||||
for name in (
|
||||
"prompt_manager.log",
|
||||
"prompt_manager.log.1",
|
||||
"prompt_manager.log.bak",
|
||||
):
|
||||
(self.log_dir / name).write_text("x")
|
||||
resp = await self.client.request("GET", "/prompt_manager/logs/files")
|
||||
data = await resp.json()
|
||||
kinds = {row["filename"]: row["kind"] for row in data["files"]}
|
||||
self.assertEqual(
|
||||
kinds,
|
||||
{
|
||||
"prompt_manager.log": "active",
|
||||
"prompt_manager.log.1": "rotated",
|
||||
"prompt_manager.log.bak": "other",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestTruncateConfigAndStats(LoggingAPITestCase):
|
||||
|
||||
@@ -395,6 +533,40 @@ class TestTruncateConfigAndStats(LoggingAPITestCase):
|
||||
self.assertEqual(data["results"]["truncated"], ["prompt_manager.log"])
|
||||
self.assertEqual((self.log_dir / "prompt_manager.log").read_text(), "")
|
||||
|
||||
async def test_truncate_keeps_other_files(self):
|
||||
for name in (
|
||||
"prompt_manager.log",
|
||||
"prompt_manager.log.1",
|
||||
"prompt_manager.log.bak",
|
||||
):
|
||||
(self.log_dir / name).write_text("data")
|
||||
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
|
||||
data = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(set(data), {"success", "message", "results"})
|
||||
self.assertEqual(
|
||||
sorted(data["results"]["truncated"]),
|
||||
["prompt_manager.log", "prompt_manager.log.1"],
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(p.name for p in self.log_dir.iterdir()),
|
||||
["prompt_manager.log", "prompt_manager.log.bak"],
|
||||
)
|
||||
|
||||
async def test_truncate_runs_off_the_loop(self):
|
||||
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIsNot(
|
||||
self.manager.threads["truncate_logs"], threading.current_thread()
|
||||
)
|
||||
|
||||
async def test_update_config_runs_off_the_loop(self):
|
||||
resp = await self._post_config({"level": "DEBUG"})
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIsNot(
|
||||
self.manager.threads["update_config"], threading.current_thread()
|
||||
)
|
||||
|
||||
async def test_truncate_failure_is_500(self):
|
||||
def boom():
|
||||
raise RuntimeError("locked")
|
||||
|
||||
@@ -876,6 +876,18 @@ class TestLoraScanRoute(LoraAPITestCase):
|
||||
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
|
||||
self.assertEqual(resp.status, 400)
|
||||
|
||||
async def test_a_finished_scan_clears_the_layout_cache(self):
|
||||
# #362: the legacy scan replaces through the same method.
|
||||
self._enable()
|
||||
loras = self.tmp_root / "loras"
|
||||
loras.mkdir()
|
||||
_write_metadata(loras, "plain")
|
||||
(loras / "plain.png").write_bytes(_png_bytes())
|
||||
with patch("py.lora_utils.clear_layout_cache") as clear:
|
||||
resp, events, _calls = await self._scan(loras)
|
||||
self.assertEqual(events[-1]["type"], "complete", events)
|
||||
clear.assert_called_once_with()
|
||||
|
||||
async def test_scan_imports_loras_with_images_and_links_existing(self):
|
||||
self._enable()
|
||||
loras = self.tmp_root / "loras"
|
||||
|
||||
+24
-2
@@ -516,8 +516,9 @@ class TestAutotagConfig(unittest.TestCase):
|
||||
bad_values = {
|
||||
"model_type": ["wd14-convnext", 3, None],
|
||||
"existing": ["replace", "", True],
|
||||
"general_threshold": [1.5, -0.1, float("nan"), "0.4", True],
|
||||
"character_threshold": [2, float("inf"), None],
|
||||
# #369: a saved threshold off the 0.05 step no page can show.
|
||||
"general_threshold": [1.5, -0.1, float("nan"), "0.4", True, 0.37],
|
||||
"character_threshold": [2, float("inf"), None, 0.33],
|
||||
# 4000 characters are at most 16,000 UTF-8 bytes, so the 16 KiB
|
||||
# byte cap can only be met past the character cap.
|
||||
"template": ["", " ", "x" * 4001, "\U0001f600" * 4097, "\ud800", 7],
|
||||
@@ -567,6 +568,27 @@ class TestAutotagConfig(unittest.TestCase):
|
||||
config_module.MODEL_TYPES, {"gguf", "hf", "wd14-swinv2", "wd14-vit"}
|
||||
)
|
||||
|
||||
def test_saved_threshold_is_a_multiple_of_the_step(self):
|
||||
"""#369: every page shows the thresholds in steps of 0.05, so a saved
|
||||
one must be on that grid; it is stored exactly on it."""
|
||||
from py.tagging_defaults import validate_saved_threshold as validate
|
||||
|
||||
for value in (0, 0.05, 0.15, 0.35, 0.85, 1):
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(validate("x", value), value)
|
||||
self.assertEqual(validate("x", 0.1 + 0.2), 0.3)
|
||||
for value, message in (
|
||||
(0.37, "x must be a multiple of 0.05"),
|
||||
(0.351, "x must be a multiple of 0.05"),
|
||||
(0.35000000001, "x must be a multiple of 0.05"),
|
||||
(1.5, "x must be between 0 and 1"),
|
||||
("0.4", "x must be a finite number"),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
validate("x", value)
|
||||
self.assertEqual(str(caught.exception), message)
|
||||
|
||||
def test_suggestion_limit_setting_validated_on_load(self):
|
||||
for value in (0, 101, "10", float("nan"), 2.5, True, None, [10]):
|
||||
with self.subTest(value=value):
|
||||
|
||||
@@ -365,11 +365,39 @@ class TestStartStop(unittest.TestCase):
|
||||
def test_stop_monitoring_reports_an_observer_stop_that_raised(self):
|
||||
fake = FakeObserver(stop_error=OSError("descriptor gone"))
|
||||
self._start_with(fake)
|
||||
fake.stopped = True # its thread has ended; stop() still raises
|
||||
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
|
||||
self.assertFalse(self.monitor.stop_monitoring())
|
||||
self.assertIn("Error while stopping image monitoring", "\n".join(logs.output))
|
||||
text = "\n".join(logs.output)
|
||||
self.assertIn("Error while stopping image monitoring", text)
|
||||
self.assertNotIn("watchdog observer still running", text)
|
||||
fake.stop_error = None # tearDown's stop calls stop() again
|
||||
|
||||
def test_the_still_running_warning_names_the_time_the_join_got(self):
|
||||
fake = FakeObserver(stay_alive=True)
|
||||
handler = self._start_with(fake)
|
||||
now = [100.0]
|
||||
recorded_close = handler.close
|
||||
|
||||
def close_that_uses_the_deadline(timeout=im_mod.WORKER_JOIN_SECONDS):
|
||||
ok = recorded_close(timeout)
|
||||
now[0] += 5.0 # a busy worker took the whole deadline
|
||||
return ok
|
||||
|
||||
handler.close = close_that_uses_the_deadline
|
||||
with (
|
||||
patch.object(
|
||||
im_mod, "time", types.SimpleNamespace(monotonic=lambda: now[0])
|
||||
),
|
||||
self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs,
|
||||
):
|
||||
self.assertFalse(self.monitor.stop_monitoring(timeout=5.0))
|
||||
self.assertEqual(fake.calls[-1], ("join", 0.0))
|
||||
self.assertIn(
|
||||
"watchdog observer still running after 0.0 s", "\n".join(logs.output)
|
||||
)
|
||||
fake.stay_alive = False # it ends; tearDown's stop waits for it
|
||||
|
||||
def test_stop_monitoring_without_an_observer_returns_true(self):
|
||||
self.assertTrue(self.monitor.stop_monitoring())
|
||||
|
||||
|
||||
+214
-49
@@ -603,6 +603,24 @@ def hooked_app():
|
||||
)
|
||||
|
||||
|
||||
WAIT_SECONDS = 30 # ceiling for every Event wait (TESTING.md test rules)
|
||||
|
||||
|
||||
def start_busy_worker(package, folder, name="busy.png"):
|
||||
"""The image worker alive in its processing delay."""
|
||||
handler = package._global_image_monitor.handler
|
||||
if handler is None:
|
||||
raise AssertionError("the package's monitor did not start")
|
||||
handler.processing_delay = 3600
|
||||
# Outside the watched folder, so the observer does not schedule it first.
|
||||
path = os.path.join(folder, name)
|
||||
with open(path, "wb") as image:
|
||||
image.write(b"x" * 16)
|
||||
if not handler.schedule(path) or not handler._worker.is_alive():
|
||||
raise AssertionError("the image worker did not start")
|
||||
return handler._worker
|
||||
|
||||
|
||||
class PackageTeardownCase(unittest.TestCase):
|
||||
"""Each test loads its own package (#301): the teardown runs once."""
|
||||
|
||||
@@ -631,54 +649,83 @@ class PackageTeardownCase(unittest.TestCase):
|
||||
return package
|
||||
|
||||
def busy_worker(self, package, name="busy.png"):
|
||||
"""The image worker alive in its processing delay."""
|
||||
handler = package._global_image_monitor.handler
|
||||
self.assertIsNotNone(handler, "the package's monitor did not start")
|
||||
handler.processing_delay = 3600
|
||||
# Outside the watched folder, so the observer does not schedule it first.
|
||||
path = os.path.join(self.tmp.name, name)
|
||||
with open(path, "wb") as image:
|
||||
image.write(b"x" * 16)
|
||||
self.assertTrue(handler.schedule(path))
|
||||
self.assertTrue(handler._worker.is_alive())
|
||||
return handler._worker
|
||||
return start_busy_worker(package, self.tmp.name, name)
|
||||
|
||||
def hold_a_preview(self, package, *, advance, kill_releases):
|
||||
"""A preview worker held inside a fake encode that tracks a fake
|
||||
ffmpeg, and a package clock that the monitor step moves by
|
||||
``advance`` seconds. Cleanups release the encode first."""
|
||||
# Not the watched folder. Real path for both the file and the root:
|
||||
# macOS /var vs /private/var, Windows short names.
|
||||
previews = os.path.realpath(os.path.join(self.tmp.name, "previews"))
|
||||
os.makedirs(previews)
|
||||
from PIL import Image
|
||||
|
||||
image = os.path.join(previews, "held.png")
|
||||
Image.new("RGB", (8, 8), "red").save(image)
|
||||
held = types.SimpleNamespace(
|
||||
entered=threading.Event(),
|
||||
release=threading.Event(),
|
||||
killed=threading.Event(),
|
||||
stop_seen=[],
|
||||
timeouts=[],
|
||||
now=[100.0],
|
||||
)
|
||||
|
||||
class FakeFfmpeg:
|
||||
def poll(self): # _track keeps only processes still running
|
||||
return None
|
||||
|
||||
def kill(self):
|
||||
held.killed.set()
|
||||
if kill_releases:
|
||||
held.release.set()
|
||||
|
||||
def held_encode(src, dst, settings, is_video, *, stop, guard, track):
|
||||
track(FakeFfmpeg())
|
||||
held.entered.set()
|
||||
held.release.wait(WAIT_SECONDS)
|
||||
held.stop_seen.append(stop())
|
||||
return {"action": "skipped"}
|
||||
|
||||
api = package.api
|
||||
monitor = package._global_image_monitor
|
||||
real_stop = monitor.stop_monitoring
|
||||
real_close = api.close_auto_thumbnails
|
||||
|
||||
def stop_then_advance(timeout):
|
||||
ok = real_stop(timeout)
|
||||
held.now[0] += advance
|
||||
return ok
|
||||
|
||||
def recorded_close(final=False, timeout=None):
|
||||
held.timeouts.append(timeout)
|
||||
return real_close(final=final, timeout=timeout)
|
||||
|
||||
for target, name, value in (
|
||||
(api, "_get_all_output_dirs", lambda: [Path(previews)]),
|
||||
(api, "_generate_one", held_encode),
|
||||
(api, "close_auto_thumbnails", recorded_close),
|
||||
(monitor, "stop_monitoring", stop_then_advance),
|
||||
(package, "_clock", lambda: held.now[0]),
|
||||
):
|
||||
patcher = mock.patch.object(target, name, value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.assertEqual(api.queue_capture_thumbnail(image), "queued")
|
||||
self.addCleanup(held.release.set)
|
||||
self.assertTrue(held.entered.wait(WAIT_SECONDS), "the encode never started")
|
||||
held.thumbnailer = api._auto_thumbnailer # the teardown drops the API's
|
||||
held.worker = held.thumbnailer._worker
|
||||
# Cleanups run newest first: release the encode, then join the worker
|
||||
# (even when an assertion failed first), then end the patches.
|
||||
self.addCleanup(held.worker.join, WAIT_SECONDS)
|
||||
self.addCleanup(held.release.set)
|
||||
self.assertTrue(held.worker.is_alive())
|
||||
return held
|
||||
|
||||
|
||||
class TestPackageTeardown(PackageTeardownCase):
|
||||
def test_teardown_stops_every_part_once(self):
|
||||
package = self.load()
|
||||
monitor = package._global_image_monitor
|
||||
observer = monitor.observer
|
||||
tracker_thread = package._global_prompt_tracker.cleanup_thread
|
||||
worker = self.busy_worker(package)
|
||||
|
||||
with self.assertLogs("prompt_manager.init", "INFO") as logs:
|
||||
result = package._stop_background_work()
|
||||
|
||||
self.assertEqual(result, {"monitor": True, "thumbnails": True, "tracker": True})
|
||||
self.assertEqual(list(result), ["monitor", "thumbnails", "tracker"])
|
||||
self.assertIn("Background work stopped in", "\n".join(logs.output))
|
||||
self.assertIn("monitor=ok thumbnails=ok tracker=ok", "\n".join(logs.output))
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertFalse(observer.is_alive())
|
||||
self.assertFalse(tracker_thread.is_alive())
|
||||
result["monitor"] = False # a copy: the stored result is unchanged
|
||||
with mock.patch.object(monitor, "stop_monitoring") as again:
|
||||
self.assertEqual(
|
||||
package._stop_background_work(),
|
||||
{"monitor": True, "thumbnails": True, "tracker": True},
|
||||
)
|
||||
again.assert_not_called()
|
||||
|
||||
def test_teardown_detaches_the_monitor_callbacks(self):
|
||||
package = self.load()
|
||||
monitor = package._global_image_monitor
|
||||
self.assertIsNotNone(monitor.review_recorder)
|
||||
package._stop_background_work()
|
||||
self.assertIsNone(monitor.review_recorder)
|
||||
self.assertIsNone(monitor.thumbnail_queue)
|
||||
self.assertIsNone(monitor.output_listener)
|
||||
|
||||
def test_an_image_in_hand_queues_no_preview_during_teardown(self):
|
||||
package = self.load()
|
||||
monitor = package._global_image_monitor
|
||||
@@ -712,11 +759,17 @@ class TestPackageTeardown(PackageTeardownCase):
|
||||
self.assertIsNone(seen["worker"])
|
||||
self.assertTrue(all(result.values()), result)
|
||||
|
||||
def test_teardown_removes_its_atexit_callback(self):
|
||||
def test_teardown_kills_ffmpeg_and_stops_the_preview_worker(self):
|
||||
package = self.load()
|
||||
with mock.patch("atexit.unregister", wraps=atexit.unregister) as unregister:
|
||||
package._stop_background_work()
|
||||
unregister.assert_called_once_with(package._stop_background_work)
|
||||
held = self.hold_a_preview(package, advance=4.0, kill_releases=True)
|
||||
|
||||
result = package._stop_background_work()
|
||||
|
||||
self.assertTrue(held.killed.is_set())
|
||||
self.assertEqual(held.timeouts, [6.0]) # min(30, 10 - 4)
|
||||
self.assertTrue(result["thumbnails"], result)
|
||||
self.assertFalse(held.worker.is_alive())
|
||||
self.assertEqual(held.stop_seen, [True])
|
||||
|
||||
def test_on_cleanup_runs_the_teardown(self):
|
||||
app = hooked_app()
|
||||
@@ -749,6 +802,74 @@ class TestPackageTeardown(PackageTeardownCase):
|
||||
)
|
||||
|
||||
|
||||
class ListHandler(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.INFO)
|
||||
self.lines = []
|
||||
|
||||
def emit(self, record):
|
||||
self.lines.append(f"{record.levelname}:{record.getMessage()}")
|
||||
|
||||
|
||||
class TestPackageTeardownOnce(PackageTestCase):
|
||||
"""The tests that only observe one plain teardown share one package.
|
||||
|
||||
The teardown runs once, so it runs here in ``setUpClass`` (per-test
|
||||
``assertLogs`` cannot wrap it); the tests read what it recorded.
|
||||
"""
|
||||
|
||||
package_name = "pm_teardown_once"
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
super().setUpClass()
|
||||
package = cls.package
|
||||
cls.monitor = package._global_image_monitor
|
||||
cls.observer = cls.monitor.observer
|
||||
cls.tracker_thread = package._global_prompt_tracker.cleanup_thread
|
||||
cls.worker = start_busy_worker(package, cls.tmp.name)
|
||||
cls.recorder_before = cls.monitor.review_recorder
|
||||
logger = logging.getLogger("prompt_manager.init")
|
||||
handler, level = ListHandler(), logger.level
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO) # otherwise set by the config
|
||||
try:
|
||||
with mock.patch("atexit.unregister", wraps=atexit.unregister) as unregister:
|
||||
cls.result = package._stop_background_work()
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(level)
|
||||
cls.log = "\n".join(handler.lines)
|
||||
cls.unregister_calls = unregister.call_args_list
|
||||
|
||||
def test_teardown_stops_every_part_once(self):
|
||||
expected = {"monitor": True, "thumbnails": True, "tracker": True}
|
||||
self.assertEqual(self.result, expected)
|
||||
self.assertEqual(list(self.result), ["monitor", "thumbnails", "tracker"])
|
||||
self.assertIn("Background work stopped in", self.log)
|
||||
self.assertIn("monitor=ok thumbnails=ok tracker=ok", self.log)
|
||||
self.assertFalse(self.worker.is_alive())
|
||||
self.assertFalse(self.observer.is_alive())
|
||||
self.assertFalse(self.tracker_thread.is_alive())
|
||||
copy = self.package._stop_background_work()
|
||||
copy["monitor"] = False # a copy: the stored result is unchanged
|
||||
with mock.patch.object(self.monitor, "stop_monitoring") as again:
|
||||
self.assertEqual(self.package._stop_background_work(), expected)
|
||||
again.assert_not_called()
|
||||
|
||||
def test_teardown_detaches_the_monitor_callbacks(self):
|
||||
self.assertIsNotNone(self.recorder_before)
|
||||
self.assertIsNone(self.monitor.review_recorder)
|
||||
self.assertIsNone(self.monitor.thumbnail_queue)
|
||||
self.assertIsNone(self.monitor.output_listener)
|
||||
|
||||
def test_teardown_removes_its_atexit_callback(self):
|
||||
self.assertEqual(
|
||||
self.unregister_calls, [mock.call(self.package._stop_background_work)]
|
||||
)
|
||||
|
||||
|
||||
class TestPackageTeardownFailures(PackageTeardownCase):
|
||||
"""A failing step never skips the later ones (design D14, R2)."""
|
||||
|
||||
@@ -775,6 +896,50 @@ class TestPackageTeardownFailures(PackageTeardownCase):
|
||||
def test_second_interrupt_does_not_skip_the_tracker(self):
|
||||
self.assert_tracker_stops_after(KeyboardInterrupt())
|
||||
|
||||
def test_an_interrupt_between_steps_still_records_the_teardown(self):
|
||||
package = self.load(require_stopped=False)
|
||||
real_step = package._teardown_step
|
||||
|
||||
def interrupted_before_the_previews(name, stop, logger):
|
||||
if name == "the preview worker":
|
||||
raise KeyboardInterrupt # outside the step's own try
|
||||
return real_step(name, stop, logger)
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
package, "_teardown_step", interrupted_before_the_previews
|
||||
),
|
||||
mock.patch("atexit.unregister", wraps=atexit.unregister) as unregister,
|
||||
self.assertRaises(KeyboardInterrupt),
|
||||
):
|
||||
package._stop_background_work()
|
||||
expected = {"monitor": True, "thumbnails": False, "tracker": False}
|
||||
self.assertEqual(package._background_work_result, expected)
|
||||
unregister.assert_called_once_with(package._stop_background_work)
|
||||
with mock.patch.object(
|
||||
package._global_image_monitor, "stop_monitoring"
|
||||
) as again:
|
||||
self.assertEqual(package._stop_background_work(), expected)
|
||||
again.assert_not_called()
|
||||
|
||||
def test_teardown_reports_a_preview_worker_still_held(self):
|
||||
package = self.load(require_stopped=False)
|
||||
held = self.hold_a_preview(package, advance=9.95, kill_releases=False)
|
||||
|
||||
with self.assertLogs("prompt_manager.init", "WARNING") as logs:
|
||||
result = package._stop_background_work()
|
||||
|
||||
self.assertTrue(held.killed.is_set())
|
||||
self.assertEqual(len(held.timeouts), 1)
|
||||
self.assertAlmostEqual(held.timeouts[0], 0.05, places=6)
|
||||
self.assertTrue(held.thumbnailer.closed)
|
||||
self.assertFalse(result["thumbnails"], result)
|
||||
self.assertRegex("\n".join(logs.output), r"not stopped in time .*thumbnails")
|
||||
held.release.set()
|
||||
held.worker.join(WAIT_SECONDS)
|
||||
self.assertFalse(held.worker.is_alive())
|
||||
self.assertEqual(held.stop_seen, [True])
|
||||
|
||||
def test_teardown_shares_one_deadline(self):
|
||||
package = self.load(require_stopped=False)
|
||||
now = [100.0]
|
||||
|
||||
@@ -529,6 +529,16 @@ class LoraImportRouteTests(LoraImportAPITestCase):
|
||||
resp, body = await self._post(APPLY, {"token": plan["token"]})
|
||||
self.assertEqual((resp.status, body["code"]), (409, "plan-used"))
|
||||
|
||||
async def test_a_finished_import_clears_the_layout_cache(self):
|
||||
# #362: the import may link files under a folder LoraManager created
|
||||
# after the image routes cached its layout.
|
||||
self._two_loras()
|
||||
with patch("py.lora_utils.clear_layout_cache") as clear:
|
||||
_plan, job = await self._apply_reviewed()
|
||||
await job.task
|
||||
self.assertEqual(job.state, "finished", job.log)
|
||||
clear.assert_called_once_with()
|
||||
|
||||
async def test_discovery_failure_keeps_previous_imports(self):
|
||||
self._two_loras()
|
||||
before = _snapshot(self.api.db)
|
||||
@@ -936,6 +946,35 @@ class LoraImportRefusalTests(LoraImportAPITestCase):
|
||||
self.assertEqual((job.state, job.scope["failure"]), ("failed", "plan-changed"))
|
||||
self.assertEqual(_snapshot(self.api.db), before)
|
||||
|
||||
async def test_a_refused_import_keeps_the_layout_cache(self):
|
||||
self._two_loras()
|
||||
resp, plan = await self._post(PREVIEW)
|
||||
self.api.db.save_prompt(
|
||||
text="late", category=CATEGORY, prompt_hash=generate_prompt_hash("late")
|
||||
)
|
||||
with patch("py.lora_utils.clear_layout_cache") as clear:
|
||||
resp, body = await self._post(APPLY, {"token": plan["token"]})
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await job.task
|
||||
self.assertEqual(job.scope["failure"], "plan-changed")
|
||||
clear.assert_not_called()
|
||||
|
||||
async def test_start_refused_when_a_skipped_sidecar_changed(self):
|
||||
# #362 CUJ 4: the review counted the orphan as skipped.
|
||||
self._two_loras()
|
||||
orphan = self.loras / "orphan.metadata.json"
|
||||
orphan.write_text(json.dumps({"model_name": "orphan"}))
|
||||
resp, plan = await self._post(PREVIEW)
|
||||
self.assertEqual(resp.status, 200, plan)
|
||||
self.assertEqual((plan["lora_files"], plan["skipped"]), (3, 1))
|
||||
before = _snapshot(self.api.db)
|
||||
orphan.write_text(orphan.read_text() + " ")
|
||||
resp, body = await self._post(APPLY, {"token": plan["token"]})
|
||||
self.assertEqual(resp.status, 409, body)
|
||||
self.assertEqual(body["code"], "plan-changed")
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertEqual(_snapshot(self.api.db), before)
|
||||
|
||||
async def test_a_pair_saved_after_the_review_blocks_the_job(self):
|
||||
self._two_loras()
|
||||
resp, plan = await self._post(PREVIEW)
|
||||
@@ -1187,6 +1226,62 @@ class LoraImportStageTests(unittest.TestCase):
|
||||
)
|
||||
self.assertNotEqual(lora_import.fingerprint(self.discover()), first)
|
||||
|
||||
# #362: a skipped sidecar is part of what the review counted.
|
||||
|
||||
def _assert_fingerprint_follows(self, change):
|
||||
first = lora_import.fingerprint(self.discover())
|
||||
self.assertEqual(lora_import.fingerprint(self.discover()), first)
|
||||
change()
|
||||
self.assertNotEqual(lora_import.fingerprint(self.discover()), first)
|
||||
|
||||
def _assert_add_edit_remove_change_it(self, path, text):
|
||||
self._assert_fingerprint_follows(lambda: path.write_text(text))
|
||||
self._assert_fingerprint_follows(
|
||||
lambda: path.write_text(path.read_text() + " ")
|
||||
)
|
||||
self._assert_fingerprint_follows(path.unlink)
|
||||
|
||||
def _centralized(self):
|
||||
sidecars = self.tmp / "sidecars"
|
||||
sidecars.mkdir()
|
||||
self.settings_file(
|
||||
{
|
||||
"sidecar_storage_mode": "centralized",
|
||||
"sidecar_storage_path": str(sidecars),
|
||||
}
|
||||
)
|
||||
model = self.loras / "kept.safetensors"
|
||||
model.write_bytes(b"m")
|
||||
(sidecars / "kept.metadata.json").write_text(
|
||||
json.dumps({"file_path": str(model)})
|
||||
)
|
||||
return sidecars
|
||||
|
||||
def test_fingerprint_follows_a_centralized_sidecar_whose_model_is_missing(self):
|
||||
sidecars = self._centralized()
|
||||
gone = self.loras / "gone.safetensors"
|
||||
self._assert_add_edit_remove_change_it(
|
||||
sidecars / "gone.metadata.json", json.dumps({"file_path": str(gone)})
|
||||
)
|
||||
self.assertEqual(len(self.discover().sidecars), 1)
|
||||
|
||||
def test_fingerprint_follows_a_checkpoint_sidecar_outside_the_lora_roots(self):
|
||||
sidecars = self._centralized()
|
||||
checkpoints = self.tmp / "checkpoints"
|
||||
checkpoints.mkdir()
|
||||
ckpt = checkpoints / "base.safetensors"
|
||||
ckpt.write_bytes(b"c")
|
||||
self._assert_add_edit_remove_change_it(
|
||||
sidecars / "base.metadata.json", json.dumps({"file_path": str(ckpt)})
|
||||
)
|
||||
|
||||
def test_fingerprint_follows_an_alongside_sidecar_without_its_model(self):
|
||||
_write_metadata(self.loras, "kept")
|
||||
self._assert_add_edit_remove_change_it(
|
||||
self.loras / "orphan.metadata.json", json.dumps({"model_name": "orphan"})
|
||||
)
|
||||
self.assertEqual(len(self.discover().sidecars), 1)
|
||||
|
||||
def test_fingerprint_follows_a_missing_file_between_stat_calls(self):
|
||||
meta = _write_metadata(self.loras, "one")
|
||||
discovery = self.discover()
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
"""The LoraManager layout cache for the image hot paths (#362).
|
||||
|
||||
``py/lora_layout_cache.py``. Every test injects the install resolver, the
|
||||
layout loader (both counting calls), the wall clock, the monotonic clock and,
|
||||
where needed, ``stat`` and ``pathmod``, over a temporary folder. No test
|
||||
sleeps: modification times are set with ``os.utime`` and the clocks are
|
||||
plain attributes the test moves.
|
||||
"""
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py import lora_layout_cache as cache_module
|
||||
from py.lora_layout_cache import (
|
||||
MAX_AGE_SECONDS,
|
||||
MISSING,
|
||||
RACY_SECONDS,
|
||||
LayoutCache,
|
||||
)
|
||||
from py.lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical
|
||||
|
||||
SECOND_NS = 10**9
|
||||
# NTFS keeps modification times in 100 ns steps and FAT in 2 s steps: every
|
||||
# time a test sets is a whole millisecond, and a test whose file system rounds
|
||||
# it anyway skips (``set_mtime``).
|
||||
TICK_NS = 10**6
|
||||
|
||||
|
||||
def make_layout(settings_path=None, media_roots=(), error=None, source="user config"):
|
||||
return LoraManagerLayout(
|
||||
settings_path=settings_path,
|
||||
source=source,
|
||||
error=error,
|
||||
mode="alongside",
|
||||
lora_roots=tuple(media_roots),
|
||||
sidecar_root=None,
|
||||
example_images_root=None,
|
||||
library="default",
|
||||
media_roots=tuple(media_roots),
|
||||
)
|
||||
|
||||
|
||||
class CacheTestCase(unittest.TestCase):
|
||||
"""An install, its plugin settings file, a settings file and a LoRA root."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.tmp)
|
||||
self.install_dir = self.tmp / "custom_nodes" / "ComfyUI-Lora-Manager"
|
||||
self.install_dir.mkdir(parents=True)
|
||||
self.plugin_settings = self.install_dir / "settings.json"
|
||||
self.settings = self.tmp / "user-config" / "settings.json"
|
||||
self.settings.parent.mkdir()
|
||||
self.root_dir = self.tmp / "loras"
|
||||
self.root_dir.mkdir()
|
||||
# Files are written ten minutes before the injected wall clock, so
|
||||
# nothing is racy unless a test sets a recent modification time.
|
||||
self.now_ns = time.time_ns() // TICK_NS * TICK_NS
|
||||
self.mono = 1000.0
|
||||
self.resolved = []
|
||||
self.loaded = []
|
||||
self.install = _canonical(str(self.install_dir))
|
||||
self.layout = make_layout(
|
||||
settings_path=self.settings,
|
||||
media_roots=(_canonical(str(self.root_dir)),),
|
||||
)
|
||||
self.write(self.settings, "{}")
|
||||
|
||||
def write(self, path, text, mtime_ns=None):
|
||||
Path(path).write_text(text, encoding="utf-8")
|
||||
stamp = mtime_ns if mtime_ns is not None else self.now_ns - 600 * SECOND_NS
|
||||
self.set_mtime(path, stamp)
|
||||
|
||||
def set_mtime(self, path, stamp):
|
||||
os.utime(path, ns=(stamp, stamp))
|
||||
if os.stat(path).st_mtime_ns != stamp:
|
||||
self.skipTest("this file system rounds modification times")
|
||||
|
||||
def resolve(self, key):
|
||||
self.resolved.append(key)
|
||||
return self.install
|
||||
|
||||
def load(self, install):
|
||||
self.loaded.append(install)
|
||||
return self.layout
|
||||
|
||||
def cache(self, **kwargs):
|
||||
kwargs.setdefault("wall_ns", lambda: self.now_ns)
|
||||
kwargs.setdefault("monotonic", lambda: self.mono)
|
||||
return LayoutCache(
|
||||
kwargs.pop("resolve", self.resolve), kwargs.pop("load", self.load), **kwargs
|
||||
)
|
||||
|
||||
|
||||
class ValidationTests(CacheTestCase):
|
||||
def test_hit_reuses_the_entry_without_resolving_or_loading(self):
|
||||
cache = self.cache()
|
||||
first = cache.get("")
|
||||
second = cache.get("")
|
||||
self.assertEqual(len(self.resolved), 1)
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
self.assertIs(first[1], second[1])
|
||||
self.assertIs(first[0], second[0])
|
||||
self.assertEqual(first, (self.install, self.layout))
|
||||
|
||||
def test_settings_size_change_reloads_once(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
stamp = self.settings.stat().st_mtime_ns
|
||||
self.write(self.settings, '{"a": 1}', mtime_ns=stamp)
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_settings_mtime_change_reloads_once(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.set_mtime(self.settings, self.settings.stat().st_mtime_ns + TICK_NS)
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_settings_deletion_reloads_once(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.settings.unlink()
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_settings_creation_reloads_once(self):
|
||||
self.settings.unlink()
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.write(self.settings, "{}")
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_plugin_settings_change_reloads_once(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.write(self.plugin_settings, '{"use_portable_settings": true}')
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_a_different_configured_path_reloads(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
cache.get("other")
|
||||
self.assertEqual(self.resolved, ["", "other"])
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_a_different_home_folder_reloads(self):
|
||||
# The folder guard compares against the home folder: a cached layout
|
||||
# must not outlive a change of it.
|
||||
home = {"value": str(self.tmp / "home-a")}
|
||||
cache = self.cache(home=lambda: home["value"])
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
home["value"] = str(self.tmp / "home-b")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_configured_path_is_stripped_for_the_key_and_the_resolver(self):
|
||||
cache = self.cache()
|
||||
cache.get(" x ")
|
||||
cache.get("x")
|
||||
self.assertEqual(self.resolved, ["x"])
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
|
||||
def test_a_recently_modified_file_is_not_stored(self):
|
||||
self.write(self.settings, "{}", mtime_ns=self.now_ns - SECOND_NS)
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
self.assertIsNone(cache._entry)
|
||||
self.now_ns += RACY_SECONDS * SECOND_NS
|
||||
cache.get("")
|
||||
self.assertIsNotNone(cache._entry)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 3)
|
||||
|
||||
def test_racy_boundary(self):
|
||||
# Exactly RACY_SECONDS old is trusted; anything newer is not.
|
||||
boundary = self.now_ns - RACY_SECONDS * SECOND_NS
|
||||
self.write(self.settings, "{}", mtime_ns=boundary + TICK_NS)
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
self.set_mtime(self.settings, boundary)
|
||||
cache.get("")
|
||||
self.assertIsNotNone(cache._entry)
|
||||
|
||||
def test_a_modification_time_in_the_future_is_racy(self):
|
||||
self.write(self.plugin_settings, "{}", mtime_ns=self.now_ns + 60 * SECOND_NS)
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_a_missing_file_is_not_racy(self):
|
||||
self.assertFalse(self.plugin_settings.exists())
|
||||
self.settings.unlink()
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.assertIsNotNone(cache._entry)
|
||||
signatures = dict(cache._entry.signatures)
|
||||
self.assertTrue(all(value is MISSING for value in signatures.values()))
|
||||
self.assertEqual(len(signatures), 2)
|
||||
|
||||
def test_entry_is_reused_below_the_maximum_age_and_reloaded_at_it(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
stored_at = self.mono
|
||||
self.mono = stored_at + MAX_AGE_SECONDS - 0.5
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
self.mono = stored_at + MAX_AGE_SECONDS
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_a_wall_clock_moved_backward_does_not_extend_the_age(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.now_ns -= 3600 * SECOND_NS
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
# The old entry is too old; the new load sees files "from the future"
|
||||
# (racy) and is not stored, but it does load.
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_stat_failure_on_a_hit_reloads(self):
|
||||
failing = {"on": False}
|
||||
|
||||
def stat(path):
|
||||
if failing["on"]:
|
||||
raise PermissionError(13, "sharing violation", path)
|
||||
return os.stat(path)
|
||||
|
||||
cache = self.cache(stat=stat)
|
||||
cache.get("")
|
||||
failing["on"] = True
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_stat_failure_on_a_miss_leaves_the_layout_unstored(self):
|
||||
def stat(path):
|
||||
raise PermissionError(13, "sharing violation", path)
|
||||
|
||||
cache = self.cache(stat=stat)
|
||||
self.assertEqual(cache.get(""), (self.install, self.layout))
|
||||
self.assertIsNone(cache._entry)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_an_unreadable_settings_layout_is_not_stored(self):
|
||||
self.layout = make_layout(settings_path=self.settings, error=UNREADABLE)
|
||||
cache = self.cache()
|
||||
self.assertEqual(cache.get("")[1].error, UNREADABLE)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_other_layout_errors_are_stored(self):
|
||||
self.layout = make_layout(
|
||||
settings_path=self.settings, error="the settings file is not valid JSON"
|
||||
)
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
|
||||
def _symlink(self, link, target):
|
||||
try:
|
||||
os.symlink(target, link, target_is_directory=True)
|
||||
except (OSError, NotImplementedError) as error:
|
||||
self.skipTest(f"symlinks are not available here: {error}")
|
||||
|
||||
def test_a_media_root_swapped_for_a_symlink_reloads(self):
|
||||
elsewhere = self.tmp / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
shutil.rmtree(self.root_dir)
|
||||
self._symlink(self.root_dir, elsewhere)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_an_install_swapped_for_a_symlink_reloads(self):
|
||||
elsewhere = self.tmp / "elsewhere-lm"
|
||||
elsewhere.mkdir()
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
moved = self.tmp / "moved-lm"
|
||||
self.install_dir.rename(moved)
|
||||
self._symlink(self.install_dir, elsewhere)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_a_root_whose_realpath_fails_reloads(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
with patch.object(
|
||||
cache_module, "_canonical", side_effect=OSError("realpath failed")
|
||||
):
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_a_stat_value_error_cannot_validate(self):
|
||||
def stat(path):
|
||||
raise ValueError("embedded null character in path")
|
||||
|
||||
cache = self.cache(stat=stat)
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_not_a_directory_is_missing(self):
|
||||
def stat(path):
|
||||
raise NotADirectoryError(20, "Not a directory", path)
|
||||
|
||||
cache = self.cache(stat=stat)
|
||||
cache.get("")
|
||||
self.assertTrue(
|
||||
all(value is MISSING for _path, value in cache._entry.signatures)
|
||||
)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
|
||||
def test_an_offline_windows_share_cannot_validate(self):
|
||||
def stat(path):
|
||||
error = FileNotFoundError(2, "The network path was not found", path)
|
||||
error.winerror = 53
|
||||
raise error
|
||||
|
||||
cache = self.cache(stat=stat)
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_a_failed_reload_leaves_no_stale_entry(self):
|
||||
failing = {"on": False}
|
||||
|
||||
def load(install):
|
||||
self.loaded.append(install)
|
||||
if failing["on"]:
|
||||
raise RuntimeError("boom")
|
||||
return self.layout
|
||||
|
||||
cache = self.cache(load=load)
|
||||
cache.get("")
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
failing["on"] = True
|
||||
with self.assertRaises(RuntimeError):
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_a_resolve_failure_propagates_and_stores_nothing(self):
|
||||
def resolve(key):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
cache = self.cache(resolve=resolve)
|
||||
with self.assertRaises(RuntimeError):
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
|
||||
def test_without_a_settings_path_only_the_plugin_file_is_watched(self):
|
||||
self.layout = make_layout(media_roots=self.layout.media_roots)
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.assertEqual(
|
||||
[path for path, _sig in cache._entry.signatures],
|
||||
[os.path.join(self.install, "settings.json")],
|
||||
)
|
||||
|
||||
def test_portable_settings_are_watched_once(self):
|
||||
self.layout = make_layout(settings_path=self.install_dir / "settings.json")
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
self.assertEqual(len(cache._entry.signatures), 1)
|
||||
|
||||
def test_a_load_failure_propagates_and_stores_nothing(self):
|
||||
calls = []
|
||||
|
||||
def load(install):
|
||||
calls.append(install)
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("boom")
|
||||
return self.layout
|
||||
|
||||
cache = self.cache(load=load)
|
||||
with self.assertRaises(RuntimeError):
|
||||
cache.get("")
|
||||
self.assertIsNone(cache._entry)
|
||||
self.assertEqual(cache.get(""), (self.install, self.layout))
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_without_an_install_nothing_is_watched_and_the_entry_ages_out(self):
|
||||
stats = []
|
||||
|
||||
def stat(path):
|
||||
stats.append(path)
|
||||
return os.stat(path)
|
||||
|
||||
self.layout = make_layout(media_roots=self.layout.media_roots, source=None)
|
||||
cache = self.cache(resolve=lambda key: self.resolved.append(key), stat=stat)
|
||||
self.assertEqual(cache.get(""), (None, self.layout))
|
||||
self.assertEqual(cache._entry.signatures, ())
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
self.assertEqual(stats, [])
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
def test_windows_paths_differing_only_in_case_are_watched_once(self):
|
||||
stats = []
|
||||
|
||||
def stat(path):
|
||||
stats.append(path)
|
||||
return SimpleNamespace(st_mtime_ns=self.now_ns - 600 * SECOND_NS, st_size=2)
|
||||
|
||||
install = _canonical("C:\\ComfyUI\\custom_nodes\\LM", ntpath)
|
||||
# The hit check needs ntpath's realpath to be idempotent here.
|
||||
self.assertEqual(install, _canonical(install, ntpath))
|
||||
self.layout = make_layout(
|
||||
settings_path=PureWindowsPath(
|
||||
"C:\\ComfyUI\\custom_nodes\\lm\\SETTINGS.JSON"
|
||||
)
|
||||
)
|
||||
cache = self.cache(resolve=lambda key: install, stat=stat, pathmod=ntpath)
|
||||
cache.get("")
|
||||
self.assertEqual(len(cache._entry.signatures), 1)
|
||||
self.assertEqual(len(stats), 1)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 1)
|
||||
self.assertEqual(len(stats), 2)
|
||||
|
||||
|
||||
class ConcurrencyTests(CacheTestCase):
|
||||
def test_a_load_that_started_before_clear_stores_nothing(self):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def load(install):
|
||||
self.loaded.append(install)
|
||||
if len(self.loaded) == 1:
|
||||
started.set()
|
||||
self.assertTrue(release.wait(10))
|
||||
return self.layout
|
||||
|
||||
cache = self.cache(load=load)
|
||||
results = []
|
||||
worker = threading.Thread(target=lambda: results.append(cache.get("")))
|
||||
worker.start()
|
||||
self.assertTrue(started.wait(10))
|
||||
cache.clear()
|
||||
release.set()
|
||||
worker.join(10)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(results, [(self.install, self.layout)])
|
||||
self.assertIsNone(cache._entry)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
self.assertIsNotNone(cache._entry)
|
||||
|
||||
def test_concurrent_misses_all_get_a_layout_and_one_entry_is_stored(self):
|
||||
cache = self.cache()
|
||||
count = 6
|
||||
barrier = threading.Barrier(count)
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
barrier.wait(10)
|
||||
result = cache.get("")
|
||||
with lock:
|
||||
results.append(result)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(count)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(10)
|
||||
self.assertEqual(results, [(self.install, self.layout)] * count)
|
||||
self.assertIsNotNone(cache._entry)
|
||||
loads = len(self.loaded)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), loads)
|
||||
|
||||
def test_a_valid_hit_takes_no_lock(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
results = []
|
||||
with cache._lock:
|
||||
worker = threading.Thread(target=lambda: results.append(cache.get("")))
|
||||
worker.start()
|
||||
worker.join(10)
|
||||
alive = worker.is_alive()
|
||||
worker.join(10)
|
||||
self.assertFalse(alive)
|
||||
self.assertEqual(results, [(self.install, self.layout)])
|
||||
|
||||
def test_clear_forces_a_reload(self):
|
||||
cache = self.cache()
|
||||
cache.get("")
|
||||
cache.clear()
|
||||
self.assertIsNone(cache._entry)
|
||||
cache.get("")
|
||||
self.assertEqual(len(self.loaded), 2)
|
||||
|
||||
|
||||
class LoggingTests(CacheTestCase):
|
||||
LOGGER = "prompt_manager.lora_layout_cache"
|
||||
|
||||
def get(self, cache):
|
||||
with self.assertLogs(self.LOGGER, "DEBUG") as logs:
|
||||
cache.get("")
|
||||
# assertLogs needs one record; this marker is not counted.
|
||||
cache_module.logger.debug("marker")
|
||||
return [r for r in logs.records if r.getMessage() != "marker"]
|
||||
|
||||
def test_a_new_layout_logs_one_info_line(self):
|
||||
cache = self.cache()
|
||||
records = self.get(cache)
|
||||
self.assertEqual([r.levelname for r in records], ["INFO"])
|
||||
self.assertEqual(
|
||||
records[0].getMessage(),
|
||||
"LoraManager layout changed: user config settings, alongside storage, "
|
||||
"1 media folders",
|
||||
)
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
self.assertEqual(self.get(cache), []) # an equal layout, reloaded
|
||||
cache.clear()
|
||||
self.assertEqual(self.get(cache), [])
|
||||
self.layout = replace(self.layout, media_roots=(), lora_roots=())
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
self.assertEqual(len(self.get(cache)), 1)
|
||||
|
||||
def test_no_settings_source_prints_no(self):
|
||||
self.layout = replace(self.layout, source=None)
|
||||
records = self.get(self.cache())
|
||||
self.assertIn("layout changed: no settings,", records[0].getMessage())
|
||||
|
||||
def test_an_unstored_load_logs_no_info_line(self):
|
||||
self.write(self.settings, "{}", mtime_ns=self.now_ns)
|
||||
self.assertEqual(
|
||||
[r for r in self.get(self.cache()) if r.levelname == "INFO"], []
|
||||
)
|
||||
|
||||
def test_a_newly_refused_default_folder_logs_one_warning(self):
|
||||
self.layout = replace(self.layout, refused_defaults=("/home",))
|
||||
cache = self.cache()
|
||||
records = self.get(cache)
|
||||
warnings = [r for r in records if r.levelname == "WARNING"]
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn("/home", warnings[0].getMessage())
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
self.assertEqual(self.get(cache), [])
|
||||
cache.clear()
|
||||
self.assertEqual(self.get(cache), [])
|
||||
self.layout = replace(self.layout, refused_defaults=("/home", "/"))
|
||||
self.mono += MAX_AGE_SECONDS
|
||||
warnings = [r for r in self.get(cache) if r.levelname == "WARNING"]
|
||||
self.assertEqual([w.getMessage().count("'/'") for w in warnings], [1])
|
||||
|
||||
|
||||
class ModuleTests(unittest.TestCase):
|
||||
def test_constants(self):
|
||||
self.assertEqual(cache_module.RACY_SECONDS, 3)
|
||||
self.assertEqual(cache_module.MAX_AGE_SECONDS, 30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -33,6 +33,10 @@ MEDIA = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".bmp"}
|
||||
)
|
||||
MARKER = "marker-from-unkept-settings-0123"
|
||||
BAD_SIDECAR_REASON = (
|
||||
"the sidecar folder is a filesystem root, the home folder or a folder "
|
||||
"that contains it"
|
||||
)
|
||||
|
||||
|
||||
class LayoutTestCase(unittest.TestCase):
|
||||
@@ -261,11 +265,26 @@ class UnusableLayoutTests(LayoutTestCase):
|
||||
{"sidecar_storage_mode": "centralized", "sidecar_storage_path": "/"},
|
||||
)
|
||||
layout = self.load()
|
||||
self.assertEqual(
|
||||
layout.error, "the sidecar folder is a filesystem root or the home folder"
|
||||
)
|
||||
self.assertEqual(layout.error, BAD_SIDECAR_REASON)
|
||||
self.assertNotIn(os.path.normcase(os.path.realpath("/")), layout.media_roots)
|
||||
|
||||
def test_a_centralized_sidecar_root_that_contains_home(self):
|
||||
home = self.tmp / "outer" / "me"
|
||||
home.mkdir(parents=True)
|
||||
self.write_settings(
|
||||
self.user_config,
|
||||
{
|
||||
"sidecar_storage_mode": "centralized",
|
||||
"sidecar_storage_path": str(self.tmp / "outer"),
|
||||
},
|
||||
)
|
||||
with patch.object(layout_module.Path, "home", return_value=home):
|
||||
layout = self.load()
|
||||
self.assertEqual(layout.error, BAD_SIDECAR_REASON)
|
||||
self.assertEqual(
|
||||
layout.media_roots, (os.path.normcase(str(self.default_loras)),)
|
||||
)
|
||||
|
||||
|
||||
class SecretTests(LayoutTestCase):
|
||||
|
||||
@@ -501,6 +520,31 @@ class CentralizedDiscoveryTests(LayoutTestCase):
|
||||
self.assertEqual(discovery.scanned, 3)
|
||||
self.assertEqual(discovery.sidecars, [])
|
||||
|
||||
def test_every_scanned_file_is_listed_resolved_and_sorted(self):
|
||||
# #362: the fingerprint covers every scanned sidecar, kept or skipped.
|
||||
ckpt = self.model(self.checkpoints, "base")
|
||||
alpha = self.model(self.loras, "alpha")
|
||||
self.sidecar(self.sidecars / "z", "base", {"file_path": str(ckpt)})
|
||||
self.sidecar(self.sidecars / "a", "alpha", {"file_path": str(alpha)})
|
||||
self.sidecar(self.sidecars / "m", "nopath", {"model_name": "x"})
|
||||
discovery = discover_sidecars(self.load())
|
||||
files = discovery.scanned_files
|
||||
self.assertIsInstance(files, tuple)
|
||||
self.assertEqual(len(files), 3)
|
||||
self.assertEqual(discovery.scanned, len(files))
|
||||
self.assertEqual([str(f) for f in files], sorted(str(f) for f in files))
|
||||
self.assertEqual(
|
||||
[f.name for f in files],
|
||||
[
|
||||
f"alpha{METADATA_SUFFIX}",
|
||||
f"nopath{METADATA_SUFFIX}",
|
||||
f"base{METADATA_SUFFIX}",
|
||||
],
|
||||
)
|
||||
for path in files:
|
||||
self.assertEqual(str(path), os.path.realpath(path))
|
||||
self.assertEqual(len(discovery.sidecars), 1)
|
||||
|
||||
def test_leftover_sidecars_beside_models_are_not_scanned(self):
|
||||
model = self.model(self.loras, "alpha")
|
||||
self.sidecar(self.loras, "alpha", {"file_path": str(model)})
|
||||
@@ -611,6 +655,16 @@ class LocalMediaTests(LayoutTestCase):
|
||||
self.touch(self.default_loras / "alpha.webp")
|
||||
self.assertEqual(self.media({"preview_url": preview}), [preview])
|
||||
|
||||
def test_a_relative_preview_url_is_logged_and_skipped(self):
|
||||
fallback = self.touch(self.default_loras / "alpha.webp")
|
||||
self.touch(self.default_loras / "relative.png")
|
||||
with self.assertLogs("prompt_manager.lora_manager_layout", "DEBUG") as logs:
|
||||
media = self.media({"preview_url": "relative.png"})
|
||||
self.assertEqual(media, [fallback])
|
||||
self.assertEqual(len(logs.records), 1)
|
||||
self.assertEqual(logs.records[0].levelname, "DEBUG")
|
||||
self.assertIn(self.meta.name, logs.output[0])
|
||||
|
||||
def test_preview_url_outside_media_roots_or_not_servable_is_ignored(self):
|
||||
outside = self.touch(self.tmp / "elsewhere" / "p.png")
|
||||
avif = self.touch(self.default_loras / "p.avif")
|
||||
@@ -810,6 +864,63 @@ class FolderGuardTests(unittest.TestCase):
|
||||
self.assertFalse(layout_module._inside(str(self.tmp / "other.png"), (root,)))
|
||||
|
||||
|
||||
class RefusedDefaultsTests(LayoutTestCase):
|
||||
"""#362: ComfyUI's default LoRA folders pass the same guard; the refused
|
||||
ones that exist are listed so the layout cache can warn about them."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.outer = self.tmp / "outer"
|
||||
self.home = self.outer / "me"
|
||||
self.home.mkdir(parents=True)
|
||||
self.fs_root = os.path.abspath(os.sep)
|
||||
self.defaults = [
|
||||
str(self.home),
|
||||
str(self.outer),
|
||||
self.fs_root,
|
||||
str(self.tmp / "missing"),
|
||||
str(self.default_loras),
|
||||
]
|
||||
self.expected = (
|
||||
layout_module._canonical(str(self.home)),
|
||||
layout_module._canonical(str(self.outer)),
|
||||
layout_module._canonical(self.fs_root),
|
||||
)
|
||||
home_patch = patch.object(layout_module.Path, "home", return_value=self.home)
|
||||
home_patch.start()
|
||||
self.addCleanup(home_patch.stop)
|
||||
|
||||
def load_defaults(self, path="__plugin__"):
|
||||
return self.load(path, default_roots=lambda: list(self.defaults))
|
||||
|
||||
def test_refused_existing_defaults_are_listed_on_a_usable_layout(self):
|
||||
self.write_settings(self.user_config, {})
|
||||
layout = self.load_defaults()
|
||||
self.assertIsNone(layout.error)
|
||||
self.assertEqual(layout.refused_defaults, self.expected)
|
||||
self.assertEqual(
|
||||
layout.lora_roots, (os.path.normcase(str(self.default_loras)),)
|
||||
)
|
||||
|
||||
def test_they_are_listed_on_an_unusable_centralized_layout(self):
|
||||
self.write_settings(
|
||||
self.user_config,
|
||||
{"sidecar_storage_mode": "centralized", "sidecar_storage_path": "/"},
|
||||
)
|
||||
layout = self.load_defaults()
|
||||
self.assertEqual(layout.error, BAD_SIDECAR_REASON)
|
||||
self.assertEqual(layout.refused_defaults, self.expected)
|
||||
|
||||
def test_they_are_listed_without_an_install(self):
|
||||
layout = self.load_defaults(None)
|
||||
self.assertEqual(layout.error, "no LoraManager install found")
|
||||
self.assertEqual(layout.refused_defaults, self.expected)
|
||||
|
||||
def test_nothing_is_listed_when_every_default_is_accepted(self):
|
||||
self.write_settings(self.user_config, {})
|
||||
self.assertEqual(self.load().refused_defaults, ())
|
||||
|
||||
|
||||
class SummaryTests(LayoutTestCase):
|
||||
|
||||
def test_summary_names_what_the_import_read(self):
|
||||
|
||||
@@ -224,6 +224,14 @@ class TestReadLoraMetadata(unittest.TestCase):
|
||||
result = read_lora_metadata(Path("/nonexistent/file.metadata.json"))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_returns_none_for_a_file_that_is_not_utf8(self):
|
||||
# #362: one malformed sidecar is skipped; it must not fail the whole
|
||||
# trigger-word load (now logged with a traceback) or the review.
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / "bad.metadata.json"
|
||||
path.write_bytes(b'{"model_name": "\xff\xfe"}')
|
||||
self.assertIsNone(read_lora_metadata(path))
|
||||
|
||||
|
||||
class TestGetLoraImageCacheDir(unittest.TestCase):
|
||||
"""Test get_lora_image_cache_dir — returns and creates cache path."""
|
||||
@@ -1791,6 +1799,34 @@ class TestTriggerWordCacheLayout(unittest.TestCase):
|
||||
self.assertTrue(cache.is_loaded)
|
||||
self.assertTrue(cache.retry_due())
|
||||
|
||||
def test_an_os_error_is_a_warning_without_a_traceback(self):
|
||||
cache = TriggerWordCache()
|
||||
cache._cache = {"old": ["word"]}
|
||||
with (
|
||||
patch.object(TriggerWordCache, "_build", side_effect=OSError("denied")),
|
||||
self.assertLogs("prompt_manager.lora_utils", level="WARNING") as logs,
|
||||
):
|
||||
self.assertEqual(cache.load(str(self.lm)), 1)
|
||||
self.assertEqual([r.levelname for r in logs.records], ["WARNING"])
|
||||
self.assertIsNone(logs.records[0].exc_info)
|
||||
self.assertEqual(cache.get_trigger_words("old"), ["word"])
|
||||
self.assertFalse(cache.retry_due())
|
||||
|
||||
def test_an_unexpected_failure_logs_its_traceback(self):
|
||||
# #362: a programming error must not look like a disk error.
|
||||
cache = TriggerWordCache()
|
||||
cache._cache = {"old": ["word"]}
|
||||
with (
|
||||
patch.object(TriggerWordCache, "_build", side_effect=ValueError("bug")),
|
||||
self.assertLogs("prompt_manager.lora_utils", level="WARNING") as logs,
|
||||
):
|
||||
self.assertEqual(cache.load(str(self.lm)), 1)
|
||||
self.assertEqual(len(logs.records), 1)
|
||||
self.assertIsNotNone(logs.records[0].exc_info)
|
||||
self.assertIs(logs.records[0].exc_info[0], ValueError)
|
||||
self.assertEqual(cache.get_trigger_words("old"), ["word"])
|
||||
self.assertFalse(cache.retry_due())
|
||||
|
||||
def test_any_other_failure_never_raises(self):
|
||||
cache = TriggerWordCache()
|
||||
with (
|
||||
|
||||
@@ -1037,6 +1037,23 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
|
||||
settings = (await self._get())["settings"]
|
||||
self.assertEqual({key: settings[key] for key in self.SAVED}, self.SAVED)
|
||||
|
||||
async def test_threshold_near_the_grid_is_saved_on_it(self):
|
||||
"""#369: a computed value a hair off the 0.05 grid is stored on it, so
|
||||
every page's step check accepts what /settings and the file return."""
|
||||
revision = (await self._get())["revision"]
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings",
|
||||
{"expected_revision": revision, "autotag_general_threshold": 0.1 + 0.2},
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(
|
||||
repr(self._read_config()["autotag"]["defaults"]["general_threshold"]),
|
||||
"0.3",
|
||||
)
|
||||
self.assertEqual(
|
||||
repr((await self._get())["settings"]["autotag_general_threshold"]), "0.3"
|
||||
)
|
||||
|
||||
async def test_stale_revision_changes_no_default(self):
|
||||
revision = (await self._get())["revision"]
|
||||
self._write_config({"custom": {"edited": "elsewhere"}})
|
||||
@@ -1055,6 +1072,16 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
|
||||
1.5,
|
||||
"General-tag confidence must be between 0 and 1.",
|
||||
),
|
||||
(
|
||||
"autotag_general_threshold",
|
||||
0.37,
|
||||
"General-tag confidence must be a multiple of 0.05.",
|
||||
),
|
||||
(
|
||||
"autotag_character_threshold",
|
||||
0.33,
|
||||
"Character-tag confidence must be a multiple of 0.05.",
|
||||
),
|
||||
(
|
||||
"autotag_character_threshold",
|
||||
"0.8",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""PromptManagerLogger: memory buffer, file rotation, level changes, truncation."""
|
||||
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -11,14 +13,20 @@ import unittest.mock as mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils import logging_config
|
||||
from utils.logging_config import (
|
||||
from utils import logging_config # noqa: E402
|
||||
from utils.logging_config import ( # noqa: E402
|
||||
MemoryBufferHandler,
|
||||
PromptManagerLogger,
|
||||
RotatedChanged,
|
||||
get_logger,
|
||||
get_logger_manager,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed # noqa: E402
|
||||
|
||||
|
||||
class IsolatedLoggerTestCase(unittest.TestCase):
|
||||
"""A logger manager whose log directory is a temporary folder."""
|
||||
@@ -32,6 +40,7 @@ class IsolatedLoggerTestCase(unittest.TestCase):
|
||||
with mock.patch.object(logging_config, "__file__", fake_file):
|
||||
self.manager = object.__new__(PromptManagerLogger) # bypass singleton
|
||||
self.manager.__init__()
|
||||
self.allowed_thread_names = set()
|
||||
self.manager.update_config({"console_logging": False})
|
||||
# Drop the "Updated logging configuration" entry.
|
||||
self.manager._log_buffer.clear()
|
||||
@@ -53,12 +62,21 @@ class IsolatedLoggerTestCase(unittest.TestCase):
|
||||
|
||||
def _filter_to_this_thread(self):
|
||||
this_thread = threading.get_ident()
|
||||
# A test may let named helper threads of its own through too.
|
||||
allowed_names = self.allowed_thread_names
|
||||
|
||||
def allowed(record):
|
||||
return record.thread == this_thread or record.threadName in allowed_names
|
||||
|
||||
for handler in self.manager.logger.handlers:
|
||||
handler.addFilter(lambda record: record.thread == this_thread)
|
||||
handler.filters.clear()
|
||||
handler.addFilter(allowed)
|
||||
|
||||
def _restore_global_logging(self):
|
||||
for handler in logging.getLogger("prompt_manager").handlers[:]:
|
||||
handler.close()
|
||||
# Windows refuses to delete an open log: prove every handle is closed.
|
||||
assert_closed(self, self.tmp.name)
|
||||
get_logger_manager()._setup_loggers()
|
||||
self.tmp.cleanup()
|
||||
|
||||
@@ -175,15 +193,14 @@ class TestFilesAndRotation(IsolatedLoggerTestCase):
|
||||
|
||||
def test_unreadable_files_are_skipped_in_the_listing(self):
|
||||
self.logger.info("something")
|
||||
original_stat = pathlib.Path.stat
|
||||
original_lstat = os.lstat
|
||||
|
||||
def stat_failing_for_logs(path, *args, **kwargs):
|
||||
if path.suffix == ".log":
|
||||
def lstat_failing_for_logs(path, *args, **kwargs):
|
||||
if str(path).endswith(".log"):
|
||||
raise OSError("denied")
|
||||
return original_stat(path, *args, **kwargs)
|
||||
return original_lstat(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(pathlib.Path, "stat", autospec=True) as stat:
|
||||
stat.side_effect = stat_failing_for_logs
|
||||
with mock.patch.object(logging_config.os, "lstat", lstat_failing_for_logs):
|
||||
self.assertEqual(self.manager.get_log_files(), [])
|
||||
|
||||
def test_truncate_does_not_roll_the_log_over_while_deleting_backups(self):
|
||||
@@ -228,10 +245,522 @@ class TestFilesAndRotation(IsolatedLoggerTestCase):
|
||||
|
||||
def test_truncate_reports_files_it_cannot_remove(self):
|
||||
self._rotate()
|
||||
with mock.patch.object(pathlib.Path, "unlink", side_effect=OSError("busy")):
|
||||
|
||||
def busy(path, *args, **kwargs):
|
||||
raise PermissionError(13, "busy", str(path))
|
||||
|
||||
with mock.patch.object(logging_config.os, "unlink", busy):
|
||||
result = self.manager.truncate_logs()
|
||||
self.assertEqual(len(result["errors"]), 2)
|
||||
self.assertEqual(result["truncated"], ["prompt_manager.log"])
|
||||
for error in result["errors"]:
|
||||
self.assertNotIn(self.tmp.name, error)
|
||||
self.assertRegex(error, r"prompt_manager\.log\.[12]")
|
||||
|
||||
|
||||
class TestRotatedCopyRule(IsolatedLoggerTestCase):
|
||||
"""Rule R (#329): only prompt_manager.log.<n> regular files are deletable."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.logger.info("active log line") # the active log exists
|
||||
self.outside = os.path.join(self.tmp.name, "outside")
|
||||
os.makedirs(self.outside)
|
||||
|
||||
def _path(self, name):
|
||||
return os.path.join(self.log_dir, name)
|
||||
|
||||
def _write(self, name, data=b"rotated\n"):
|
||||
with open(self._path(name), "wb") as handle:
|
||||
handle.write(data)
|
||||
return self._path(name)
|
||||
|
||||
def _symlink(self, name, target_name="secret.txt", data=b"secret " * 1000):
|
||||
"""A symlink in the log folder to a file outside it, or None."""
|
||||
target = os.path.join(self.outside, target_name)
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(data)
|
||||
try:
|
||||
os.symlink(target, self._path(name))
|
||||
except (OSError, NotImplementedError): # pragma: no cover - no privilege
|
||||
return None
|
||||
return target
|
||||
|
||||
def _require_symlink(self, name, **kwargs):
|
||||
target = self._symlink(name, **kwargs)
|
||||
if target is None: # pragma: no cover - Windows without the privilege
|
||||
self.skipTest("this account cannot create symlinks")
|
||||
return target
|
||||
|
||||
def _kinds(self):
|
||||
return {row["name"]: row["kind"] for row in self.manager.scan_log_folder()}
|
||||
|
||||
def _rotated_rows(self):
|
||||
return [r for r in self.manager.scan_log_folder() if r["kind"] == "rotated"]
|
||||
|
||||
def _names(self):
|
||||
return sorted(os.listdir(self.log_dir))
|
||||
|
||||
def _file_handler(self):
|
||||
return next(
|
||||
h
|
||||
for h in self.manager.logger.handlers
|
||||
if isinstance(h, logging.FileHandler)
|
||||
)
|
||||
|
||||
def test_scan_classes_rotated_active_link_and_other(self):
|
||||
for name in ("prompt_manager.log.1", "prompt_manager.log.2"):
|
||||
self._write(name)
|
||||
self._write("prompt_manager.log.3", b"x" * 3000)
|
||||
for name in (
|
||||
"prompt_manager.log.0",
|
||||
"prompt_manager.log.01",
|
||||
"prompt_manager.log.bak",
|
||||
"prompt_manager.log.1.gz",
|
||||
"prompt_manager.log.-1",
|
||||
"notes.txt",
|
||||
):
|
||||
self._write(name)
|
||||
os.makedirs(self._path("prompt_manager.log.5"))
|
||||
self._require_symlink("prompt_manager.log.4")
|
||||
kinds = self._kinds()
|
||||
self.assertEqual(
|
||||
kinds,
|
||||
{
|
||||
"prompt_manager.log": "active",
|
||||
"prompt_manager.log.1": "rotated",
|
||||
"prompt_manager.log.2": "rotated",
|
||||
"prompt_manager.log.3": "rotated",
|
||||
"prompt_manager.log.0": "other",
|
||||
"prompt_manager.log.01": "other",
|
||||
"prompt_manager.log.bak": "other",
|
||||
"prompt_manager.log.1.gz": "other",
|
||||
"prompt_manager.log.-1": "other",
|
||||
"notes.txt": "other",
|
||||
"prompt_manager.log.5": "other",
|
||||
"prompt_manager.log.4": "link",
|
||||
},
|
||||
)
|
||||
row = next(
|
||||
r
|
||||
for r in self.manager.scan_log_folder()
|
||||
if r["name"] == "prompt_manager.log.3"
|
||||
)
|
||||
self.assertEqual(
|
||||
set(row), {"name", "kind", "size", "mtime_ns", "ino", "modified"}
|
||||
)
|
||||
stat = os.lstat(self._path("prompt_manager.log.3"))
|
||||
self.assertEqual(row["size"], 3000)
|
||||
self.assertEqual(row["mtime_ns"], stat.st_mtime_ns)
|
||||
self.assertEqual(row["ino"], stat.st_ino)
|
||||
|
||||
def test_scan_matches_names_case_insensitively_on_windows(self):
|
||||
self._write("PROMPT_MANAGER.LOG.2")
|
||||
if os.name == "nt": # pragma: no cover - native case folding
|
||||
self.assertEqual(self._kinds()["PROMPT_MANAGER.LOG.2"], "rotated")
|
||||
return
|
||||
# POSIX names are case-sensitive: another file, never deleted.
|
||||
self.assertEqual(self._kinds()["PROMPT_MANAGER.LOG.2"], "other")
|
||||
with mock.patch.object(logging_config, "_name_key", ntpath.normcase):
|
||||
self.assertEqual(self._kinds()["PROMPT_MANAGER.LOG.2"], "rotated")
|
||||
result = self.manager.delete_rotated(self._rotated_rows())
|
||||
self.assertEqual(result["removed"], ["PROMPT_MANAGER.LOG.2"])
|
||||
|
||||
def test_a_junction_is_classed_as_a_link_and_kept(self):
|
||||
# Portable control: whatever os.path.isjunction reports is a link.
|
||||
self._write("prompt_manager.log.4")
|
||||
self._write("prompt_manager.log.1")
|
||||
junction = self._path("prompt_manager.log.4")
|
||||
real_isjunction = os.path.isjunction
|
||||
|
||||
def isjunction(path):
|
||||
return os.fspath(path) == junction or real_isjunction(path)
|
||||
|
||||
with mock.patch.object(logging_config.os.path, "isjunction", isjunction):
|
||||
self.assertEqual(self._kinds()["prompt_manager.log.4"], "link")
|
||||
self.manager.delete_rotated(self._rotated_rows())
|
||||
self.manager.truncate_logs()
|
||||
self.assertIn("prompt_manager.log.4", self._names())
|
||||
self.assertNotIn("prompt_manager.log.1", self._names())
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "junctions exist only on Windows")
|
||||
def test_junction_is_never_deleted_on_windows(self): # pragma: no cover
|
||||
import _winapi
|
||||
|
||||
target = os.path.join(self.outside, "kept")
|
||||
os.makedirs(target)
|
||||
with open(os.path.join(target, "inside.txt"), "wb") as handle:
|
||||
handle.write(b"keep me")
|
||||
_winapi.CreateJunction(target, self._path("prompt_manager.log.4"))
|
||||
self._write("prompt_manager.log.1")
|
||||
self.assertEqual(self._kinds()["prompt_manager.log.4"], "link")
|
||||
self.manager.delete_rotated(self._rotated_rows())
|
||||
self.manager.truncate_logs()
|
||||
self.assertIn("prompt_manager.log.4", self._names())
|
||||
self.assertTrue(os.path.exists(os.path.join(target, "inside.txt")))
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
self.manager.log_file_entry("prompt_manager.log.4")
|
||||
os.rmdir(self._path("prompt_manager.log.4")) # removes the junction only
|
||||
|
||||
def test_delete_rotated_removes_exactly_the_listed_files(self):
|
||||
for n in (1, 2, 3):
|
||||
self._write(f"prompt_manager.log.{n}", b"r" * n)
|
||||
self._write("prompt_manager.log.bak")
|
||||
os.makedirs(self._path("prompt_manager.log.7"))
|
||||
target = self._symlink("prompt_manager.log.4")
|
||||
listed = [
|
||||
r for r in self._rotated_rows() if r["name"] != "prompt_manager.log.3"
|
||||
]
|
||||
result = self.manager.delete_rotated(listed)
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"removed": ["prompt_manager.log.1", "prompt_manager.log.2"], "failed": []},
|
||||
)
|
||||
expected = [
|
||||
"prompt_manager.log",
|
||||
"prompt_manager.log.3",
|
||||
"prompt_manager.log.7",
|
||||
"prompt_manager.log.bak",
|
||||
]
|
||||
if target is not None:
|
||||
expected.append("prompt_manager.log.4")
|
||||
self.assertTrue(os.path.exists(target))
|
||||
self.assertEqual(self._names(), sorted(expected))
|
||||
|
||||
def test_delete_rotated_refuses_all_when_one_changed(self):
|
||||
def change_size(path):
|
||||
with open(path, "ab") as handle:
|
||||
handle.write(b"more")
|
||||
|
||||
def change_mtime(path):
|
||||
stat = os.lstat(path)
|
||||
os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 5_000_000_000))
|
||||
|
||||
def change_inode(path):
|
||||
stat = os.lstat(path)
|
||||
with open(path, "rb") as handle:
|
||||
data = handle.read()
|
||||
other = path + ".new"
|
||||
with open(other, "wb") as handle:
|
||||
handle.write(data)
|
||||
os.utime(other, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
||||
os.replace(other, path)
|
||||
if os.lstat(path).st_ino in (0, stat.st_ino): # pragma: no cover
|
||||
self.skipTest("this filesystem reports no inode numbers")
|
||||
|
||||
def remove(path):
|
||||
os.unlink(path)
|
||||
|
||||
def replace_by_link(path):
|
||||
os.unlink(path)
|
||||
target = os.path.join(self.outside, "elsewhere.txt")
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(b"rotated\n")
|
||||
try:
|
||||
os.symlink(target, path)
|
||||
except (OSError, NotImplementedError): # pragma: no cover
|
||||
self.skipTest("this account cannot create symlinks")
|
||||
|
||||
changes = {
|
||||
"size": change_size,
|
||||
"mtime": change_mtime,
|
||||
"inode": change_inode,
|
||||
"missing": remove,
|
||||
"link": replace_by_link,
|
||||
}
|
||||
for label, change in changes.items():
|
||||
with self.subTest(change=label):
|
||||
for n in (1, 2, 3):
|
||||
self._write(f"prompt_manager.log.{n}", b"rotated\n")
|
||||
reviewed = self._rotated_rows()
|
||||
change(self._path("prompt_manager.log.2"))
|
||||
before = self._names()
|
||||
with self.assertRaises(RotatedChanged):
|
||||
self.manager.delete_rotated(reviewed)
|
||||
self.assertEqual(self._names(), before)
|
||||
self.assertIn("prompt_manager.log.1", before)
|
||||
self.assertIn("prompt_manager.log.3", before)
|
||||
for name in self._names():
|
||||
if name.startswith("prompt_manager.log."):
|
||||
os.unlink(self._path(name))
|
||||
|
||||
def test_rollover_waits_while_cleanup_holds_the_handler_lock(self):
|
||||
# The file is already over its limit: the next record rolls it over,
|
||||
# renaming .1 to .2 and the active log to .1.
|
||||
self.manager.update_config({"max_file_size": 64, "backup_count": 3})
|
||||
self.logger.info("x" * 100)
|
||||
self._write("prompt_manager.log.1", b"first copy\n")
|
||||
self._write("prompt_manager.log.2", b"second copy\n")
|
||||
reviewed = self._rotated_rows()
|
||||
holding = threading.Event()
|
||||
release = threading.Event()
|
||||
logged = threading.Event()
|
||||
real_unlink = os.unlink
|
||||
self.allowed_thread_names.add("pm-test-rollover")
|
||||
|
||||
def log_from_another_thread():
|
||||
self.logger.info("rolls the log over")
|
||||
logged.set()
|
||||
|
||||
worker = threading.Thread(
|
||||
target=log_from_another_thread, name="pm-test-rollover", daemon=True
|
||||
)
|
||||
|
||||
def slow_unlink(path, *args, **kwargs):
|
||||
if not holding.is_set():
|
||||
worker.start() # its record must wait for the cleanup's lock
|
||||
holding.set()
|
||||
if not release.wait(10):
|
||||
raise RuntimeError("the test never released the cleanup")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
result = {}
|
||||
|
||||
def cleanup():
|
||||
result.update(self.manager.delete_rotated(reviewed))
|
||||
|
||||
with mock.patch.object(logging_config.os, "unlink", slow_unlink):
|
||||
cleaner = threading.Thread(target=cleanup, daemon=True)
|
||||
cleaner.start()
|
||||
self.assertTrue(holding.wait(10), "the cleanup never started deleting")
|
||||
# The record waits on the handler lock the cleanup holds.
|
||||
self.assertFalse(logged.wait(0.3), "a record was written mid-cleanup")
|
||||
release.set()
|
||||
cleaner.join(10)
|
||||
worker.join(10)
|
||||
self.assertFalse(cleaner.is_alive())
|
||||
self.assertTrue(logged.is_set())
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"removed": ["prompt_manager.log.1", "prompt_manager.log.2"], "failed": []},
|
||||
)
|
||||
# The rollover ran after the cleanup: only the old active log moved.
|
||||
self.assertEqual(self._names(), ["prompt_manager.log", "prompt_manager.log.1"])
|
||||
with open(self._path("prompt_manager.log.1"), "rb") as handle:
|
||||
self.assertIn(b"x" * 100, handle.read())
|
||||
|
||||
def test_open_rotated_copy_is_reported_not_deleted(self):
|
||||
for n in (1, 2, 3):
|
||||
self._write(f"prompt_manager.log.{n}")
|
||||
real_unlink = os.unlink
|
||||
held = self._path("prompt_manager.log.2")
|
||||
|
||||
def unlink(path, *args, **kwargs):
|
||||
if os.fspath(path) == held:
|
||||
error = PermissionError(
|
||||
13,
|
||||
"The process cannot access the file because it is being "
|
||||
"used by another process",
|
||||
held,
|
||||
)
|
||||
error.winerror = 32
|
||||
raise error
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(logging_config.os, "unlink", unlink):
|
||||
result = self.manager.delete_rotated(self._rotated_rows())
|
||||
self.assertEqual(
|
||||
result["removed"], ["prompt_manager.log.1", "prompt_manager.log.3"]
|
||||
)
|
||||
[(name, error)] = result["failed"]
|
||||
self.assertEqual(name, "prompt_manager.log.2")
|
||||
self.assertIsInstance(error, PermissionError)
|
||||
self.assertIn("prompt_manager.log.2", self._names())
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "only Windows refuses to unlink an open file")
|
||||
def test_open_rotated_copy_is_reported_not_deleted_on_windows(
|
||||
self,
|
||||
): # pragma: no cover
|
||||
for n in (1, 2, 3):
|
||||
self._write(f"prompt_manager.log.{n}")
|
||||
reviewed = self._rotated_rows()
|
||||
handle = open(self._path("prompt_manager.log.2"), "rb")
|
||||
try:
|
||||
result = self.manager.delete_rotated(reviewed)
|
||||
finally:
|
||||
handle.close()
|
||||
self.assertEqual(
|
||||
result["removed"], ["prompt_manager.log.1", "prompt_manager.log.3"]
|
||||
)
|
||||
[(name, error)] = result["failed"]
|
||||
self.assertEqual(name, "prompt_manager.log.2")
|
||||
self.assertIsInstance(error, PermissionError)
|
||||
self.assertIn("prompt_manager.log.2", self._names())
|
||||
|
||||
def test_cleanup_never_logs_while_holding_the_handler_lock(self):
|
||||
for n in (1, 2):
|
||||
self._write(f"prompt_manager.log.{n}")
|
||||
handler = self._file_handler()
|
||||
depth = 0
|
||||
calls = []
|
||||
real_acquire, real_release = handler.acquire, handler.release
|
||||
|
||||
def acquire():
|
||||
nonlocal depth
|
||||
real_acquire()
|
||||
depth += 1
|
||||
|
||||
def release():
|
||||
nonlocal depth
|
||||
depth -= 1
|
||||
real_release()
|
||||
|
||||
real_call_handlers = logging.Logger.callHandlers
|
||||
|
||||
def call_handlers(logger_self, record):
|
||||
calls.append((record.getMessage(), depth))
|
||||
return real_call_handlers(logger_self, record)
|
||||
|
||||
with (
|
||||
mock.patch.object(handler, "acquire", acquire),
|
||||
mock.patch.object(handler, "release", release),
|
||||
mock.patch.object(logging.Logger, "callHandlers", call_handlers),
|
||||
):
|
||||
self.manager.delete_rotated(self._rotated_rows())
|
||||
self.assertEqual(depth, 0)
|
||||
self._write("prompt_manager.log.1")
|
||||
self.manager.truncate_logs()
|
||||
self.assertEqual(depth, 0)
|
||||
self.assertTrue(calls, "the cleanup logged no summary")
|
||||
self.assertEqual([c for c in calls if c[1]], [])
|
||||
|
||||
def test_truncate_keeps_other_files_and_links(self):
|
||||
# Corrected (#329): the legacy glob deleted every prompt_manager.log*.
|
||||
self._rotate()
|
||||
self._write("prompt_manager.log.bak", b"mine")
|
||||
self._write("prompt_manager.log.1.gz", b"zip")
|
||||
target = self._symlink("prompt_manager.log.4")
|
||||
result = self.manager.truncate_logs()
|
||||
self.assertEqual(result["errors"], [])
|
||||
self.assertEqual(
|
||||
sorted(result["truncated"]),
|
||||
["prompt_manager.log", "prompt_manager.log.1", "prompt_manager.log.2"],
|
||||
)
|
||||
names = self._names()
|
||||
self.assertIn("prompt_manager.log.bak", names)
|
||||
self.assertIn("prompt_manager.log.1.gz", names)
|
||||
self.assertNotIn("prompt_manager.log.1", names)
|
||||
if target is not None:
|
||||
self.assertIn("prompt_manager.log.4", names)
|
||||
self.assertTrue(os.path.islink(self._path("prompt_manager.log.4")))
|
||||
with open(target, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"secret " * 1000)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "Windows cannot rename a log that is open")
|
||||
def test_truncate_never_empties_a_renamed_log_the_handler_still_writes(self):
|
||||
# The user renamed the active log to keep it; the handler still has it
|
||||
# open. Clear must empty the new prompt_manager.log, not the kept copy.
|
||||
self.logger.info("worth keeping")
|
||||
kept = os.path.join(self.log_dir, "saved_copy.txt")
|
||||
os.rename(self._path("prompt_manager.log"), kept)
|
||||
with open(kept, "rb") as handle:
|
||||
before = handle.read()
|
||||
self._write("prompt_manager.log", b"fresh active log\n")
|
||||
result = self.manager.truncate_logs()
|
||||
self.assertEqual(result["errors"], [])
|
||||
with open(kept, "rb") as handle:
|
||||
self.assertTrue(handle.read().startswith(before))
|
||||
with open(self._path("prompt_manager.log"), "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"")
|
||||
|
||||
def test_name_surrogate_reparse_points_are_links_and_placeholders_are_not(self):
|
||||
regular = stat.S_IFREG | 0o644
|
||||
path = self._write("prompt_manager.log.3")
|
||||
for tag, is_link in (
|
||||
(0xA000001D, True), # WSL symlink
|
||||
(0xA0000003, True), # mount point (junction)
|
||||
(0x9000001A, False), # OneDrive cloud placeholder: a regular file
|
||||
(0, False),
|
||||
):
|
||||
with self.subTest(tag=hex(tag)):
|
||||
st = mock.Mock(st_mode=regular, st_reparse_tag=tag)
|
||||
self.assertIs(logging_config._is_link(path, st), is_link)
|
||||
|
||||
def test_truncate_with_file_logging_off_never_creates_the_active_log(self):
|
||||
self.manager.update_config({"file_logging": False})
|
||||
os.unlink(self._path("prompt_manager.log"))
|
||||
self._write("prompt_manager.log.1")
|
||||
result = self.manager.truncate_logs()
|
||||
self.assertEqual(result, {"truncated": ["prompt_manager.log.1"], "errors": []})
|
||||
self.assertEqual(self._names(), [])
|
||||
|
||||
def test_truncate_never_empties_a_linked_active_log(self):
|
||||
self.manager.update_config({"file_logging": False})
|
||||
os.unlink(self._path("prompt_manager.log"))
|
||||
target = self._require_symlink("prompt_manager.log", data=b"keep")
|
||||
self.manager.truncate_logs()
|
||||
with open(target, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"keep")
|
||||
|
||||
def test_log_files_mark_kind_and_lstat_links(self):
|
||||
self._write("prompt_manager.log.1", b"r" * 10)
|
||||
self._write("prompt_manager.log.bak", b"b")
|
||||
target = self._require_symlink("prompt_manager.log.4")
|
||||
rows = {row["filename"]: row for row in self.manager.get_log_files()}
|
||||
self.assertEqual(rows["prompt_manager.log"]["kind"], "active")
|
||||
self.assertTrue(rows["prompt_manager.log"]["is_main"])
|
||||
self.assertEqual(rows["prompt_manager.log.1"]["kind"], "rotated")
|
||||
self.assertEqual(rows["prompt_manager.log.bak"]["kind"], "other")
|
||||
link = rows["prompt_manager.log.4"]
|
||||
self.assertEqual(link["kind"], "link")
|
||||
self.assertEqual(
|
||||
link["size"], os.lstat(self._path("prompt_manager.log.4")).st_size
|
||||
)
|
||||
self.assertNotEqual(link["size"], os.path.getsize(target))
|
||||
for row in rows.values():
|
||||
self.assertEqual(
|
||||
set(row), {"filename", "path", "size", "modified", "is_main", "kind"}
|
||||
)
|
||||
|
||||
def test_an_unreadable_timestamp_does_not_break_the_scan(self):
|
||||
self._write("prompt_manager.log.1")
|
||||
real = logging_config.datetime
|
||||
|
||||
class Clock:
|
||||
@staticmethod
|
||||
def fromtimestamp(value):
|
||||
raise OSError(22, "Invalid argument")
|
||||
|
||||
with mock.patch.object(logging_config, "datetime", Clock):
|
||||
rows = {r["name"]: r for r in self.manager.scan_log_folder()}
|
||||
self.assertIs(logging_config.datetime, real)
|
||||
self.assertEqual(rows["prompt_manager.log.1"]["kind"], "rotated")
|
||||
self.assertEqual(rows["prompt_manager.log.1"]["modified"], "")
|
||||
|
||||
def test_stats_exclude_links(self):
|
||||
self._write("prompt_manager.log.1", b"r" * 10)
|
||||
before = self.manager.get_log_stats()
|
||||
self._require_symlink("prompt_manager.log.4")
|
||||
after = self.manager.get_log_stats()
|
||||
self.assertEqual(after["log_files_count"], before["log_files_count"])
|
||||
self.assertEqual(after["total_log_size"], before["total_log_size"])
|
||||
|
||||
def test_log_file_entry_accepts_only_listed_regular_files(self):
|
||||
self._write("prompt_manager.log.1")
|
||||
self._write("prompt_manager.log.bak")
|
||||
os.makedirs(self._path("folder.log"))
|
||||
self._symlink("prompt_manager.log.4")
|
||||
self.assertEqual(
|
||||
os.path.normcase(str(self.manager.log_file_entry("prompt_manager.log.1"))),
|
||||
os.path.normcase(self._path("prompt_manager.log.1")),
|
||||
)
|
||||
self.manager.log_file_entry("prompt_manager.log")
|
||||
self.manager.log_file_entry("prompt_manager.log.bak")
|
||||
for name in (
|
||||
"prompt_manager.log.4",
|
||||
"folder.log",
|
||||
"missing.log",
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../outside/secret.txt",
|
||||
"..\\outside\\secret.txt",
|
||||
"prompt_manager.log:stream",
|
||||
"prompt_manager.log\x00",
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
self.manager.log_file_entry(name)
|
||||
|
||||
|
||||
class TestConfiguration(IsolatedLoggerTestCase):
|
||||
|
||||
+29
-23
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 32,
|
||||
"lines": 37,
|
||||
"branches": 61,
|
||||
"functions": 27,
|
||||
"statements": 32
|
||||
"functions": 32,
|
||||
"statements": 37
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
@@ -48,10 +48,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 45,
|
||||
"branches": 66,
|
||||
"functions": 48,
|
||||
"statements": 45
|
||||
"lines": 49,
|
||||
"branches": 68,
|
||||
"functions": 50,
|
||||
"statements": 49
|
||||
},
|
||||
"html-escape.js": {
|
||||
"lines": 98,
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"sse-stream.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -181,7 +181,7 @@
|
||||
},
|
||||
"workspace/activity-scan.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 87,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -420,10 +420,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/maintenance-review.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 86,
|
||||
"functions": 90,
|
||||
"statements": 94
|
||||
"lines": 95,
|
||||
"branches": 87,
|
||||
"functions": 91,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/media-viewer.mjs": {
|
||||
"lines": 98,
|
||||
@@ -515,15 +515,21 @@
|
||||
"functions": 89,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-diagnostics.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 86,
|
||||
"functions": 94,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/system-integrations.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 88,
|
||||
"functions": 91,
|
||||
"statements": 96
|
||||
"lines": 97,
|
||||
"branches": 90,
|
||||
"functions": 92,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/system-maintenance.mjs": {
|
||||
"lines": 93,
|
||||
"branches": 80,
|
||||
"branches": 81,
|
||||
"functions": 92,
|
||||
"statements": 93
|
||||
},
|
||||
@@ -546,10 +552,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-tagging.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 75,
|
||||
"statements": 80
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-thumbnails.mjs": {
|
||||
"lines": 98,
|
||||
@@ -560,7 +566,7 @@
|
||||
"workspace/system.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 86,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
|
||||
+11
-7
@@ -1075,7 +1075,9 @@ class ImageMonitor:
|
||||
"""Stop the image monitoring system within one ``timeout`` deadline.
|
||||
|
||||
Closes the handler first (its worker is joined and its dispatch
|
||||
returns at once), then stops the watchdog observer and joins it.
|
||||
returns at once), then stops the watchdog observer and joins it. The
|
||||
worker and the observer share the one deadline: the observer's join
|
||||
gets what the worker left, possibly 0 s.
|
||||
watchdog's ``observer.stop()`` joins its emitters without a limit;
|
||||
it counts against the deadline but is not bounded here. A part an
|
||||
earlier call left running is waited for again. Safe to call when
|
||||
@@ -1100,10 +1102,6 @@ class ImageMonitor:
|
||||
for pair_observer, pair_handler in pairs:
|
||||
handler_ok = self._close_handler(pair_handler, remaining())
|
||||
observer_ok = self._stop_observer(pair_observer, remaining())
|
||||
if not observer_ok:
|
||||
self.logger.warning(
|
||||
f"watchdog observer still running after {timeout} s"
|
||||
)
|
||||
if not (handler_ok and observer_ok):
|
||||
self._unfinished.append(
|
||||
(
|
||||
@@ -1124,7 +1122,8 @@ class ImageMonitor:
|
||||
return False
|
||||
|
||||
def _stop_observer(self, observer, timeout: float) -> bool:
|
||||
"""``observer.stop()`` (safe to repeat), then a bounded join."""
|
||||
"""``observer.stop()`` (safe to repeat), then a join of ``timeout``
|
||||
seconds; a join that times out is logged with the time it got."""
|
||||
if observer is None:
|
||||
return True
|
||||
try:
|
||||
@@ -1133,7 +1132,12 @@ class ImageMonitor:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error while stopping image monitoring: {e}")
|
||||
return False
|
||||
return not observer.is_alive()
|
||||
if observer.is_alive():
|
||||
self.logger.warning(
|
||||
f"watchdog observer still running after {timeout:.1f} s"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def detect_comfyui_output_dirs(self) -> list:
|
||||
"""Auto-detect ComfyUI output directories.
|
||||
|
||||
+304
-45
@@ -10,9 +10,12 @@ This module provides centralized logging configuration with support for:
|
||||
"""
|
||||
|
||||
import collections
|
||||
import fnmatch
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -20,6 +23,160 @@ from pathlib import Path
|
||||
|
||||
from . import library_location
|
||||
|
||||
ACTIVE_LOG_NAME = "prompt_manager.log"
|
||||
# Rule R (#329): a rotated copy is prompt_manager.log.<n>, n >= 1, no leading 0.
|
||||
_ROTATED_NAME = re.compile(r"prompt_manager\.log\.([1-9][0-9]*)")
|
||||
# The legacy listing's pattern (GET /logs/files and /logs/stats).
|
||||
_LISTED_PATTERN = "*.log*"
|
||||
LOG_FILE_KINDS = ("active", "rotated", "link", "other")
|
||||
|
||||
|
||||
class RotatedChanged(Exception):
|
||||
"""A reviewed rotated copy changed since the review; nothing was deleted."""
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(f"{name} changed since the review")
|
||||
self.name = name
|
||||
|
||||
|
||||
def _name_key(name: str) -> str:
|
||||
"""How a name compares: case-folded where the filesystem folds it (Windows)."""
|
||||
return os.path.normcase(name)
|
||||
|
||||
|
||||
def _rotated_number(name: str) -> Optional[int]:
|
||||
match = _ROTATED_NAME.fullmatch(_name_key(name))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
# Windows reparse tags with the name-surrogate bit name another file or
|
||||
# folder (symlinks, junctions, WSL symlinks); cloud placeholders do not.
|
||||
_NAME_SURROGATE = 0x20000000
|
||||
|
||||
|
||||
def _is_link(path: str, st: os.stat_result) -> bool:
|
||||
if getattr(st, "st_reparse_tag", 0) & _NAME_SURROGATE:
|
||||
return True
|
||||
return stat.S_ISLNK(st.st_mode) or os.path.isjunction(path)
|
||||
|
||||
|
||||
class LogFilePath(type(Path())):
|
||||
"""A listed log file's path, with the inode it had when it was listed."""
|
||||
|
||||
listed_ino = 0
|
||||
|
||||
|
||||
def _kind(name: str, path: str, st: os.stat_result) -> str:
|
||||
"""Rule R: ``active``, ``rotated``, ``link`` or ``other`` (never deleted)."""
|
||||
if _is_link(path, st):
|
||||
return "link"
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return "other"
|
||||
if _name_key(name) == _name_key(ACTIVE_LOG_NAME):
|
||||
return "active"
|
||||
if _rotated_number(name) is not None:
|
||||
return "rotated"
|
||||
return "other"
|
||||
|
||||
|
||||
def scan_log_folder(log_dir) -> List[Dict[str, Any]]:
|
||||
"""Every direct entry of ``log_dir`` with its Rule R kind (blocking).
|
||||
|
||||
Rows are ``{name, kind, size, mtime_ns, ino, modified}``, read with
|
||||
``lstat`` so a link is described, never followed. An entry that cannot
|
||||
be read is left out. Never logs: the cleanup calls it while it holds the
|
||||
file handler's lock.
|
||||
"""
|
||||
rows = []
|
||||
try:
|
||||
names = [entry.name for entry in os.scandir(log_dir)]
|
||||
except OSError:
|
||||
return rows
|
||||
for name in names:
|
||||
path = os.path.join(log_dir, name)
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
kind = _kind(name, path, st)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
modified = datetime.fromtimestamp(st.st_mtime).isoformat()
|
||||
except (OSError, OverflowError, ValueError): # a time before 1970 on Windows
|
||||
modified = ""
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"size": st.st_size,
|
||||
"mtime_ns": st.st_mtime_ns,
|
||||
"ino": st.st_ino,
|
||||
"modified": modified,
|
||||
"_dir": stat.S_ISDIR(st.st_mode) and kind != "link",
|
||||
"_regular": stat.S_ISREG(st.st_mode) and kind != "link",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def public_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A scan row without its private keys."""
|
||||
return {key: value for key, value in row.items() if not key.startswith("_")}
|
||||
|
||||
|
||||
def rotated_sort_key(row: Dict[str, Any]) -> int:
|
||||
return _rotated_number(row["name"]) or 0
|
||||
|
||||
|
||||
def log_file_entry(log_dir, name) -> Path:
|
||||
"""The path of a downloadable log file named ``name`` (blocking).
|
||||
|
||||
Only a regular, non-link file listed directly in ``log_dir`` under that
|
||||
exact name; a separator, ``..``, a stream suffix or any unlisted name
|
||||
raises FileNotFoundError, as a missing file does.
|
||||
"""
|
||||
if not isinstance(name, str) or not name or name in (".", ".."):
|
||||
raise FileNotFoundError("Log file not found")
|
||||
if any(ch in name for ch in ("/", "\\", "\x00", ":")):
|
||||
raise FileNotFoundError("Log file not found")
|
||||
for row in scan_log_folder(log_dir):
|
||||
if row["name"] == name and row["kind"] in ("active", "rotated", "other"):
|
||||
if not row["_regular"]: # a folder, FIFO, socket or device
|
||||
break
|
||||
entry = LogFilePath(log_dir, row["name"])
|
||||
entry.listed_ino = row["ino"]
|
||||
return entry
|
||||
raise FileNotFoundError("Log file not found")
|
||||
|
||||
|
||||
def _stream_is(stream, ino: int) -> bool:
|
||||
"""Whether ``stream`` is open on the file with inode ``ino`` (0: unknown)."""
|
||||
try:
|
||||
open_ino = os.fstat(stream.fileno()).st_ino
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
return not (ino and open_ino and open_ino != ino)
|
||||
|
||||
|
||||
def _error_text(error: BaseException) -> str:
|
||||
"""An OSError's reason and file name, without the folder it is in."""
|
||||
if isinstance(error, OSError):
|
||||
parts = [error.strerror or type(error).__name__]
|
||||
if error.filename:
|
||||
parts.append(os.path.basename(str(error.filename)))
|
||||
return ": ".join(parts)
|
||||
return str(error) or type(error).__name__
|
||||
|
||||
|
||||
def _matches_review(row: Optional[Dict[str, Any]], reviewed: Dict[str, Any]) -> bool:
|
||||
if row is None or row["kind"] != "rotated":
|
||||
return False
|
||||
if row["size"] != reviewed.get("size") or row["mtime_ns"] != reviewed.get(
|
||||
"mtime_ns"
|
||||
):
|
||||
return False
|
||||
ino = reviewed.get("ino") or 0
|
||||
return not (ino and row["ino"] and row["ino"] != ino)
|
||||
|
||||
|
||||
class _OwnedRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""Keep a retired owned sink closed when an in-flight record resumes."""
|
||||
@@ -87,6 +244,9 @@ class PromptManagerLogger:
|
||||
# Memory buffer for recent logs (for web viewer)
|
||||
self._log_buffer = collections.deque(maxlen=self.config["buffer_size"])
|
||||
self._buffer_lock = threading.Lock()
|
||||
# Held while a handler is replaced or log files are deleted (#329):
|
||||
# taken before any file handler's own lock, never the other way round.
|
||||
self._files_lock = threading.Lock()
|
||||
|
||||
# Initialize loggers
|
||||
self._setup_loggers()
|
||||
@@ -238,6 +398,14 @@ class PromptManagerLogger:
|
||||
# Return most recent first
|
||||
return list(reversed(logs[-limit:]))
|
||||
|
||||
def scan_log_folder(self) -> List[Dict[str, Any]]:
|
||||
"""Every entry of the log folder with its Rule R kind (blocking)."""
|
||||
return [public_row(row) for row in scan_log_folder(self.log_dir)]
|
||||
|
||||
def log_file_entry(self, name: str) -> Path:
|
||||
"""A downloadable (regular, non-link) log file; FileNotFoundError else."""
|
||||
return log_file_entry(self.log_dir, name)
|
||||
|
||||
def get_log_files(self) -> List[Dict[str, Any]]:
|
||||
"""Get information about available log files.
|
||||
|
||||
@@ -245,26 +413,27 @@ class PromptManagerLogger:
|
||||
List of dictionaries containing file information:
|
||||
- filename: Name of the log file
|
||||
- path: Full path to the log file
|
||||
- size: File size in bytes
|
||||
- size: File size in bytes (a link's own size: links are not followed)
|
||||
- modified: ISO formatted modification timestamp
|
||||
- is_main: True if this is the main log file
|
||||
- kind: ``active``, ``rotated``, ``link`` or ``other`` (Rule R)
|
||||
"""
|
||||
log_files = []
|
||||
|
||||
for log_file in self.log_dir.glob("*.log*"):
|
||||
try:
|
||||
stat = log_file.stat()
|
||||
log_files.append(
|
||||
{
|
||||
"filename": log_file.name,
|
||||
"path": str(log_file),
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
"is_main": log_file.name == "prompt_manager.log",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting info for log file {log_file}: {e}")
|
||||
for row in scan_log_folder(self.log_dir):
|
||||
if row["_dir"]:
|
||||
continue
|
||||
if not fnmatch.fnmatchcase(_name_key(row["name"]), _LISTED_PATTERN):
|
||||
continue
|
||||
log_files.append(
|
||||
{
|
||||
"filename": row["name"],
|
||||
"path": str(self.log_dir / row["name"]),
|
||||
"size": row["size"],
|
||||
"modified": row["modified"],
|
||||
"is_main": row["kind"] == "active",
|
||||
"kind": row["kind"],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by modification time, newest first
|
||||
log_files.sort(key=lambda x: x["modified"], reverse=True)
|
||||
@@ -302,14 +471,44 @@ class PromptManagerLogger:
|
||||
self.logger.error(f"Error reading log file {filename}: {e}")
|
||||
raise
|
||||
|
||||
def _truncate_main_log(self, log_file: Path) -> None:
|
||||
def _file_handlers(self) -> List[logging.Handler]:
|
||||
return [h for h in self.logger.handlers if isinstance(h, logging.FileHandler)]
|
||||
|
||||
def _acquire_file_handlers(self) -> List[logging.Handler]:
|
||||
"""Take every file handler's lock: no rollover renames a file meanwhile.
|
||||
|
||||
The caller holds ``_files_lock`` (so no handler is replaced) and must
|
||||
not log until it released them: the lock is re-entrant, so a record
|
||||
logged here could roll the log over under the caller's feet.
|
||||
"""
|
||||
held = []
|
||||
try:
|
||||
for handler in self._file_handlers():
|
||||
handler.acquire()
|
||||
held.append(handler)
|
||||
except BaseException:
|
||||
self._release_handlers(held)
|
||||
raise
|
||||
return held
|
||||
|
||||
@staticmethod
|
||||
def _release_handlers(held) -> None:
|
||||
for handler in reversed(held):
|
||||
handler.release()
|
||||
|
||||
def _truncate_main_log(self, log_file: Path, expected_ino: int = 0) -> None:
|
||||
"""Empty the main log through the handler that writes to it.
|
||||
|
||||
Opening the file a second time with ``"w"`` leaves the handler's own
|
||||
stream at its old offset: later records land past the new end
|
||||
(zero-padded on POSIX) and ``tell()`` keeps reporting the old size, so
|
||||
the next record rolls the file over. Truncating the handler's stream
|
||||
keeps position and size in step.
|
||||
keeps position and size in step. Without a handler on it (file logging
|
||||
off) the existing file is emptied in place, never through a link.
|
||||
|
||||
A handler whose open stream is not the listed file (``expected_ino``:
|
||||
the user renamed the log, which the handler still writes to) is left
|
||||
alone: the file under the active name is emptied instead.
|
||||
"""
|
||||
handled = False
|
||||
target = os.path.normcase(os.path.realpath(str(log_file)))
|
||||
@@ -320,6 +519,8 @@ class PromptManagerLogger:
|
||||
continue
|
||||
if os.path.normcase(os.path.realpath(base)) != target:
|
||||
continue
|
||||
if not _stream_is(stream, expected_ino):
|
||||
continue
|
||||
handler.acquire()
|
||||
try:
|
||||
stream.flush()
|
||||
@@ -329,14 +530,65 @@ class PromptManagerLogger:
|
||||
finally:
|
||||
handler.release()
|
||||
if not handled:
|
||||
with open(log_file, "w", encoding="utf-8", errors="replace") as f:
|
||||
f.write("")
|
||||
flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
os.close(os.open(str(log_file), flags))
|
||||
|
||||
def delete_rotated(self, expected: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Delete exactly the reviewed rotated copies, or none of them (blocking).
|
||||
|
||||
``expected`` are scan rows (``name``, ``size``, ``mtime_ns``,
|
||||
``ino``). Under ``_files_lock`` and every file handler's lock the
|
||||
folder is scanned again; when any expected file is missing, is no
|
||||
longer a rotated copy (Rule R) or differs in size, ``mtime_ns`` or a
|
||||
non-zero ``ino``, RotatedChanged is raised with nothing deleted.
|
||||
Otherwise each is unlinked in ``<n>`` order.
|
||||
|
||||
Returns ``{"removed": [names], "failed": [(name, OSError)]}``. One
|
||||
summary line is logged once both locks are released.
|
||||
"""
|
||||
removed, failed = [], []
|
||||
changed = None
|
||||
with self._files_lock:
|
||||
held = self._acquire_file_handlers()
|
||||
try:
|
||||
current = {
|
||||
_name_key(row["name"]): row for row in scan_log_folder(self.log_dir)
|
||||
}
|
||||
targets = []
|
||||
for reviewed in sorted(expected, key=rotated_sort_key):
|
||||
row = current.get(_name_key(str(reviewed.get("name", ""))))
|
||||
if not _matches_review(row, reviewed):
|
||||
changed = str(reviewed.get("name", ""))
|
||||
break
|
||||
targets.append(row["name"])
|
||||
if changed is None:
|
||||
for name in targets:
|
||||
try:
|
||||
os.unlink(os.path.join(self.log_dir, name))
|
||||
removed.append(name)
|
||||
except OSError as error:
|
||||
failed.append((name, error))
|
||||
finally:
|
||||
self._release_handlers(held)
|
||||
if changed is not None:
|
||||
self.logger.warning(
|
||||
"Rotated log cleanup refused: %s changed since the review", changed
|
||||
)
|
||||
raise RotatedChanged(changed)
|
||||
self.logger.info(
|
||||
"Deleted %d rotated log copies%s",
|
||||
len(removed),
|
||||
f", {len(failed)} could not be deleted" if failed else "",
|
||||
)
|
||||
return {"removed": removed, "failed": failed}
|
||||
|
||||
def truncate_logs(self) -> Dict[str, Any]:
|
||||
"""Truncate all log files.
|
||||
"""Empty the active log and delete its rotated copies (Rule R).
|
||||
|
||||
Clears the main log file and deletes rotated log files.
|
||||
Also clears the memory buffer.
|
||||
Every other file in the log folder, and every link, is kept. Also
|
||||
clears the memory buffer. Runs under ``_files_lock`` and the file
|
||||
handlers' locks, so no rollover recreates a just-deleted copy, and
|
||||
logs only once they are released.
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
@@ -345,28 +597,31 @@ class PromptManagerLogger:
|
||||
"""
|
||||
results = {"truncated": [], "errors": []}
|
||||
|
||||
# Collect first and stay silent until every file has been handled:
|
||||
# a log line written mid-way can roll the just-emptied main log over
|
||||
# and recreate the backup that was deleted a moment earlier.
|
||||
log_files = sorted(self.log_dir.glob("prompt_manager.log*"))
|
||||
for log_file in log_files:
|
||||
with self._files_lock:
|
||||
held = self._acquire_file_handlers()
|
||||
try:
|
||||
if log_file.name == "prompt_manager.log":
|
||||
self._truncate_main_log(log_file)
|
||||
else:
|
||||
# For rotated files, delete them
|
||||
log_file.unlink()
|
||||
|
||||
results["truncated"].append(log_file.name)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(
|
||||
f"Failed to truncate {log_file.name}: {str(e)}"
|
||||
rows = scan_log_folder(self.log_dir)
|
||||
active = [row for row in rows if row["kind"] == "active"]
|
||||
rotated = sorted(
|
||||
(row for row in rows if row["kind"] == "rotated"),
|
||||
key=rotated_sort_key,
|
||||
)
|
||||
|
||||
# Clear memory buffer
|
||||
with self._buffer_lock:
|
||||
self._log_buffer.clear()
|
||||
for row in active + rotated:
|
||||
path = os.path.join(self.log_dir, row["name"])
|
||||
try:
|
||||
if row["kind"] == "active":
|
||||
self._truncate_main_log(Path(path), row["ino"])
|
||||
else:
|
||||
os.unlink(path)
|
||||
results["truncated"].append(row["name"])
|
||||
except OSError as error:
|
||||
results["errors"].append(
|
||||
f"Failed to truncate {row['name']}: {_error_text(error)}"
|
||||
)
|
||||
with self._buffer_lock:
|
||||
self._log_buffer.clear()
|
||||
finally:
|
||||
self._release_handlers(held)
|
||||
|
||||
if results["truncated"]:
|
||||
self.logger.info("Truncated log files: %s", ", ".join(results["truncated"]))
|
||||
@@ -409,7 +664,9 @@ class PromptManagerLogger:
|
||||
"backup_count",
|
||||
]
|
||||
):
|
||||
self._setup_loggers()
|
||||
# No cleanup may hold the old handler while it is replaced.
|
||||
with self._files_lock:
|
||||
self._setup_loggers()
|
||||
|
||||
self.logger.info(f"Updated logging configuration: {new_config}")
|
||||
|
||||
@@ -440,7 +697,9 @@ class PromptManagerLogger:
|
||||
level = entry["level"]
|
||||
level_counts[level] = level_counts.get(level, 0) + 1
|
||||
|
||||
log_files = self.get_log_files()
|
||||
# A link's target is not a log file of this folder: neither counted
|
||||
# nor sized.
|
||||
log_files = [f for f in self.get_log_files() if f["kind"] != "link"]
|
||||
total_log_size = sum(f["size"] for f in log_files)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""The project version, read from ``pyproject.toml`` (#329)."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def project_version(root):
|
||||
"""The version in ``root``'s ``pyproject.toml``; ``"unknown"`` on failure."""
|
||||
try:
|
||||
pyproject_path = Path(root) / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
content = pyproject_path.read_text()
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception: # nosec B110 - an unreadable file reads as unknown
|
||||
pass
|
||||
return "unknown"
|
||||
@@ -53,7 +53,14 @@ export const OPERATION_TITLES = Object.freeze({
|
||||
duplicate_files: 'Duplicate image files',
|
||||
// Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none).
|
||||
workflow_storage: 'Store each workflow once',
|
||||
// S12: Diagnostics & logs › Log files & retention › Review log cleanup (the concept's `maintenance-plan` "rotated logs").
|
||||
rotated_logs: 'Rotated log copies',
|
||||
});
|
||||
/** S12: plans whose details are files, not records ("Listed files"). */
|
||||
const FILE_PLANS = new Set(['rotated_logs']);
|
||||
/** S12: a plan that changes no library record (`backup.policy: "none"`): no backup is taken or offered. */
|
||||
export const NO_BACKUP_TEXT = 'Not needed: no library record changes';
|
||||
const noBackup = plan => plan?.backup?.policy === 'none';
|
||||
// The review is gone or no longer matches the database: review it again.
|
||||
const REVIEW_AGAIN = new Set(['plan-expired', 'storage-changed', 'plan-changed']);
|
||||
// Refusals whose server message is the whole truth.
|
||||
@@ -71,6 +78,7 @@ function el(tag, text, className) {
|
||||
|
||||
/** The Backup value: what the apply will do before it changes anything. */
|
||||
export function backupText(backup) {
|
||||
if (backup?.policy === 'none') return NO_BACKUP_TEXT;
|
||||
if (backup?.policy === 'off') return 'No safety backup (the policy is off)';
|
||||
const where = backup?.destination_label ? ` (${backup.destination_label})` : '';
|
||||
if (backup?.policy === 'required') return `Always created and verified first${where}`;
|
||||
@@ -205,6 +213,18 @@ export async function applyPlan({ api, store, scope, token, reviewAgain, notify
|
||||
return { job };
|
||||
}
|
||||
|
||||
/** The details list's summary: "Listed records (N)", or "Listed files (N)" for a plan of files (S12); "(N of M)" when cut. */
|
||||
export function detailsSummary(plan, shown) {
|
||||
const what = FILE_PLANS.has(plan?.operation) ? 'Listed files' : 'Listed records';
|
||||
const count = plan?.scope?.count ?? shown;
|
||||
return shown < count ? `${what} (${number(shown)} of ${number(count)})` : `${what} (${number(shown)})`;
|
||||
}
|
||||
|
||||
/** D55's backup box for the review, or null for a plan that changes no library record (S12, `policy: "none"`). */
|
||||
export function backupBox(plan, onBackup) {
|
||||
return noBackup(plan) ? null : { copy: BACKUP_COPY, onBackup };
|
||||
}
|
||||
|
||||
/** Show the reviewed `plan` for `operation`; Apply applies exactly this token. */
|
||||
export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain, notify = showWorkspaceToast,
|
||||
navigate = navigateTo, backup = () => downloadDatabaseBackup() }) {
|
||||
@@ -212,9 +232,7 @@ export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain,
|
||||
const lines = detailLines(plan);
|
||||
if (lines.length) {
|
||||
const list = el('details');
|
||||
const count = plan.scope?.count ?? lines.length;
|
||||
list.append(el('summary', lines.length < count ? `Listed records (${number(lines.length)} of ${number(count)})`
|
||||
: `Listed records (${number(lines.length)})`));
|
||||
list.append(el('summary', detailsSummary(plan, lines.length)));
|
||||
const body = el('pre', lines.join('\n'));
|
||||
body.tabIndex = 0;
|
||||
list.append(body);
|
||||
@@ -238,7 +256,7 @@ export function showPlan(plan, { api, store, scope, trigger = null, reviewAgain,
|
||||
return;
|
||||
}
|
||||
openReviewDialog({ scope, trigger, title: PLAN_TITLE, heading,
|
||||
grid: planGrid(plan), options, sentence: planNote(plan), backup: { copy: BACKUP_COPY, onBackup: backup },
|
||||
grid: planGrid(plan), options, sentence: planNote(plan), backup: backupBox(plan, backup),
|
||||
cancelLabel: 'Cancel', primary: { label: APPLY_LABEL },
|
||||
onStart: () => applyPlan({ api, store, scope, token: plan.token, reviewAgain, notify, navigate }) });
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export const routes = [
|
||||
// The concept's Activity (app.js renderActivity 732–738): this server session's jobs. No href: no Overview card.
|
||||
{ id: 'activity', label: 'Activity' },
|
||||
// Native System (#249 System S2): `#/system/<section>`. Q12: Overview's card opens its Overview section (`native`:
|
||||
// a workspace hash, not a legacy page); each undelivered section hands off to its legacy tool (D94). The
|
||||
// description is the concept's System heading (app.js renderSystem 700).
|
||||
// a workspace hash, not a legacy page); every section is native (S12 retired D94's hand-off). The description is
|
||||
// the concept's System heading (app.js renderSystem 700).
|
||||
{ id: 'system', label: 'System', href: '#/system/overview', native: true,
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* System › Diagnostics & logs (#249 System S12, #329): the concept's `renderSystem` "diagnostics" body (app.js 694–695)
|
||||
* with live data, built with the DOM.
|
||||
*
|
||||
* - "Diagnostics": "Run checks" opens the shared Health report (D95), as Overview and Maintenance do. The concept's
|
||||
* `div.meta-grid` reads one `GET /system/diagnostics` (Database, Sources, Viewer.js) and the send bridge (Host
|
||||
* renderers, D106's line). "Download diagnostic report" saves a fresh report (the server's listed fields, no secret,
|
||||
* no absolute path) with the renderer line added. "Image-link diagnostics" → Inspect opens the read-only Inspect a
|
||||
* file page. D110 adds "Test image link": the legacy synthetic link, confirmed first, naming the prompt it links.
|
||||
* - "Logs": the concept's toolbar (Level, Refresh, Download log) over the in-memory buffer, newest first, as
|
||||
* `HH:MM:SS LEVEL message`. D109 adds: Lines 1–500, all six minimum levels, the statistics line, CRITICAL as a
|
||||
* runtime level, the descriptions that say a runtime value lasts until the server restarts, the retention line, the
|
||||
* log files with their downloads and "Clear all logs" (confirmed). "Runtime log level" and "Console logging" are
|
||||
* runtime drafts saved by the one Save row (`POST /logs/config`). "Review log cleanup" reviews the `rotated_logs`
|
||||
* maintenance plan (D100), which deletes only the rotated copies it listed.
|
||||
*
|
||||
* Every request goes through the page's api client; every listener sits on the section's `scope`, and a read that
|
||||
* answers after the section was left paints nothing.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { runHealthAction } from './system-overview.mjs';
|
||||
import { reviewMissingLinks } from './system-maintenance.mjs';
|
||||
import { bridgeFactory, rendererText, RENDERER_COPY } from './system-integrations.mjs';
|
||||
import { closeWorkspaceDialog, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { reviewPlan } from './maintenance-review.mjs';
|
||||
import { backupFilename, saveResponse } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
export const REPORT_URL = '/prompt_manager/system/diagnostics';
|
||||
export const RECENT_URL = '/prompt_manager/recent?limit=1';
|
||||
export const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
|
||||
export const LOGS_URL = '/prompt_manager/logs';
|
||||
export const STATS_URL = '/prompt_manager/logs/stats';
|
||||
export const FILES_URL = '/prompt_manager/logs/files';
|
||||
export const CONFIG_URL = '/prompt_manager/logs/config';
|
||||
export const TRUNCATE_URL = '/prompt_manager/logs/truncate';
|
||||
export const DOWNLOAD_URL = '/prompt_manager/logs/download/';
|
||||
export const ACTIVE_LOG = 'prompt_manager.log';
|
||||
export const TEST_LINK_FILE = 'prompt_manager_test_link.png';
|
||||
|
||||
export const LOADING = 'Loading…';
|
||||
export const UNREADABLE = 'Could not be read.';
|
||||
export const META_TERMS = Object.freeze(['Database', 'Sources', 'Viewer.js', 'Host renderers']);
|
||||
/** S12-8: a minimum level; `''` asks for every level. */
|
||||
export const LEVEL_OPTIONS = Object.freeze([['', 'All levels'], ['DEBUG', 'DEBUG'], ['INFO', 'INFO'], ['WARNING', 'WARNING'],
|
||||
['ERROR', 'ERROR'], ['CRITICAL', 'CRITICAL']]);
|
||||
/** The concept's runtime levels in its order, then CRITICAL (D109). */
|
||||
export const RUNTIME_LEVELS = Object.freeze(['INFO', 'WARNING', 'DEBUG', 'ERROR', 'CRITICAL']);
|
||||
export const DEFAULT_LIMIT = 100;
|
||||
export const MAX_LIMIT = 500;
|
||||
export const KIND_LABELS = Object.freeze({ active: 'Active log', rotated: 'Rotated copy', link: 'Link, not downloaded',
|
||||
other: 'Other file' });
|
||||
|
||||
export const COPY = Object.freeze({
|
||||
runtimeLevel: 'How much the server writes to the log. Applied until the server restarts.',
|
||||
console: 'Print Prompt Manager’s warnings and errors in the ComfyUI console (everything at DEBUG). Applied until the server restarts.',
|
||||
inspect: 'Check a file-to-prompt association without changing it.',
|
||||
testLink: 'Write a placeholder image record to check that linking works.',
|
||||
noPrompt: 'Add a prompt first.',
|
||||
promptUnread: 'The most recent prompt could not be read.',
|
||||
noEntries: 'No log entries at this level.',
|
||||
logUnread: 'The log could not be read.',
|
||||
statsUnread: 'The log statistics could not be read.',
|
||||
filesUnread: 'The log files could not be read.',
|
||||
configUnread: 'The retention settings could not be read.',
|
||||
runtimeUnread: 'The current value could not be read.',
|
||||
limitHint: 'Lines to show: a whole number from 1 to 500.',
|
||||
logDownloadFailed: 'The log could not be downloaded.',
|
||||
reportDone: 'Diagnostic report downloaded.',
|
||||
reportFailed: 'The diagnostic report could not be downloaded. Nothing was saved to this device.',
|
||||
testLinkFailed: 'The test link could not be created. Nothing was changed.',
|
||||
clearFailed: 'The logs could not be cleared. Read the log files again before retrying.',
|
||||
clearDescription: 'Empty the active log and delete its rotated copies.',
|
||||
});
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** A byte count as the server's plan labels it: `1000 bytes`, `2.0 KB`, `10.0 MB`, `1.6 GB` (1 KB = 1024 bytes). */
|
||||
export function sizeText(bytes) {
|
||||
const size = Number.isFinite(bytes) && bytes > 0 ? Math.floor(bytes) : 0;
|
||||
if (size < 1024) return `${number(size)} ${noun(size, 'byte')}`;
|
||||
for (const [unit, scale] of [['KB', 2 ** 10], ['MB', 2 ** 20]]) {
|
||||
if (size < scale * 1024) return `${(size / scale).toFixed(1)} ${unit}`;
|
||||
}
|
||||
return `${(size / 2 ** 30).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/** The text for a read that was refused: a `busy` refusal names the running job (D57's naming). */
|
||||
export function refusalText(error) {
|
||||
if (error?.code === 'busy') return `Busy: ${error.running?.title || 'another job'} is running.`;
|
||||
return UNREADABLE;
|
||||
}
|
||||
|
||||
/** A `dd` text from a read's state: `{loading}`, `{error}` or `{report}` handed to `value(report)`. */
|
||||
function stateText(state, value) {
|
||||
if (!state || state.loading) return LOADING;
|
||||
if (state.error !== undefined) return refusalText(state.error);
|
||||
return value(state.report);
|
||||
}
|
||||
|
||||
/** Database: "Readable · 1.6 GB · 19,956 images", or why it is not readable. */
|
||||
export function databaseText(state) {
|
||||
return stateText(state, report => {
|
||||
const check = report?.checks?.database;
|
||||
if (check?.status !== 'ok') return `Not readable: ${check?.message || 'no answer from the database'}`;
|
||||
const images = Number.isSafeInteger(check.image_count) ? check.image_count : 0;
|
||||
return `Readable · ${sizeText(report.database_info?.database_size_bytes)} · ${number(images)} ${noun(images, 'image')}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Sources: the first match of the design's precedence table (§4). */
|
||||
export function sourcesText(state) {
|
||||
return stateText(state, report => {
|
||||
const count = key => (Number.isSafeInteger(report?.sources?.[key]) ? report.sources[key] : 0);
|
||||
const configured = count('configured');
|
||||
const watching = count('watching');
|
||||
const unavailable = count('unavailable');
|
||||
if (configured === 0) return `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`;
|
||||
if (unavailable > 0) return `${number(configured)} ${noun(configured, 'source')} · ${number(unavailable)} not available`;
|
||||
return `${number(configured)} ${noun(configured, 'source')} · ${number(watching)} watching`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Viewer.js: the pinned vendor version the server ships. */
|
||||
export function viewerText(state) {
|
||||
return stateText(state, report => `${report?.viewerjs || 'unknown'} · pinned vendor`);
|
||||
}
|
||||
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
|
||||
/** `prompt-manager-diagnostics-YYYYMMDD-HHMM.json` in local time. */
|
||||
export function reportFilename(now = new Date()) {
|
||||
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||
return `prompt-manager-diagnostics-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** The report as saved: the server's fields and the renderer line this page saw (null while it looks). */
|
||||
export function mergeReport(report, hostRenderers) {
|
||||
return { ...report, host_renderers: typeof hostRenderers === 'string' ? hostRenderers : null };
|
||||
}
|
||||
|
||||
/** One buffer entry as the concept prints it: `09:42:18 INFO message`. */
|
||||
export function logLine(entry) {
|
||||
const time = typeof entry?.timestamp === 'string' ? entry.timestamp.slice(11, 19) : '--:--:--';
|
||||
return `${time} ${String(entry?.level ?? '').padEnd(5)} ${entry?.message ?? ''}`;
|
||||
}
|
||||
|
||||
/** Lines: a whole number 1–500 after trimming, else null. */
|
||||
export function validLimit(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!/^\d+$/.test(text)) return null;
|
||||
const limit = Number(text);
|
||||
return limit >= 1 && limit <= MAX_LIMIT ? limit : null;
|
||||
}
|
||||
|
||||
/** The `/logs` request for a level (`''`: every level) and a valid limit. */
|
||||
export function logsUrl(level, limit) {
|
||||
const query = new URLSearchParams({ limit: String(limit) });
|
||||
if (level) query.set('level', level);
|
||||
return `${LOGS_URL}?${query}`;
|
||||
}
|
||||
|
||||
/** D109: "1,000 entries in memory · 3 errors · 12 warnings · 5 files · 41.2 MB on disk" (errors: ERROR + CRITICAL). */
|
||||
export function statsText(stats) {
|
||||
const counts = stats?.level_counts ?? {};
|
||||
const of = key => (Number.isSafeInteger(counts[key]) ? counts[key] : 0);
|
||||
const entries = Number.isSafeInteger(stats?.buffer_count) ? stats.buffer_count : 0;
|
||||
const errors = of('ERROR') + of('CRITICAL');
|
||||
const warnings = of('WARNING');
|
||||
const files = Number.isSafeInteger(stats?.log_files_count) ? stats.log_files_count : 0;
|
||||
return [`${number(entries)} ${noun(entries, 'entry', 'entries')} in memory`, `${number(errors)} ${noun(errors, 'error')}`,
|
||||
`${number(warnings)} ${noun(warnings, 'warning')}`, `${number(files)} ${noun(files, 'file')}`,
|
||||
`${sizeText(stats?.total_log_size)} on disk`].join(' · ');
|
||||
}
|
||||
|
||||
const megabytes = bytes => {
|
||||
const value = (Number.isFinite(bytes) ? bytes : 0) / 2 ** 20;
|
||||
return Number.isInteger(value) ? number(value) : value.toFixed(1);
|
||||
};
|
||||
|
||||
/** D109: the read-only retention values. */
|
||||
export function retentionText(config) {
|
||||
const copies = Number.isSafeInteger(config?.backup_count) ? config.backup_count : 0;
|
||||
const buffer = Number.isSafeInteger(config?.buffer_size) ? config.buffer_size : 0;
|
||||
return `Rotates at ${megabytes(config?.max_file_size)} MB · keeps ${number(copies)} rotated ${noun(copies, 'copy', 'copies')}`
|
||||
+ ` · file logging ${config?.file_logging === false ? 'off' : 'on'} · memory buffer ${number(buffer)} ${noun(buffer, 'entry', 'entries')}`;
|
||||
}
|
||||
|
||||
const rotatedOf = files => (Array.isArray(files) ? files : []).filter(row => row?.kind === 'rotated');
|
||||
|
||||
/** The concept's line under "Log files & retention": "prompt_manager.log · 1.4 MB · rotated copies: 3". */
|
||||
export function fileLine(files) {
|
||||
const active = (Array.isArray(files) ? files : []).find(row => row?.kind === 'active');
|
||||
const size = active ? sizeText(active.size) : 'not written yet';
|
||||
return `${ACTIVE_LOG} · ${size} · rotated copies: ${number(rotatedOf(files).length)}`;
|
||||
}
|
||||
|
||||
/** "2026-10-25 14:02" from the server's naive local ISO time. */
|
||||
export function modifiedText(iso) {
|
||||
return typeof iso === 'string' && iso.length >= 16 ? iso.slice(0, 16).replace('T', ' ') : 'unknown time';
|
||||
}
|
||||
|
||||
/** One listed file's line: "<size> · <modified> · <kind>". */
|
||||
export function fileDetail(row) {
|
||||
return `${sizeText(row?.size)} · ${modifiedText(row?.modified)} · ${KIND_LABELS[row?.kind] ?? KIND_LABELS.other}`;
|
||||
}
|
||||
|
||||
/** CUJ 7: what Clear all logs does, from the files read last (null: they could not be read). */
|
||||
export function clearSentence(files) {
|
||||
const tail = 'and clears the lines shown here. The other files in the log folder are kept. This cannot be undone.';
|
||||
if (!Array.isArray(files)) return `Empties ${ACTIVE_LOG}, deletes its rotated copies ${tail}`;
|
||||
const rotated = rotatedOf(files);
|
||||
if (!rotated.length) return `Empties ${ACTIVE_LOG} ${tail}`;
|
||||
const size = rotated.reduce((sum, row) => sum + (Number.isFinite(row.size) ? row.size : 0), 0);
|
||||
return `Empties ${ACTIVE_LOG}, deletes ${number(rotated.length)} rotated ${noun(rotated.length, 'copy', 'copies')} (${sizeText(size)}) ${tail}`;
|
||||
}
|
||||
|
||||
/** CUJ 7: the toast for `/logs/truncate`'s `results` ({truncated: [names], errors: [messages]}). */
|
||||
export function clearToast(results) {
|
||||
const truncated = Array.isArray(results?.truncated) ? results.truncated : [];
|
||||
const deleted = truncated.filter(name => typeof name === 'string' && name.toLowerCase() !== ACTIVE_LOG).length;
|
||||
const failed = Array.isArray(results?.errors) ? results.errors.length : 0;
|
||||
const parts = ['Logs cleared', `${number(deleted)} rotated ${noun(deleted, 'copy', 'copies')} deleted`];
|
||||
if (failed) parts.push(`${number(failed)} could not be deleted`);
|
||||
return `${parts.join(' · ')}.`;
|
||||
}
|
||||
|
||||
/** A prompt's short name for the test-link dialog: its first words, on one line. */
|
||||
export function promptName(prompt) {
|
||||
const text = String(prompt?.text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
||||
}
|
||||
|
||||
/** CUJ 8: the confirmation, naming the prompt the placeholder record links to. */
|
||||
export function testLinkSentence(prompt) {
|
||||
return `Writes a placeholder image record (${TEST_LINK_FILE}) linked to prompt #${prompt.id} “${promptName(prompt)}”. `
|
||||
+ 'Maintenance › Missing-file links removes it later.';
|
||||
}
|
||||
|
||||
/** S12-9: the runtime drafts from `GET /logs/config`'s `config`. */
|
||||
export function runtimeValues(config) {
|
||||
return { runtime_level: config?.level, console_logging: config?.console_logging };
|
||||
}
|
||||
|
||||
/** S12-9: the `POST /logs/config` body for the changed runtime drafts only. */
|
||||
export function runtimeBody(changes) {
|
||||
const body = {};
|
||||
if (Object.hasOwn(changes ?? {}, 'runtime_level')) body.level = changes.runtime_level;
|
||||
if (Object.hasOwn(changes ?? {}, 'console_logging')) body.console_logging = changes.console_logging;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Fetch `url` through the api-client URL and save it under the name the server gave (or `fallback`). */
|
||||
async function download(url, fallback, fetchUrl) {
|
||||
const response = await fetchUrl(url);
|
||||
if (!response.ok) throw new Error(`Download failed with ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
saveResponse(blob, backupFilename(response.headers.get('Content-Disposition'), fallback));
|
||||
}
|
||||
|
||||
/** Run `work` with `button` aria-disabled; a second press meanwhile is ignored. */
|
||||
async function pressing(button, work) {
|
||||
if (button.getAttribute('aria-disabled') === 'true') return;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
try {
|
||||
await work();
|
||||
} finally {
|
||||
button.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDialog({ scope, trigger, title, sentence, label, danger = false, onConfirm }) {
|
||||
const cancel = createButton('Cancel');
|
||||
const confirm = createButton(label, danger ? 'danger' : 'ca-primary');
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
confirm.addEventListener('click', () => {
|
||||
closeWorkspaceDialog();
|
||||
onConfirm();
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, confirm);
|
||||
openContentDialog({ scope, title, trigger, nodes: [el('p', sentence), actions] });
|
||||
}
|
||||
|
||||
/** The "Test image link" operation (D110): disabled until the most recent prompt was read. */
|
||||
function testLinkRow({ api, scope, notify }) {
|
||||
let prompt = null;
|
||||
const row = operationRow({ scope, title: 'Test image link', description: COPY.testLink, label: 'Create test link…',
|
||||
onClick: button => {
|
||||
if (button.getAttribute('aria-disabled') === 'true' || !prompt) return;
|
||||
const target = prompt;
|
||||
// The button is held until the write answered, so a second press cannot write a second record.
|
||||
confirmDialog({ scope, trigger: button, title: 'Create test link', sentence: testLinkSentence(target), label: 'Create',
|
||||
onConfirm: () => pressing(button, async () => {
|
||||
try {
|
||||
const answer = await api.post(TEST_LINK_URL, { prompt_id: target.id });
|
||||
if (!scope.signal.aborted) notify(`Test link created (image record ${answer?.result?.image_id}).`);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(COPY.testLinkFailed);
|
||||
}
|
||||
}) });
|
||||
} });
|
||||
const button = row.querySelector('.row-actions > button');
|
||||
const description = row.querySelector('p');
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
api.get(RECENT_URL, { signal: scope.signal, cache: 'no-store' }).then(answer => {
|
||||
if (scope.signal.aborted) return;
|
||||
const first = Array.isArray(answer?.results) ? answer.results[0] : null;
|
||||
if (first && first.id !== undefined && first.id !== null) {
|
||||
prompt = first;
|
||||
button.removeAttribute('aria-disabled');
|
||||
} else {
|
||||
description.textContent = `${COPY.testLink} ${COPY.noPrompt}`;
|
||||
}
|
||||
}, () => {
|
||||
if (!scope.signal.aborted) description.textContent = `${COPY.testLink} ${COPY.promptUnread}`;
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The "Diagnostics" surface. */
|
||||
function diagnosticsSurface({ api, activity, scope, navigate, notify, clientFactory }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
const heading = el('div', undefined, 'panel-heading');
|
||||
const run = createButton('Run checks');
|
||||
heading.append(el('h2', 'Diagnostics'), run);
|
||||
const grid = el('div', undefined, 'meta-grid');
|
||||
const cells = {};
|
||||
for (const term of META_TERMS) {
|
||||
const entry = el('div');
|
||||
cells[term] = el('dd', LOADING);
|
||||
entry.append(el('dt', term), cells[term]);
|
||||
grid.append(entry);
|
||||
}
|
||||
const report = createButton('Download diagnostic report');
|
||||
const inspect = operationRow({ scope, title: 'Image-link diagnostics', description: COPY.inspect, label: 'Inspect',
|
||||
onClick: () => navigate('#/inspect') });
|
||||
surface.append(heading, grid, report, inspect, testLinkRow({ api, scope, notify }));
|
||||
|
||||
// D95: the same Health report as Overview and Maintenance; its "Review missing links" reviews a fresh plan.
|
||||
const onReviewMissing = reviewMissingLinks({ api, store: activity, scope, notify, navigate });
|
||||
scope.listen(run, 'click', () => runHealthAction({ api, activity, scope, button: run, navigate, notify, onReviewMissing }));
|
||||
|
||||
const paint = state => {
|
||||
cells.Database.textContent = databaseText(state);
|
||||
cells.Sources.textContent = sourcesText(state);
|
||||
cells['Viewer.js'].textContent = viewerText(state);
|
||||
};
|
||||
paint({ loading: true });
|
||||
api.get(REPORT_URL, { signal: scope.signal, cache: 'no-store' })
|
||||
.then(answer => { if (!scope.signal.aborted) paint({ report: answer }); },
|
||||
error => { if (!scope.signal.aborted) paint({ error }); });
|
||||
|
||||
// S12-13: the renderer line Integrations shows (D106), from the send bridge.
|
||||
let renderers = null;
|
||||
let client = null;
|
||||
scope.cleanup(() => client?.dispose());
|
||||
(async () => {
|
||||
let text;
|
||||
try {
|
||||
client = clientFactory();
|
||||
text = rendererText(await client.discover({ waitMs: 300 }));
|
||||
} catch {
|
||||
text = RENDERER_COPY.unavailable;
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
renderers = text;
|
||||
cells['Host renderers'].textContent = text;
|
||||
})();
|
||||
|
||||
scope.listen(report, 'click', () => pressing(report, async () => {
|
||||
try {
|
||||
const answer = await api.get(REPORT_URL, { signal: scope.signal, cache: 'no-store' });
|
||||
const body = JSON.stringify(mergeReport(answer, renderers), null, 2);
|
||||
saveResponse(new Blob([body], { type: 'application/json' }), reportFilename());
|
||||
if (!scope.signal.aborted) notify(COPY.reportDone);
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
notify(error?.code === 'busy' ? `${refusalText(error)} Try again when it ends.` : COPY.reportFailed);
|
||||
}
|
||||
}));
|
||||
return surface;
|
||||
}
|
||||
|
||||
/** The two runtime rows: drafts of the `runtime` domain (S12-9), saved by Save preferences. */
|
||||
function runtimeRows({ scope, drafts }) {
|
||||
const level = settingRow({ id: 'runtime-level', label: 'Runtime log level', description: COPY.runtimeLevel,
|
||||
control: { type: 'select', options: RUNTIME_LEVELS }, scope,
|
||||
onChange: value => drafts.set('runtime', 'runtime_level', value) });
|
||||
const consoleRow = settingRow({ id: 'console-log', label: 'Console logging', description: COPY.console,
|
||||
control: { type: 'toggle', checked: false }, scope, onChange: value => drafts.set('runtime', 'console_logging', value) });
|
||||
const select = level.querySelector('[data-setting="runtime-level"]');
|
||||
const toggle = consoleRow.querySelector('[data-setting="console-log"]');
|
||||
select.dataset.key = 'runtime_level';
|
||||
toggle.dataset.key = 'console_logging';
|
||||
const repaint = () => {
|
||||
const value = drafts.get('runtime', 'runtime_level');
|
||||
select.disabled = typeof value !== 'string';
|
||||
if (typeof value === 'string') select.value = value;
|
||||
const on = drafts.get('runtime', 'console_logging');
|
||||
toggle.disabled = typeof on !== 'boolean';
|
||||
if (typeof on === 'boolean') toggle.checked = on;
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
// A failed read leaves both controls disabled; each says why (the value the server holds is not known).
|
||||
const notes = [level, consoleRow].map(row => {
|
||||
const note = el('p', COPY.runtimeUnread, 'small');
|
||||
return () => row.children[0].append(note);
|
||||
});
|
||||
const reload = () => drafts.load('runtime').then(() => {}, () => {
|
||||
if (!scope.signal.aborted) for (const show of notes) show();
|
||||
});
|
||||
reload();
|
||||
return { rows: [level, consoleRow], reload };
|
||||
}
|
||||
|
||||
/** The "Logs" surface. */
|
||||
function logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnly, STALE, fetchUrl }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
const toolbar = el('div', undefined, 'toolbar spaced-16');
|
||||
const levelLabel = el('label', 'Level', 'small');
|
||||
levelLabel.htmlFor = 'log-level';
|
||||
const level = el('select');
|
||||
level.id = 'log-level';
|
||||
for (const [value, label] of LEVEL_OPTIONS) {
|
||||
const option = el('option', label);
|
||||
option.value = value;
|
||||
level.append(option);
|
||||
}
|
||||
const limitLabel = el('label', 'Lines', 'small');
|
||||
limitLabel.htmlFor = 'log-limit';
|
||||
const limit = el('input');
|
||||
limit.id = 'log-limit';
|
||||
limit.type = 'number';
|
||||
limit.min = '1';
|
||||
limit.max = String(MAX_LIMIT);
|
||||
limit.step = '1';
|
||||
limit.value = String(DEFAULT_LIMIT);
|
||||
limit.title = COPY.limitHint;
|
||||
const refresh = createButton('Refresh');
|
||||
const downloadLog = createButton('Download log');
|
||||
toolbar.append(levelLabel, level, limitLabel, limit, refresh, downloadLog);
|
||||
|
||||
const lines = el('pre');
|
||||
lines.id = 'log-lines';
|
||||
lines.tabIndex = 0;
|
||||
const code = el('code', LOADING);
|
||||
lines.append(code);
|
||||
const stats = el('p', LOADING, 'small');
|
||||
stats.id = 'log-stats';
|
||||
stats.setAttribute('role', 'status');
|
||||
stats.setAttribute('aria-label', 'Log statistics');
|
||||
|
||||
const files = el('details');
|
||||
const line = el('p', LOADING, 'small');
|
||||
const review = createButton('Review log cleanup');
|
||||
const retention = el('p', LOADING, 'small');
|
||||
const list = el('div', undefined, 'operation-list');
|
||||
let listed = null;
|
||||
// The confirmation names what Clear deletes now: the files are read again before it opens (a read that fails
|
||||
// gives the general sentence, never an old count).
|
||||
const clear = operationRow({ scope, title: 'Clear all logs', description: COPY.clearDescription, label: 'Clear all logs…',
|
||||
danger: true, onClick: button => pressing(button, async () => {
|
||||
await readFiles();
|
||||
if (scope.signal.aborted) return;
|
||||
confirmDialog({ scope, trigger: button, title: 'Clear all logs', sentence: clearSentence(listed), label: 'Clear',
|
||||
danger: true, onConfirm: clearLogs });
|
||||
}) });
|
||||
files.append(el('summary', 'Log files & retention'), line, review, retention, list, clear);
|
||||
const runtime = runtimeRows({ scope, drafts });
|
||||
surface.append(el('h2', 'Logs'), toolbar, lines, stats, ...runtime.rows, files);
|
||||
|
||||
const logsRead = latestOnly({ lifetime: scope.signal });
|
||||
async function readLogs() {
|
||||
const count = validLimit(limit.value);
|
||||
if (count === null) {
|
||||
limit.setAttribute('aria-invalid', 'true');
|
||||
return;
|
||||
}
|
||||
limit.removeAttribute('aria-invalid');
|
||||
let answer;
|
||||
try {
|
||||
answer = await logsRead(signal => api.get(logsUrl(level.value, count), { signal, cache: 'no-store' }));
|
||||
} catch {
|
||||
if (!scope.signal.aborted) code.textContent = COPY.logUnread;
|
||||
return;
|
||||
}
|
||||
if (answer === STALE || scope.signal.aborted) return;
|
||||
const entries = Array.isArray(answer?.logs) ? answer.logs : [];
|
||||
code.textContent = entries.length ? entries.map(logLine).join('\n') : COPY.noEntries;
|
||||
}
|
||||
|
||||
/** One latest-only read of `url` painting `paint(answer)`, or `failed()` when it could not be read. */
|
||||
const reader = (url, paint, failed) => {
|
||||
const read = latestOnly({ lifetime: scope.signal });
|
||||
return async () => {
|
||||
let answer;
|
||||
try {
|
||||
answer = await read(signal => api.get(url, { signal, cache: 'no-store' }));
|
||||
} catch {
|
||||
if (!scope.signal.aborted) failed();
|
||||
return;
|
||||
}
|
||||
if (answer !== STALE && !scope.signal.aborted) paint(answer);
|
||||
};
|
||||
};
|
||||
const readStats = reader(STATS_URL, answer => { stats.textContent = statsText(answer?.stats); },
|
||||
() => { stats.textContent = COPY.statsUnread; });
|
||||
const readConfig = reader(CONFIG_URL, answer => { retention.textContent = retentionText(answer?.config); },
|
||||
() => { retention.textContent = COPY.configUnread; });
|
||||
const readFiles = reader(FILES_URL, answer => {
|
||||
listed = Array.isArray(answer?.files) ? answer.files : [];
|
||||
line.textContent = fileLine(listed);
|
||||
list.replaceChildren(...listed.map(fileRow));
|
||||
}, () => {
|
||||
listed = null;
|
||||
line.textContent = COPY.filesUnread;
|
||||
list.replaceChildren();
|
||||
});
|
||||
|
||||
function fileRow(row) {
|
||||
const item = el('div', undefined, 'operation');
|
||||
const text = el('div');
|
||||
text.append(el('h3', row.filename), el('p', fileDetail(row)));
|
||||
item.append(text);
|
||||
if (row.kind !== 'link') {
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const button = createButton('Download');
|
||||
button.setAttribute('aria-label', `Download ${row.filename}`);
|
||||
scope.listen(button, 'click', () => saveLog(button, row.filename));
|
||||
actions.append(button);
|
||||
item.append(actions);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function saveLog(button, name) {
|
||||
return pressing(button, async () => {
|
||||
try {
|
||||
await download(`${DOWNLOAD_URL}${encodeURIComponent(name)}`, name, fetchUrl);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(COPY.logDownloadFailed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readAll() {
|
||||
readLogs();
|
||||
readStats();
|
||||
readFiles();
|
||||
readConfig();
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
let answer;
|
||||
try {
|
||||
answer = await api.post(TRUNCATE_URL, {});
|
||||
} catch {
|
||||
if (!scope.signal.aborted) notify(COPY.clearFailed);
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (answer) notify(clearToast(answer.results));
|
||||
readAll();
|
||||
runtime.reload();
|
||||
}
|
||||
|
||||
scope.listen(level, 'change', readLogs);
|
||||
scope.listen(limit, 'change', readLogs);
|
||||
scope.listen(refresh, 'click', () => { readLogs(); readStats(); readFiles(); });
|
||||
scope.listen(downloadLog, 'click', () => saveLog(downloadLog, ACTIVE_LOG));
|
||||
scope.listen(review, 'click', () => reviewPlan({ api, store: activity, scope, operation: 'rotated_logs', trigger: review,
|
||||
notify, navigate }));
|
||||
readAll();
|
||||
return surface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Diagnostics & logs in `container` (System's `.settings-layout`); returns its two surfaces in concept order.
|
||||
* `drafts` is the System draft store (its runtime domain holds the log level and console setting), `activity` the
|
||||
* page's Activity store.
|
||||
*/
|
||||
export function mountDiagnostics(container, { api, activity, scope, drafts, navigate = navigateTo, notify = showWorkspaceToast,
|
||||
clientFactory = bridgeFactory(), latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE,
|
||||
fetchUrl = window.ApiClient.fetchUrl }) {
|
||||
return [
|
||||
diagnosticsSurface({ api, activity, scope, navigate, notify, clientFactory }),
|
||||
logsSurface({ api, activity, scope, drafts, navigate, notify, latestOnly, STALE, fetchUrl }),
|
||||
];
|
||||
}
|
||||
@@ -345,7 +345,8 @@ function comfySurface({ scope, clientFactory }) {
|
||||
return surface;
|
||||
}
|
||||
|
||||
function bridgeFactory() {
|
||||
/** The send bridge's client factory for this page's mount (Integrations and Diagnostics & logs, S12-13). */
|
||||
export function bridgeFactory() {
|
||||
const url = new URL(window.ApiClient.resolveUrl('/prompt_manager/'), window.location.href);
|
||||
return () => createBridgeClient({ namespace: url.pathname.slice(0, -'prompt_manager/'.length), origin: window.location.origin });
|
||||
}
|
||||
|
||||
@@ -134,14 +134,22 @@ function metadataCacheRow({ scope, notify, cache }) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Health report's "Review missing links" (D95): a fresh Missing-file links plan, never a cleanup, holding the pressed
|
||||
* button while it is read. Maintenance and Diagnostics & logs (S12) pass it to `runHealthAction`.
|
||||
*/
|
||||
export function reviewMissingLinks({ api, store, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
return (trigger, pressed = trigger) => reviewPlan({ api, store, scope, operation: 'missing_links', trigger, hold: pressed,
|
||||
notify, navigate });
|
||||
}
|
||||
|
||||
/**
|
||||
* The Maintenance section's mount (`mountSystem`'s `sections.maintenance`): the notice and the two surfaces. `activity`
|
||||
* is the page's Activity store.
|
||||
*/
|
||||
export function mountMaintenance(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
|
||||
cache = ownership() }) {
|
||||
const reviewMissing = (trigger, pressed = trigger) => reviewPlan({ api, store: activity, scope, operation: 'missing_links',
|
||||
trigger, hold: pressed, notify, navigate });
|
||||
const reviewMissing = reviewMissingLinks({ api, store: activity, scope, notify, navigate });
|
||||
const actions = {
|
||||
health: button => runHealthAction({ api, activity, scope, button, onReviewMissing: reviewMissing, notify }),
|
||||
duplicates: () => findDuplicates({ store: activity, scope, notify, navigate }),
|
||||
|
||||
@@ -85,6 +85,9 @@ export const STATUS_COPY = Object.freeze({
|
||||
export const SETTING_PROBLEMS = Object.freeze({
|
||||
autotag_general_threshold: 'General-tag confidence must be between 0 and 1.',
|
||||
autotag_character_threshold: 'Character-tag confidence must be between 0 and 1.',
|
||||
// #369: every page shows the thresholds in steps of 0.05, so the server saves only values on that grid.
|
||||
autotag_general_threshold_step: 'General-tag confidence must be a multiple of 0.05.',
|
||||
autotag_character_threshold_step: 'Character-tag confidence must be a multiple of 0.05.',
|
||||
autotag_suggestion_limit: 'Suggestions per image must be a whole number from 1 to 100.',
|
||||
// The server's two template sentences (`validate_template`), blank first.
|
||||
autotag_template: 'LLM tag template must be non-empty text.',
|
||||
@@ -171,10 +174,15 @@ export function draftValue(key, text) {
|
||||
}
|
||||
|
||||
const unit = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
// A multiple of 0.05 within the server's tolerance (`validate_saved_threshold`): (0.1 + 0.2) * 20 is 6.000000000000001.
|
||||
const onStep = value => Math.abs(value * 20 - Math.round(value * 20)) <= 1e-12;
|
||||
// A threshold on the grid, handed on exactly as the server stores it (anything else is left for draftProblem to refuse).
|
||||
const snapped = value => (unit(value) && onStep(value) ? Math.round(value * 20) / 20 : value);
|
||||
|
||||
/**
|
||||
* The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14
|
||||
* reads both thresholds, the language models the template; Generate also reads the suggestion count (`limit`).
|
||||
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the template; Generate also reads the
|
||||
* suggestion count (`limit`).
|
||||
*/
|
||||
export function draftProblem(values, { limit = true } = {}) {
|
||||
const model = values.autotag_model_type;
|
||||
@@ -189,6 +197,7 @@ export function draftProblem(values, { limit = true } = {}) {
|
||||
}
|
||||
const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value);
|
||||
if (!valid) return { key, message: SETTING_PROBLEMS[key] };
|
||||
if (key !== 'autotag_suggestion_limit' && !onStep(value)) return { key, message: SETTING_PROBLEMS[`${key}_step`] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -197,7 +206,8 @@ export function draftProblem(values, { limit = true } = {}) {
|
||||
export function taggingPreset(values) {
|
||||
const model = values.autotag_model_type;
|
||||
const options = CLASSIFIERS.has(model)
|
||||
? { general_threshold: values.autotag_general_threshold, character_threshold: values.autotag_character_threshold }
|
||||
? { general_threshold: snapped(values.autotag_general_threshold),
|
||||
character_threshold: snapped(values.autotag_character_threshold) }
|
||||
: { instructions: values.autotag_template };
|
||||
return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } };
|
||||
}
|
||||
@@ -206,7 +216,8 @@ export function taggingPreset(values) {
|
||||
export function batchBody(values) {
|
||||
const model = values.autotag_model_type;
|
||||
const options = CLASSIFIERS.has(model)
|
||||
? { general_threshold: values.autotag_general_threshold, character_threshold: values.autotag_character_threshold }
|
||||
? { general_threshold: snapped(values.autotag_general_threshold),
|
||||
character_threshold: snapped(values.autotag_character_threshold) }
|
||||
: { instructions: values.autotag_template };
|
||||
return { model_type: model, existing: values.autotag_existing, options };
|
||||
}
|
||||
|
||||
+21
-45
@@ -3,11 +3,10 @@
|
||||
* Under the page heading it renders the section nav (`.section-nav`, one button per section, the shown one
|
||||
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
|
||||
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
|
||||
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs),
|
||||
* Auto-tagging (system-tagging.mjs), Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are
|
||||
* native.
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. Every section is native: Overview
|
||||
* (system-overview.mjs), Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations
|
||||
* (system-integrations.mjs), Auto-tagging (system-tagging.mjs), Maintenance (system-maintenance.mjs), Backup & export
|
||||
* (system-backups.mjs) and Diagnostics & logs (system-diagnostics.mjs, S12, which retired the D94 hand-off panel).
|
||||
*
|
||||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
@@ -22,21 +21,13 @@ import { mountSources } from './system-sources.mjs';
|
||||
import { mountMaintenance } from './system-maintenance.mjs';
|
||||
import { mountBackups } from './system-backups.mjs';
|
||||
import { mountTagging } from './system-tagging.mjs';
|
||||
import { mountDiagnostics, runtimeBody, runtimeValues, CONFIG_URL as LOG_CONFIG_URL } from './system-diagnostics.mjs';
|
||||
import { LORA_ENABLE_URL, LORA_STATUS_URL, loraValues, mountIntegrations, writeHostInParts } from './system-integrations.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { displayPreferences, readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
|
||||
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||||
/**
|
||||
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): only Diagnostics &
|
||||
* logs still hands off, to the admin page's Logs and Diagnostics tools.
|
||||
*/
|
||||
export const SECTION_HANDOFFS = Object.freeze({
|
||||
diagnostics: [ADMIN],
|
||||
});
|
||||
export const NOT_DELIVERED = 'This setting is not available here yet.';
|
||||
|
||||
let drafts = null;
|
||||
// The Activity store of the System page shown last: a refused host save that does not name its job reads the list.
|
||||
@@ -49,10 +40,9 @@ async function runningJob(error) {
|
||||
return jobs.running()[0];
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
function el(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -70,18 +60,16 @@ export function settingRefusal(error, body) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
|
||||
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
|
||||
|
||||
/**
|
||||
* The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write
|
||||
* says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds.
|
||||
* The host domain reads and saves the server settings with their revision (#220).
|
||||
* The host domain reads and saves the server settings with their revision (#220). The runtime domain is the running
|
||||
* server's log level and console setting (S12-9, `/logs/config`): applied until the server restarts.
|
||||
*/
|
||||
export function systemDrafts(api) {
|
||||
drafts ??= createSystemDrafts({
|
||||
readBrowser: () => readPreferences().values,
|
||||
readRuntime: () => ({}),
|
||||
readRuntime: async () => runtimeValues((await api.get(LOG_CONFIG_URL, { cache: 'no-store' })).config),
|
||||
writeBrowser: changes => {
|
||||
// Another page changed a drafted value since this tab read it, and no storage event told it yet: refuse,
|
||||
// so the store keeps the draft, flags it and reads the values again; the next Save is an explicit choice.
|
||||
@@ -90,7 +78,7 @@ export function systemDrafts(api) {
|
||||
if (conflicts.length) throw Object.assign(new Error('Settings changed elsewhere'), { code: 'settings-changed' });
|
||||
return { persisted: writePreferences(changes, undefined, { baseline }).persisted };
|
||||
},
|
||||
writeRuntime: undelivered,
|
||||
writeRuntime: changes => api.post(LOG_CONFIG_URL, runtimeBody(changes)),
|
||||
async readHost() {
|
||||
// S10: the LoRA state rides along; when it cannot be read its values stay missing (Integrations says so)
|
||||
// and every other host setting still loads.
|
||||
@@ -118,19 +106,6 @@ export function systemDrafts(api) {
|
||||
return drafts;
|
||||
}
|
||||
|
||||
/** D94: the shared hand-off panel, linking each legacy tool that still owns this section. */
|
||||
function handoff(section) {
|
||||
const panel = el('section', undefined, 'surface handoff');
|
||||
panel.append(el('h2', 'Your current tools are ready'),
|
||||
el('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'));
|
||||
for (const tool of SECTION_HANDOFFS[section]) {
|
||||
const link = el('a', tool.label, 'workspace-link');
|
||||
link.href = window.ApiClient.resolveUrl(tool.href);
|
||||
panel.append(link);
|
||||
}
|
||||
return [panel];
|
||||
}
|
||||
|
||||
/** D92: highlight the controls of the settings the last save could not save. */
|
||||
function markFailed(layout, store) {
|
||||
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
|
||||
@@ -142,8 +117,9 @@ function markFailed(layout, store) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11),
|
||||
* Integrations (S10 Task 27), Auto-tagging (S11 Task 6, #328), Maintenance (S7 Task 19) and Backup & export (S9 Task 24).
|
||||
* Every System section: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11), Integrations (S10
|
||||
* Task 27), Auto-tagging (S11 Task 6, #328), Maintenance (S7 Task 19), Backup & export (S9 Task 24) and Diagnostics &
|
||||
* logs (S12, #329).
|
||||
*/
|
||||
export const DELIVERED_SECTIONS = Object.freeze({
|
||||
overview: mountOverview,
|
||||
@@ -153,13 +129,13 @@ export const DELIVERED_SECTIONS = Object.freeze({
|
||||
tagging: mountTagging,
|
||||
maintenance: mountMaintenance,
|
||||
backups: mountBackups,
|
||||
diagnostics: mountDiagnostics,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
|
||||
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
|
||||
* (`(container, {api, activity, scope, drafts}) => nodes`), and every other section hands off. `activity` is the page's
|
||||
* Activity store, which follows the jobs a section starts.
|
||||
* (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
|
||||
*
|
||||
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
|
||||
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
|
||||
@@ -184,9 +160,9 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
|
||||
scope.cleanup(subscribePreferences(window, next => reconcile(next.values)));
|
||||
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
|
||||
onSelect: id => navigate(`#/system/${id}`) });
|
||||
const layout = el('div', undefined, 'settings-layout');
|
||||
const saveRow = el('div', undefined, 'save-row');
|
||||
const status = el('span', undefined, 'small');
|
||||
const layout = el('div', 'settings-layout');
|
||||
const saveRow = el('div', 'save-row');
|
||||
const status = el('span', 'small');
|
||||
status.id = 'settings-status';
|
||||
const save = createButton('Save preferences', 'ca-primary');
|
||||
saveRow.append(status, save);
|
||||
@@ -214,8 +190,8 @@ export function mountSystem(main, api, scope, { section, activity, sections = DE
|
||||
sectionScope = createScope();
|
||||
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
|
||||
while (layout.firstChild !== saveRow) layout.firstChild.remove();
|
||||
const mount = sections[next];
|
||||
const nodes = mount ? mount(layout, { api, activity, scope: sectionScope, drafts: store }) : handoff(next);
|
||||
// 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 });
|
||||
layout.prepend(...nodes);
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
@@ -85,9 +85,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.tool-card { border: 1px solid var(--line); background: var(--ca-field); border-radius: 8px; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tool-card p { flex: 1; margin: 0 0 12px; }
|
||||
.workspace-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 0; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.handoff .workspace-link { margin-top: 12px; }
|
||||
/* D94: a System section not delivered yet links every legacy tool that still owns it, side by side. */
|
||||
.handoff .workspace-link + .workspace-link { margin-inline-start: 18px; /* D94 */ }
|
||||
|
||||
/* D92: System's Save row is aria-disabled while nothing is unsaved (or a save runs), and a setting the last save could
|
||||
not save is highlighted, as its "Review the highlighted settings" status says. */
|
||||
@@ -439,3 +436,8 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
#workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ }
|
||||
#workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ }
|
||||
}
|
||||
|
||||
/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value
|
||||
outside 1–500 is highlighted as a refused setting is (D92). */
|
||||
.toolbar #log-limit { inline-size: 6em; min-height: 36px; font-size: 11px; /* D109 */ }
|
||||
.toolbar #log-limit[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D109 */ }
|
||||
|
||||
Reference in New Issue
Block a user