Compare commits

..
Author SHA1 Message Date
Vito Sansevero 1145ed7198 test(tagging): the batch coverage row names the renamed #379 test 2026-10-27 13:05:24 -07:00
Vito Sansevero e16d7b3f8c Merge remote-tracking branch 'origin/main' into fix/379-per-image-tagging-pr1 2026-10-27 13:04:47 -07:00
Vito Sansevero 073374e681 test(browser): reload the admin page only after the in-place edit saved
The gallery-to-admin workflow pressed Enter and reloaded as soon as the
edited text showed; the page saves after showing it, so a reload that won
the race abandoned the PUT (WebKit, run 40564511000). The test now waits
for the save's answer.

Part of #379
2026-10-27 12:16:50 -07:00
Vito 52a00370ea test(system): prove Diagnostics & logs against the concept and every legacy System capability's owner (#329, PR 3) (#387)
* fix(logs): declare the compressed length so a log download finishes

The gzip middleware compressed a text body but kept the handler's declared
Content-Length (the uncompressed size), so a browser waited for bytes that
never came: downloading any log over 1 KB hung, in the workspace and on the
admin page. The middleware now drops a declared length before compressing.

Part of #329

* feat(system): show Diagnostics & logs natively and retire the hand-off panel

System › Diagnostics & logs is native (D109, D110): the concept's
Diagnostics surface with the live database, source, Viewer.js and renderer
lines, Run checks (the shared Health report), the diagnostic report, Inspect
and the confirmed test link; and the Logs surface with the minimum-level
viewer, Lines 1-500, statistics, the log download, the runtime level and
console setting saved by Save preferences, the log files with their
downloads, Review log cleanup (the rotated_logs maintenance plan, no backup
box, Listed files) and a confirmed Clear all logs.

D94's hand-off panel, SECTION_HANDOFFS and the undelivered runtime adapter
are gone. The browser server logs into its own temporary root.

Part of #329

* test(system): compare Diagnostics & logs with the concept

System › Diagnostics & logs and its log cleanup review are compared with the
concept (app.js 694–695 and maintenancePlan("rotated logs")) in both themes at
1600x1100 and 390x844, with D109 and D110 (and D100 for the review) as the only
mapped differences, a 320-wide select check and a mutation check.

The fixture gains a concept seed mode (the concept's four log lines, a 1.4 MB
active log with three rotated copies, console logging off), and the plan
dialog's measureReview and mapPlanConcept move unchanged into
concept-inventory.mjs for Maintenance and this review.

The comparison found that the Database line read a report key that does not
exist (database_info.size_bytes) and so always showed "0 bytes"; it now reads
database_size_bytes, the key get_database_info() returns.

Part of #329

* test(system): prove every legacy System capability has an owner

TESTING.md gains "Legacy System capability coverage": every System capability
of the legacy admin and gallery pages with its anchor on main, owner, status
(covered, kept in legacy or approved removal), native control and the exact
title of the test that proves it.

tests/vendor/legacy-coverage.test.mjs (with tools/vendor/legacy-coverage.mjs)
fails on a row with no owner, an unknown status, a malformed native control, a
control on a row that is not covered, or a test title its file does not
declare; a title only in a comment does not count. workspace-system.spec's
"every covered legacy System capability has its native control" opens each
covered row's section and finds the control by role and accessible name.

Versus the design's table: the anchors are re-checked against main, "Run
health checks" is on Maintenance (D123), not Overview, Diagnostics run is
proven by "Run checks opens the shared Health report", and the export rows
repeat their test title instead of "(same as above)".

Part of #329

* fix(system): confirm Clear all logs on a fresh file list and record coverage

Review fixes for the Diagnostics & logs section:
- Clear all logs reads the log files again before it confirms, so the
  dialog names the copies it deletes now (a list it cannot read gives the
  general sentence, never an old count); Refresh reads the files too.
- Runtime values that cannot be read stay disabled and say so.
- Create test link holds its button until the write answered, so a second
  press cannot write a second record.
- The Lines field names its range.
- The browser server builds its logger in its own root, so no log file is
  ever opened in another folder; the fixture closes every directory scan
  and replaces a link instead of writing through it.
- system.mjs drops the unused section fallback; frontend coverage floors
  recorded with coverage:update (system-diagnostics.mjs 97.5% lines).

Part of #329

* fix(system): read the database size the report sends

The Database line read database_info.size_bytes, a key the report does
not have (get_database_info answers database_size_bytes), so it always
read "Readable · 0 bytes · N images". The browser test now requires a
non-zero size. Found by PR 3's concept comparison.

Part of #329

* test(system): require a running test and a control for every covered capability

Review fixes for the legacy coverage proof:

- A title declared only with test.skip, .fixme or .fail no longer proves a
  row (.only and .slow still do).
- Every covered row names its native control, except the rows in
  CONTROL_EXEMPTIONS, each with its reason (today only the gallery per-image
  auto-tag row, owned by the Images selection bar). The browser test asserts
  that the covered rows without a control are exactly the exemptions.
- JavaScript comments are stripped across the whole source, wherever a block
  comment starts, skipping strings, template literals and regular
  expressions. A Python def test_ counts only at function or method
  indentation and outside comments and docstrings.
- A control name several rows share ("Review plan", "Review options",
  "Refresh") is looked up inside the row the table names (· in "<row>").
- The diagnostics comparison records each select's chosen option, so a wrong
  default level is caught.

Part of #329
2026-10-27 19:15:37 +00:00
Vito Sansevero d5272ef0bf Merge remote-tracking branch 'origin/main' into fix/379-per-image-tagging-pr1
# Conflicts:
#	TESTING.md
2026-10-27 11:49:54 -07:00
Vito Sansevero 49db650b0a Merge remote-tracking branch 'origin/main' into fix/379-per-image-tagging-pr1 2026-10-27 11:20:21 -07:00
Vito Sansevero d051fc2a5c fix(tagging): measured GPU rate, and an ended image-tags job shows no time left (#379)
The live check measured WD14 on CUDA at 0.167 s per image (100 images in
16.7 s with the model load), so the review estimate uses 0.17 s. A finished
or cancelled Auto-tag/Add tags job kept "Processing file N of N · about X
min left"; it now ends with "<verb> <state> · N files processed".

Part of #379
2026-10-27 11:20:20 -07:00
Vito bdc38422a9 feat(system): Diagnostics & logs natively, reviewed log cleanup and Clear all logs (#329, PR 2) (#385)
* fix(logs): declare the compressed length so a log download finishes

The gzip middleware compressed a text body but kept the handler's declared
Content-Length (the uncompressed size), so a browser waited for bytes that
never came: downloading any log over 1 KB hung, in the workspace and on the
admin page. The middleware now drops a declared length before compressing.

Part of #329

* feat(system): show Diagnostics & logs natively and retire the hand-off panel

System › Diagnostics & logs is native (D109, D110): the concept's
Diagnostics surface with the live database, source, Viewer.js and renderer
lines, Run checks (the shared Health report), the diagnostic report, Inspect
and the confirmed test link; and the Logs surface with the minimum-level
viewer, Lines 1-500, statistics, the log download, the runtime level and
console setting saved by Save preferences, the log files with their
downloads, Review log cleanup (the rotated_logs maintenance plan, no backup
box, Listed files) and a confirmed Clear all logs.

D94's hand-off panel, SECTION_HANDOFFS and the undelivered runtime adapter
are gone. The browser server logs into its own temporary root.

Part of #329

* fix(system): confirm Clear all logs on a fresh file list and record coverage

Review fixes for the Diagnostics & logs section:
- Clear all logs reads the log files again before it confirms, so the
  dialog names the copies it deletes now (a list it cannot read gives the
  general sentence, never an old count); Refresh reads the files too.
- Runtime values that cannot be read stay disabled and say so.
- Create test link holds its button until the write answered, so a second
  press cannot write a second record.
- The Lines field names its range.
- The browser server builds its logger in its own root, so no log file is
  ever opened in another folder; the fixture closes every directory scan
  and replaces a link instead of writing through it.
- system.mjs drops the unused section fallback; frontend coverage floors
  recorded with coverage:update (system-diagnostics.mjs 97.5% lines).

Part of #329

* fix(system): read the database size the report sends

The Database line read database_info.size_bytes, a key the report does
not have (get_database_info answers database_size_bytes), so it always
read "Readable · 0 bytes · N images". The browser test now requires a
non-zero size. Found by PR 3's concept comparison.

Part of #329
2026-10-27 18:20:05 +00:00
Vito a985af506b test(browser): seed resets on a scratch copy and publish them in one commit (#384) (#388)
* test(browser): seed resets on a scratch copy and publish them in one commit (#384)

The concept and plain resets made hundreds of committed transactions on the
live database; on a slow CI disk they queued behind WAL checkpoint syncs and
took 30+ s. Each reset now copies the live database into a scratch database
(backup API, no syncs), runs the unchanged seeder against it through an API
proxy, and replaces the rows of the tables the seed changed in one live
transaction. A commit by another live connection meanwhile fails the reset.

tests/browser/reset_equivalence.py proves every reset kind leaves the same
rows, files and response as the old path (PM_TEST_SEED_ON_LIVE=1).

* test(browser): empty media folders by a rename, delete them in the background (#384)

With the database commits gone, throttled resets still stalled in rmtree:
unlinking the last fixture's files waits for their pending writeback. Each
reseed now moves the old output and input folders into <root>/reset-trash
and makes empty ones; a background thread deletes the trash, and the
server's cleanup stops it and removes the rest before the temp root goes.

* fix(test): recheck the live data version under the publish's write lock (#384)

A writer whose transaction was open while the seed ran committed before
BEGIN IMMEDIATE returned and its rows were overwritten. The publish now
rechecks data_version under the lock, and restores foreign_keys and
detaches the scratch database on every error path.

* test(browser): keep the reset scratch database in /dev/shm when there is one (#384)

Throttled runs still waited on the runner's disk while creating and
rewriting the scratch database, whose pages were under writeback.

* test(browser): keep the scratch on disk when /dev/shm is short, delete in place after cleanup (#384)

The largest scratch copy is about 8 MB; Docker's default /dev/shm is 64 MB
and shared with the browsers. A reset that empties a folder after the
server's cleanup closed its trash deletes in place instead of starting a
thread nobody stops.

* fix(test): drop the gallery caches only after the reset's rows are published (#384)

The seeders drop the Images listing at their end. Run on the scratch copy,
that drop came before the publish, so a background walk in between could
cache per-file values built from the live database's old rows: CI's
inspector tag chip found 0 of 0 images. The proxy now records the drop
and the seeder makes it after the publish, as the old reset did.

* fix(test): remove /dev/shm reset scratch left by killed browser servers (#384)

A server killed outright never runs its cleanup, and tmpfs keeps the
scratch folder in memory until reboot: the Python suite's server tests
left one per killed server. Scratch folders now carry the owner's pid,
and each new one first removes those whose process is gone (POSIX only).
2026-10-27 17:45:24 +00:00
Vito a0fc5171e3 fix(inspector): pass text through unknown nodes instead of refusing them (#381) (#383)
* fix(inspector): pass text through unknown nodes instead of refusing them (#381)

The inspector refused any node class missing from the semantics table, so a
PromptManagerPair behind an rgthree Any Switch showed "Prompt uses a node the
inspector does not support". The inspection resolver (Python and browser) now
reads through unknown nodes: a text input is the text, positive/negative route
by output slot, a conditioning input is followed alone, otherwise every linked
input except core type inputs (pass_through.skip_inputs). PromptManager nodes
are authoritative; several different texts become one candidate each
("Several prompts reach the sampler"). A seeded text node with no conditioning
input is generated at run time, and a recorded PromptManager execution is not
checked against text that passed through an unknown node.

INSPECTION_RESOLVER_VERSION goes to 2; ingestion keeps the default profile.
A legacy parity gate records every committed graph whose inspection positive
matches the legacy parser's; no graph may leave it.

* fix(inspector): keep recorded pair text, string-only seed rule, role inputs and unique candidates (#381)

Review of the pass-through: a walk through an unknown node never overrides a
PromptManagerPair's execution record (any outcome but work-limit is
unverifiable while the record is checked); the run-time seed rule reads only
string outputs; a node with positive/negative inputs follows only the role of
the requested slot, nothing when it is unlinked; one text offered twice is one
candidate.

* fix(inspector): anchor on the saver that wrote the file and resolve stages on shared links (#381)

An inspected file's name now anchors the inspection on the one saver whose
filename_prefix (literal or traced) makes the file's stem <prefix>_<digits>[_];
the samplers in that saver's image ancestry are the candidates. No match,
several matches or an unreadable walk anchor nothing, so the file stays
"several possible prompts". In a latent walk, an earlier sampler reading the
same conditioning links as the later one is the same prompt, not an
unattributed stage. Python and the browser resolver behave the same; the
legacy parity gate states each graph that differs from legacy on purpose.

* fix(inspector): skip only plain first stages on shared links; an unfinished prefix scan anchors nothing (#381)

Review of the saver anchor: a same-links earlier stage is skipped only when it
is not a detailer, sets no prompt override and continues no other sampler, so
an earlier prompt or override behind it is never hidden. A work limit while
tracing a saver prefix ends the match scan without an anchor instead of
counting that saver as no match.

* test(inspector): cover every plain-first-stage outcome on both resolvers (#381)

The frontend coverage gate found local-resolver-stages.mjs branches at 92.44%
(< 93). Each way an earlier stage stops being a plain first stage (no latent,
literal, missing or cyclic latent, a sampler-shaped node, a detailer) is now a
resolver test and an anchor-first-stage-* parity vector.
2026-10-27 16:56:37 +00:00
Vito 0652beba90 ci: run the required workflows for the merge queue (#382)
* ci: run the required workflows for the merge queue

main no longer requires a branch to be up to date before it merges, so a
PR no longer re-runs CI after every other merge. The Tests, Code Quality
and Viewer.js workflows also run on merge_group, so the merge queue can
test each PR against the latest main once, in order. The tracked
protection record now says strict: false.

* test(history): give waits that span a database restore their own ceiling

The merge queue's Windows py3.12 job failed
test_restore_or_generation_rotation_between_transactions_discards_evidence
(restore=True): the restore it waits for, a full verify/migrate/backup/
replace cycle, took over the 10 s BLOCK_SECONDS on a busy runner. The two
waits that span a restore now use RESTORE_SECONDS (90 s), which stays under
the suite's 120 s hang watchdog, so a real hang still fails with its thread
dump. Behavioral waits keep BLOCK_SECONDS.

* test(history): drain the writer before every bounded close or dispose

The merge queue's Windows py3.12 job failed
test_saturated_start_cannot_capture_outputs_or_retain_previous_roots:
dispose(2) ran while the writer still worked through 1,024 queued
commands on a busy runner. Like #370, the test now drains first; the 2 s
bound is not what it tests. Four more closes that follow a released
block get the same drain, so the next slow runner can't fail them one at
a time.

* ci(comfyui): run the real-host job in the pinned Playwright image

The real ComfyUI compatibility job installed Chromium with
`playwright install --with-deps`, which pulls the browser's system
packages from Ubuntu's apt mirror on every run. Three runs (#349 run
40367579020, #378, #382 job 122287230985: "Fetched 19.7 MB in 12min 23s
(26.5 kB/s)") spent 11-12 minutes there and were cancelled at the
24-minute limit after all 89 tests had passed.

The job now runs inside mcr.microsoft.com/playwright:v1.64.1-noble, the
same image as the frontend coverage and page workflow jobs, which ships
Chromium and its libraries, so the job has no apt step at all. The
whole job moves rather than only the browser part: the Playwright
suites launch the managed ComfyUI host themselves (PM_TEST_PYTHON), so
splitting the browser off would mean running the host on the runner and
the browser in a container across a network boundary, a larger change
than moving one job whose host is plain Python and pip wheels.

Python follows the other image jobs: the image's own Python 3.12 with a
user-space venv bootstrapped from a checksum-pinned pip wheel (toolcache
setup-python builds do not run in the container). A step fails the job
when the image Python differs from pythonVersion in
tools/vendor/comfyui-runtime.json, so the pinned host stays honest;
torch, torchvision and ComfyUI's requirements install from the same
pins as before. pip and npm download caches replace setup-python's and
setup-node's.

tests/vendor/browser-image.test.mjs now checks this job like the other
browser jobs: image tag equals @playwright/test in the lockfile, no
browser install or apt, no setup-python/setup-node, checksum-pinned pip,
Node 24, and the runtime Python check. The 24-minute limit is unchanged.
2026-10-27 16:35:01 +00:00
Vito f35e010f79 feat(system): log cleanup rule, reviewed rotated-log cleanup and diagnostic report (#329, PR 1) (#367)
* fix(logs): delete and download only rotated copies under one rule

Rule R (#329): a file is a rotated copy only when it is a direct entry of
the log folder named prompt_manager.log.<n> (n >= 1, no leading zero,
compared after normcase) and a regular file that is not a symlink or a
junction. Everything else is never deleted.

- Clear (POST /logs/truncate) empties the active log and deletes only
  rotated copies; a .bak, a .1.gz, any other file and every link are kept.
  It runs off the event loop under one files lock and the file handler's
  lock, so no rollover renames a file mid-way, and logs only afterwards.
- delete_rotated(expected) deletes exactly the reviewed copies, or nothing
  when one of them changed (size, mtime_ns, inode, missing, now a link).
- The download serves only a listed regular, non-link file, opened without
  following links and read off the loop; a link or folder is a 404.
- /logs/files rows gain kind; files and stats read links with lstat and
  stats no longer count a link's target.
- The config update runs off the loop and swaps handlers under the files
  lock.

* feat(logs): review and apply rotated-log cleanup as a maintenance plan

POST /maintenance/preview {operation: "rotated_logs", options: {}} lists the
rotated copies Rule R allows, with size and time, and what it keeps (the
active log, links, other files); it writes nothing and answers the Plan
shape with basenames only, backup policy none and no disk key.

POST /maintenance/apply starts the "Rotated log copies" maintenance job
(admission read: runs beside a scan, refused beside another maintenance job
or an exclusive one). It deletes exactly the reviewed copies, or nothing and
fails plan-changed when any of them changed after the review; a copy that
cannot be deleted becomes an errors row. The token is consumed on the loop
turn the job starts. (#329)

* feat(diagnostics): add a shareable report without secrets or paths

GET /system/diagnostics builds the report on the server, off the event loop
and under a registry lease (409 busy while a restore runs): the project and
Viewer.js versions, Python and the platform, the four legacy checks (one
helper, legacy_checks, that GET /diagnostics also calls), public database
info, source counts, the log configuration and statistics, the last health
summary and the config.json-only keys from an explicit list. The CivitAI key
is reported only as has_civitai_api_key; every path is relative to ComfyUI
or reads "outside ComfyUI". A scrub replaces anything still absolute, the
key or the home folder and counts it in redacted (0 for a correct build).

The version parser moves unchanged to utils/version.py. (#329)

* fix(logs): never empty a renamed log or serve a swapped file; keep root anchors private

Review findings on #329 PR 1:
- Clear empties the active log through the handler only when the handler's
  open stream is the listed file (same inode); a log the user renamed while
  ComfyUI runs is no longer emptied.
- A download is refused when the opened file is not the one listed (inode),
  or has another name (a hard link).
- Windows reparse points that name another file (the name-surrogate bit:
  symlinks, junctions, WSL links) are links under Rule R; cloud placeholders
  stay regular files.
- The report's paths use the closest anchor that is not a filesystem root,
  and the scrub also catches the home folder without its root.

* fix(diagnostics): never anchor a report path above the home folder; serve only regular log files

Review findings on #329 PR 1:
- report_path never uses a filesystem root or a strict ancestor of the
  home folder (/home, C:\Users) as its anchor, so a path inside home needs
  an anchor at or below it and otherwise reads "outside ComfyUI"; no path
  starts with a user name. The scrub matches the home folder only as whole
  path parts (in full, without its root, and its name).
- A download serves only a regular file (a FIFO, socket or device is a
  404) and opens it non-blocking.
- One unreadable timestamp no longer breaks the log listing, stats,
  download, review or Clear.
- The report tests read a temporary log folder, never the process's own.

* fix(diagnostics): match the bare home folder name only inside paths

A home folder named like a word (/home/info) no longer removes the log
level INFO from the report; the name still goes when it is a part of a
path. Documents the accepted residual risk of another user's home. (#329)
2026-10-27 06:19:39 -07:00
Vito Sansevero bb4925c42d fix(tagging): end the batch job after its cleanup and report pre-pass progress (#379)
The job read as ended while it still held the model gate, so a quick
restart could be refused with model-busy. Pre-pass chunks now publish
progress, and a cancel between chunks keeps exact counts.

Part of #379
2026-10-27 05:49:40 -07:00
Vito Sansevero cfa54dc8ae fix(tagging): Tag output images tags each image, not its prompt (#379, PR 1)
The native "Tag output images" job now runs the image-tags loop over every
still image the output folders list, linked or not: each image gets its own
model tags (at most "Suggestions per image") in its image tag set, and its
bytes are marked auto-tagged in a new media_image_autotags table. No prompt
tag or prompt text is written.

- database/autotag_schema.py: one forward-only WITHOUT ROWID table, optional
  in old backups, refused without the annotation group.
- add_image_tags/replace_image_tags take mark_autotagged; autotag_state and
  autotagged_locators read the marks through the one current-record rule.
- "Skip already tagged" skips images whose current bytes are marked, counted
  in a stat-only pre-pass; hand tags do not count.
- The review counts images and estimates the time (WD14 on CPU or CUDA).
- System > Auto-tagging review rows and copy, D134 and the Auto-tagging
  contract follow.

Part of #379
2026-10-27 05:35:22 -07:00
95 changed files with 10862 additions and 853 deletions
+310 -25
View File
@@ -745,11 +745,55 @@ with a literal true `strip_newlines` is `replace("\n", "").strip()`;
(`N` = `inputcount`) after the literal delimiter unless missing or exactly `""`, and
ignores later inputs. A linked setting is `unsupported-node`; a list output
(`return_list` true or linked) and `PromptSearchList` are `runtime-value` ("Text is
decided at run time"), never one list item. Unverified text nodes (LoRAfromText,
RegexReplace, XYZPrompt, ImpactWildcardProcessor) stay `unsupported-node`. Every
decided at run time"), never one list item. Every
intermediate text is length-checked and charged to the scanned-character budget. If
the table cannot be loaded, graph inspection is `unresolved` ("Inspection node
semantics are unavailable") and ingestion is unaffected, because it never imports the table.
Unknown nodes pass through (#381, maintainer-approved 2026-10-27; no per-node
inventory): an output whose class the table, its `facts`, `lineage` and the code's
adapters never name is read through, not refused. A `text` input is its text (the
legacy encoder rule, so RegexReplace or LoRAfromText show their input text), except
that a string output of a node with a seed input (`seed`, `noise_seed`,
`sampling_mode.seed`) and no linked `conditioning`/`positive`/`negative` input is
generated at run time (a language model such as TextGenerate, a wildcard):
`runtime-value` naming it. Else, at output slot 0 or 1 of a node with a `positive` or
`negative` input, only `positive` (slot 1, `negative`) is followed, nothing when it
is not linked; else a linked `conditioning` alone; else every linked input except
the table's `pass_through.skip_inputs` (core type inputs: model, clip, vae, image,
latent, ...), each traced with kind `any`. A followed input with no text adds
nothing; limits and selector choices propagate; another failure (`runtime-value`,
`composite`, `malformed`) blocks, unless a PromptManager node was reached: this
repository's own nodes are authoritative, and then only their texts count. One
distinct text resolves; several make the sampler `ambiguous` "Several prompts reach
the sampler" with one candidate per distinct outcome, in input order (at most 32,
else `work-limit`). A PromptManagerPair's execution record is what ran: while its
inputs are checked against it, a walk through an unknown node that fails, diverges
or passes text through is unverifiable and never overrides the record. No text
at all is `unsupported-node` naming the outermost unknown node. An unlisted output
of a listed class (a sampler, a loader, a lineage node) stays `unsupported-node`, so
the walk never reads another stage's prompt. `INSPECTION_RESOLVER_VERSION` is 2 for
this rule; ingestion keeps the default profile (`RESOLVER_VERSION` 1). The legacy
parity gate (`tests/test_legacy_parity_gate.py`) records, per corpus (the
generation-params vectors, the inspection parity fixtures, the local resolver golden
and selection cases, the unknown-node shapes in `tests/unknown_node_graphs.py`),
every graph whose inspection positive equals the legacy parser's; a graph may join
that list (`python3 -m tests.test_legacy_parity_gate --write`), never leave it. A graph
may read differently from legacy only when `LEGACY_WRONG` there states why (legacy's
first-sampler pick on a branch the file name identifies).
Saver anchoring (#381): an inspected file's name (`path.name` on the server, the chosen
file's name locally) anchors an unanchored inspection on the one node whose
`filename_prefix` (literal, or traced as a string) has a last path part that makes the
file's stem `<prefix>_<digits>` with an optional trailing `_`. That node's linked inputs
are walked like a detailer's image ancestry, through any image node, and the listed
samplers found are the candidates. No match, several matches, a walk that fails or no
sampler found means no anchor: nothing is guessed and the file stays "several possible
prompts". Ingestion ignores file names. Shared-conditioning stages: in a sampler's
latent walk, an earlier sampler reading the very same conditioning links (positive and
negative, or a guider; compared by node ID text and slot) is the same prompt, not an
unattributed stage, when it is a plain first stage: not a detailer, no prompt override
set, and no listed sampler in its own latent ancestry (else it stays unattributed) (`tests/test_resolver_saver_anchor.py`, shapes in
`tests/saver_anchor_graphs.py` from the maintainer's `gpu_unlock_bench`, dataset and
ANIMA runs; browser parity through the `anchor-*` cases, which carry `__file_name`).
`tools/inspection_corpus.py --root DIR --out JSONL [--workers N] [--baseline JSONL]
[--summary TXT] [--code TREE]` measures a media folder read-only against the #240
@@ -1639,6 +1683,9 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f
unchanged, `-` skipped and `!` error.
- The progress line is the scan's: "Processing file N of M · about X min left", the
estimate (D56's `_time_left`) shown once two files are done, each file one batch.
An ended job never keeps it: its status line becomes "<Add tags | Auto-tag>
<finished | cancelled> · N files processed" (or the storage-restored sentence),
pinned by `test_an_ended_job_shows_no_time_left` (#379 live check).
- Download report saves `prompt-manager-image-tags-<YYYYMMDD-HHMM>.json`.
- The start toasts are "Tagging started." / "Auto-tagging started." (D85); the end
toasts follow the design's pattern with the job's counts, as the scan's: "<title>
@@ -2658,7 +2705,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
@@ -2690,10 +2738,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
@@ -2724,7 +2768,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
@@ -2845,6 +2889,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
@@ -3103,25 +3152,88 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
the persisted count the Tag image dialog's Suggestions field (D91) starts from. Pinned
by the comparison's mapping (it inserts the row) and `defaults save through Save
preferences and the Tag image dialog uses them`.
- **D134** (Auto-tagging, S11, OQ2/OQ3/OQ7/OQ8): an `.operation` "Tag output images"
with "Review" follows `.row-actions`: the legacy prompt-tag batch. Review is
- **D134** (Auto-tagging, S11, OQ2/OQ3/OQ7/OQ8; amended by #379): an `.operation` "Tag
output images" with "Review" follows `.row-actions`: the per-image batch (#379), whose
description reads "Tag each output image from its own pixels with the model above.
Prompts are not changed. Review the scope first.". Review is
`aria-disabled` (the press explains) while the chosen engine is not downloaded or a
value it uses is invalid; otherwise it posts `/autotag/batch/preview` with the drafted
engine and policy (read-only) and opens "Review tag output images" in the concept's
record-changing review (its `maintenancePlan` shell): `h3` "Tag output images", the
`dl.meta-grid` Model, Existing tags, Images linked, Prompts, Already tagged, Prompts
to tag ("up to N"), Settings ("General <g> · Character <c>" or "Template of N
characters") and Backup, the upper-bound sentence in the `p.small`, the D55 backup box
and Cancel / "Start tagging", which starts the cancellable `autotag` job and opens it.
Replace after review, a model not on disk, a short disk, no linked image and nothing to
tag each show the rows with only Cancel and the reason ("Replacing tags needs a review
of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.",
"Every prompt with an output image is already tagged.", "Not enough free disk space:
the safety backup needs about X MB and Y MB is free. Nothing is written until there is
room.") in the close-only review Integrations shares (`openCloseOnlyReview`). Pinned by
`dl.meta-grid` Model, Existing tags, Images, Already auto-tagged, Images to tag
("about N"), Time, Settings ("General <g> · Character <c>" or "Template of N
characters", then " · up to L tags per image" with the preview's saved
`suggestion_limit`) and Backup, the estimate sentence ("Nothing is written until you
start. The number to tag is an estimate: images changed since they were tagged are
tagged again, and missing files are skipped. Activity reports what was tagged.") in the
`p.small`, the D55 backup box and Cancel / "Start tagging", which starts the
cancellable `autotag` job and opens it. Time (DEC11) is `m = max(1,
ceil(estimate_seconds / 60))` read "about m min" under 90 and "about H h M min" (or
"about H h") from 90, followed by " on a processor; a graphics card can be faster."
or, when the preview says WD14 runs on CUDA, " on the graphics card."; the language
models read "Language-model engines take longer per image. Activity shows the time
left once tagging starts.". The option label "Skip already tagged" is the concept's;
for this batch it skips images already auto-tagged (hand tags do not count). Replace
after review, a model not on disk, a short disk, no image and nothing to tag each show
the rows with only Cancel and the reason ("Replacing tags needs a review of each
image. Use Preview tagging review, or choose Skip or Merge for a batch run.", "The
output folders contain no images to tag.", "Every output image is already
auto-tagged.", "Not enough free disk space: the safety backup needs about X MB and Y
MB is free. Nothing is written until there is room.") in the close-only review
Integrations shares (`openCloseOnlyReview`). Pinned by
`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.
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **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
@@ -3680,19 +3792,45 @@ images", "Tag output images (legacy page)", "Tagging one image (legacy page)",
`409 busy`; the legacy SSE route attaches to the same job and keeps its frames.
- **Unload**: `POST /prompt_manager/autotag/unload` answers `{success, message}` or
`409 model-busy`.
- **Tag output images**: `POST /prompt_manager/autotag/batch/preview {model_type,
existing}` writes nothing and opens no file; it answers `images`, `prompts`,
`already_tagged`, `to_tag` (an upper bound), `model {type, label, size_label,
downloaded}`, `existing`, `review_required`, `backup {policy}` and `disk`. `POST
- **Tag output images** (per image since #379; prompt tags and prompt text are never
written): its targets are every still image the output folders list (the Images
listing of each root, linked to a prompt or not; videos are not targets; a file under
two overlapping roots is one target). `POST /prompt_manager/autotag/batch/preview
{model_type, existing}` writes nothing and opens or stats no image file (it may wait
for the shared listing walk); it answers `images`, `already_tagged` (images with any
auto-tagged byte record), `to_tag` (`images − already_tagged` under Skip, `images`
under Merge; both estimates), `estimate_seconds` (WD14: `to_tag` × 1.6 s on the
processor or × `WD14_GPU_SECONDS_PER_IMAGE` when ONNX Runtime offers CUDA, see
`py/tagging_defaults.py`; `null` for GGUF and HF), `estimate_device` (`cpu`, `gpu`,
or `null`), `suggestion_limit` (the saved count the run uses), `model {type, label,
size_label, downloaded}`, `existing`, `review_required`, `backup {policy}` and
`disk`. `POST
/prompt_manager/jobs/autotag {model_type, existing, options}` (WD14: both thresholds;
GGUF/HF: `instructions`, the template) starts the cancellable `autotag` job (202),
title "Tag output images", admission `records`, a safety backup by the policy first.
The job runs the `image-tags` loop (`_tag_each_file`) on each image: a capture, at
most `suggestion_limit` model tags, a re-hash, then a union into that image's tags
that also marks its bytes auto-tagged (`media_image_autotags`, one row per byte
record; an empty result and an unsupported still are marked too; File changed, File
not found, Too large to verify, read, inference and tag-limit errors are not). Phases,
each checked for Cancel: backup, model load, listing ("Listing output images…"), a
pre-pass that stats each file without opening it (gone: "File not found", over 256
MiB: "Too large to verify", under Skip a marked current record: "Already
auto-tagged"; counted with `record_counted`, rows up to the cap, no log line each),
then the loop ("Processing file {k + n} of {T}", the time left from the model's own
rate, D56 unchanged). It ends "Tagged N images", "Cancelled. Tags already added were
kept.", or failed with "Prompt storage was restored; start the job again.", "The
output folders could not be listed. Try again." or the model's refusal.
Refusals: `400 review-required` (Replace after review never runs as a batch), `409
model-unavailable` ("<label> is not downloaded. Download it in System ›
Auto-tagging."), `507 insufficient-space`, `409 busy`, `409 model-busy`. The section
toasts `model-busy` with its operation, the other three with the server's sentence,
`busy` with the running job (D57), anything else "<what> could not start. Try again.".
Outcomes: Tagged / Unchanged / Skipped / Errors (the `image-tags` descriptor, D111).
`tests/test_autotag_batch.py` pins it, including two images of one prompt getting
different tags with no `prompts` or `prompt_tags` row changed; the marks' schema group
is pinned by `tests/test_autotag_schema.py` (forward-only: one empty `WITHOUT ROWID`
table, optional in old backups, refused without the annotation group).
No browser test downloads or runs a model: the injected `TestModel` answers, its
`download_model` raises unless `/__test__/tagging/download {mode: hold | succeed |
@@ -3701,6 +3839,153 @@ 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.
`tests/browser/system-diagnostics-concept.spec.mjs` compares the section and its log
cleanup review with the concept (dark and light, 1600x1100 and 390x844, no page scroll;
both selects show their whole option at 320 wide). The fixture's `{mode: "concept"}` seed
reproduces the concept's Logs sample: only its four buffer lines, a 1.4 MB active log with
three rotated copies and console logging off, so those values are compared as the concept
prints them. `mapDiagnosticsConcept` applies only D109 and D110, and `mapCleanupConcept`
D100 (the shared `mapPlanConcept`) with D109's rule for this plan (no backup box, "Not
needed: no library record changes", "Listed files"). The mutation check asserts that the
unmapped difference is exactly the deviated paths. The comparison found that the Database
line read `database_info.size_bytes`, a key the report does not have, so it always said
"0 bytes"; it reads `database_size_bytes`, the key `get_database_info()` returns.
### Legacy System capability coverage
Every System capability of the legacy admin and gallery pages has an owner (#249 System
S12, #329). Status is `covered` (a native control replaces it), `kept in legacy` (it stays
on the legacy page, which keeps working) or `approved removal` (a ruling retired it). Native
control is `` `<section>` · <role> · "<accessible name>" ``, `<section>` a System hash id
and `<role>` one of button, combobox, switch, slider, spinbutton, textbox, group, status or
term, followed by ` · in "<row title>"` when several rows of the section share the name.
Every `covered` row names one, except the rows in `CONTROL_EXEMPTIONS`
(`tools/vendor/legacy-coverage.mjs`), each with its reason: today only "Gallery per-image
auto-tag / review", which the Images selection bar owns, not a System section. A row that
is not `covered` names none (`—`). Test is the exact title of a test that runs, then its
file. Anchors are line numbers on `main` at this PR.
`tests/vendor/legacy-coverage.test.mjs` fails on a row with any of these problems:
- no owner, or a status outside the three;
- a malformed Native control cell;
- a covered row without a control (unless exempt), or a control on a row that is not
covered;
- a test title its file does not declare: `test(` or `it(` (`.only` and `.slow` count),
or a `def test_` at function or method indentation;
- a title whose test is only declared with `.skip`, `.fixme` or `.fail`.
A title that appears only in a comment or a Python docstring does not count. CI running
those tests green is what proves them. `every covered legacy System capability has its
native control` (workspace-system.spec) opens each covered row's section and finds the
control by role and accessible name, inside its row when the cell names one. The shared
"Review plan" of the per-operation review row and the Sources "Refresh" of the monitoring
list stay unscoped: any row's control proves them.
| Capability | Legacy anchor | Owner | Status | Native control | Test |
| --- | --- | --- | --- | --- | --- |
| Image scan directories add/remove | `admin.html:377–390`; `admin.js:200,1273–1323` | S4 Sources | covered | `sources` · button · "+ Add source" | Add source validates on the server, then marks the host draft dirty; Save persists (workspace-system-sources.spec.mjs) |
| Currently monitoring list + Refresh | `admin.html:392–400`; `admin.js:1032` | S4 Sources | covered | `sources` · button · "Refresh" | watching only with a live observer; restart pending otherwise (workspace-system-sources.spec.mjs) |
| Monitoring enabled | `py/config.py:212,248` | S4 Sources | covered | `sources` · switch · "Watch for new outputs" | Watch for new outputs saves monitoring_enabled; a restart applies it (workspace-system-sources.spec.mjs) |
| Search results auto-hide `result_timeout` | `admin.html:407–412` | S3 Appearance | covered | `appearance` · spinbutton · "Node search auto-hide (seconds)" | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
| Web UI display mode | `admin.html:414–422` | S3 Appearance | covered | `appearance` · combobox · "Open from ComfyUI" | host fields round-trip through /settings with revision (workspace-system.spec.mjs) |
| Infinite scrolling default | `admin.html:424–430`; `admin.js:689–727` | Legacy prompt list (README continuous browsing); read-only in the report | kept in legacy | — | shouldLoadMore asks for the next page only near the bottom with pages left (list-state.test.js) |
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Diagnostics run | `admin.html:171,659`; `admin.js:1984` | S12 Diagnostics | covered | `diagnostics` · button · "Run checks" | Run checks opens the shared Health report (workspace-system-diagnostics.spec.mjs) |
| Synthetic image-link test | `admin.html:662`; `admin.js:2101` | S12 Diagnostics | covered | `diagnostics` · button · "Create test link…" | Create test link confirms first and names the prompt (workspace-system-diagnostics.spec.mjs) |
| Maintenance cleanup_duplicates, vacuum, cleanup_orphaned_images, prune_orphaned_prompts | `admin.html:692–748`; `admin.js:1834` | S6/S7 Maintenance | covered | `maintenance` · button · "Review plan" · in "Duplicate prompt text" | Apply reviewed operation closes, toasts, opens the job in Activity (workspace-system-maintenance.spec.mjs) |
| Maintenance check_hash_duplicates, check_consistency | `admin.html:692–748`; `admin.js:1834` | S2 Health report (Maintenance notice, D123) | covered | `maintenance` · button · "Run health checks" | Run health checks opens Health report and updates metric 4 (workspace-system.spec.mjs) |
| Maintenance statistics (database info) | `admin.html:692–748`; `admin.js:1834` | S12 Diagnostics | covered | `diagnostics` · term · "Database" | meta-grid shows real database and source status, the pinned Viewer.js version and the host renderers (workspace-system-diagnostics.spec.mjs) |
| Maintenance select all / clear all / run | `admin.html:754–762`; `admin.js:1822–1833` | S7 per-operation review | covered | `maintenance` · button · "Review plan" | plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing (workspace-system-maintenance.spec.mjs) |
| Prompt cleanup `/cleanup` | `py/api/admin.py:620` | S6/S7 Maintenance | covered | `maintenance` · button · "Review plan" · in "Duplicate prompt text" | test_duplicate_prompts_plan_counts_the_legacy_groups (test_maintenance_plans.py) |
| Database backup | `admin.html:179,824`; `admin.js:2139,2454` | S9 Backup & export | covered | `backups` · button · "Back up" | Back up downloads through the api client and revokes the object URL (D103) (workspace-system-backups.spec.mjs) |
| Restore from file | `admin.html:183,616–642`; `admin.js:2177–2248` | S8/S9 Backup & export | covered | `backups` · button · "Review restore…" | Review restore stages the chosen file and shows the real summary with the backup box (D105) (workspace-system-backups.spec.mjs) |
| Metadata scan with progress, cancel, pill | `admin.html:187,805–865`; `admin.js:2336–2500` | S4 Sources | covered | `sources` · button · "Review scan" | Review scan opens the shared Review metadata scan with all sources (workspace-system-sources.spec.mjs) |
| Rescan-required notice + dismiss | `admin.html:1367–1410`; `admin.js:991–1011` | S4 Sources | covered | `sources` · button · "Later" | notice Later persists the 3.2.0 dismissal (pm_update_notice_dismissed) without scanning (workspace-system-sources.spec.mjs) |
| View logs with level and limit | `admin.html:195,885–907,974`; `admin.js:2579–2641` | S12 Logs | covered | `diagnostics` · combobox · "Level" | level filter is minimum-level over all six choices (workspace-system-diagnostics.spec.mjs) |
| Log statistics | `admin.html:921–941`; `admin.js:2512` | S12 Logs | covered | `diagnostics` · status · "Log statistics" | log statistics show entries, errors, warnings, files and size (workspace-system-diagnostics.spec.mjs) |
| Log files list and download | `admin.html:910,982–985`; `admin.js:2545,2642,2679` | S12 Logs | covered | `diagnostics` · button · "Download log" | each listed file downloads by its returned name and a link has no Download (workspace-system-diagnostics.spec.mjs) |
| Clear/truncate logs | `admin.html:915`; `admin.js:2704` | S12 Logs | covered | `diagnostics` · button · "Clear all logs…" | Clear all logs states both counts, confirms first and reads everything again (workspace-system-diagnostics.spec.mjs) |
| Rotated-log cleanup (new, #329) | — | S12 Logs | covered | `diagnostics` · button · "Review log cleanup" | Review log cleanup lists only rotated copies and keeps the rest (workspace-system-diagnostics.spec.mjs) |
| Set log level (incl. CRITICAL), console logging | `admin.html:948–966`; `admin.js:2530,2729` | S12 Logs | covered | `diagnostics` · combobox · "Runtime log level" | runtime level (including CRITICAL) and console logging save and say they apply until the server restarts (workspace-system-diagnostics.spec.mjs) |
| Log file_logging / max_file_size / backup_count / buffer_size | `py/api/logging_routes.py:56,285–299` | S12 Logs (read-only) | covered | `diagnostics` · group · "Log files & retention" | each listed file downloads by its returned name and a link has no Download (workspace-system-diagnostics.spec.mjs) |
| Auto-tag model list with status | `admin.html:1095–1148`; `admin.js:3641` | S11 Auto-tagging | covered | `tagging` · combobox · "Tagging model" | model options are the four real engines with size and the description shows live status (workspace-system-tagging.spec.mjs) |
| Auto-tag model download | `admin.html:1413–1438`; `admin.js:3680–3747`; `gallery.js:3412` | S11 Auto-tagging | covered | `tagging` · button · "Download" | Download starts the job and Activity shows it without Cancel (workspace-system-tagging.spec.mjs) |
| Unload model | `gallery.js:3480` | S11 Auto-tagging | covered | `tagging` · button · "Unload" | Unload is enabled only while a model is loaded, and a held gate's refusal names the operation (workspace-system-tagging.spec.mjs) |
| Tag template, WD14 thresholds, already-tagged mode | `admin.html:1150–1198`; `admin.js:3748` | S11 Auto-tagging | covered | `tagging` · combobox · "Existing tags" | defaults save through Save preferences and the Tag image dialog uses them (workspace-system-tagging.spec.mjs) |
| Batch auto-tag output images | `admin.html:191,1210,1279–1318`; `admin.js:3748–3840` | S11 Auto-tagging | covered | `tagging` · button · "Review" | Tag output images reviews the images, tags each one and never a prompt (#379) (workspace-system-tagging.spec.mjs) |
| Batch review loop | `admin.html:1206,1218–1364`; `admin.js:3841–4172` | S11 + image-tagging plan | covered | `tagging` · button · "Preview tagging review" | Preview tagging review opens the Tag image dialog for the selected image (workspace-system-tagging.spec.mjs) |
| Gallery per-image auto-tag / review | `gallery.html:601,605`; `gallery.js:3506,3674–3753` | Image-tagging plan (selection bar) | covered | — | Auto-tag selected with Apply automatically runs a job with the chosen model, thresholds and suggestion count (workspace-tagging-bulk.spec.mjs) |
| Gallery thumbnail generation | `gallery.js:1852,1967` | S5/S7 | covered | `sources` · button · "Review options" · in "Thumbnail generation" | Start closes the dialog, toasts, and opens the job in Activity (workspace-system-sources.spec.mjs) |
| Clear server thumbnails | `gallery.js:2175–2192` | S7 Maintenance | covered | `maintenance` · button · "Review options" · in "Thumbnail cache" | Thumbnail cache opens the S5 dialog (workspace-system-maintenance.spec.mjs) |
| Browser cache clear | `gallery.js:2194–2207` | S7 Maintenance | covered | `maintenance` · button · "Review plan" · in "Metadata cache" | Metadata cache reviews and clears only owned browser entries when there are some (D102) (workspace-system-maintenance.spec.mjs) |
| Refresh file listing | `gallery.js:2209` | S4 Sources | covered | `sources` · button · "Refresh" · in "Refresh files" | Refresh files calls /gallery/rescan and toasts its counts (workspace-system-sources.spec.mjs) |
| Duplicate image scan, review, delete | `gallery.js:2234–2577` | S6/S7 Maintenance | covered | `maintenance` · button · "Find duplicates" | Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101) (workspace-system-maintenance.spec.mjs) |
| Default view mode, image quality | `gallery-settings.js:26–27` | S3 Appearance | covered | `appearance` · combobox · "Default layout" | defaults apply to a new visit but not to the current view or a saved view (workspace-system.spec.mjs) |
| Auto-load metadata, cache metadata, show file paths | `gallery-settings.js:31–33` | S3 Appearance | covered | `appearance` · switch · "Load metadata on selection" | metadata off shows an explicit load and loads once on request (workspace-system.spec.mjs) |
| Video autoplay, mute, loop | `gallery-settings.js:40–42` | S3 Appearance | covered | `appearance` · switch · "Autoplay videos" | video defaults reach the viewer (workspace-system.spec.mjs) |
| Image info on cards; sidebar collapsed | `gallery-settings.js:30,34` | S3 Appearance | covered | `appearance` · switch · "Show image details on cards" | added rows reach their consumers (workspace-system.spec.mjs) |
| Reset gallery settings | `gallery.js:1813` | S3 Appearance | covered | `appearance` · button · "Reset" | reset restores defaults without touching other keys (workspace-system.spec.mjs) |
| Lazy loading | `gallery-settings.js:25` | Legacy gallery (native always lazy) | kept in legacy | — | a legacy gallery Save keeps the workspace keys and every other stored key (gallery-settings.test.js) |
| Results per page / grid columns / paged mode | `gallery-settings.js:28–29,35` | README continuous browsing | approved removal | — | numeric strings from <select> values are accepted and columns are clamped (gallery-settings.test.js) |
| Startup thumbnail prompt | `gallery-settings.js:38–39`; `gallery.js:2578` | README; Overview thumbnail row | approved removal | — | thumbnail row reflects the status endpoint (workspace-system.spec.mjs) |
| Client debug output `debugMode` | `gallery-settings.js:36` | Legacy gallery (S12-10) | kept in legacy | — | a legacy gallery Save keeps the workspace keys and every other stored key (gallery-settings.test.js) |
| API timeout `apiTimeout` | `gallery-settings.js:37` | Legacy gallery (S12-10; read by no request) | kept in legacy | — | defaults are a complete, sane settings object (gallery-settings.test.js) |
| config.json-only keys | `py/config.py:212–234,722–739`; `py/api/diagnostics_report.py:185` | S12 report (read-only) | covered | `diagnostics` · button · "Download diagnostic report" | test_report_lists_config_only_keys (test_api_diagnostics_report.py) |
### Browser cache ownership (#222)
The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or
+6 -10
View File
@@ -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
+98 -24
View File
@@ -19,6 +19,11 @@ links (``PromptDatabase._move_tag_links``).
Cheap reads (spec amendment S1) treat a byte record as *current* only while
the file's present ``(size, mtime_ns)`` equals the record's verified
fingerprint; a file without a current record shows no image tags.
A write made from a tagging model's result passes ``mark_autotagged`` (#379):
the byte record is then marked auto-tagged (``media_image_autotags``) in the
same transaction, so a mark never exists without the write it records. Hand
tags never mark.
"""
import re
@@ -34,6 +39,7 @@ from .annotation_schema import (
MAX_LOCATOR_LENGTH,
METADATA_TABLE,
)
from .autotag_schema import AUTOTAG_TABLE
from .library_commands import _UUID, LibraryCommandError, _validate_epoch
from .models import utc_now_iso
from .vocabulary import validate_vocabulary_tag_name
@@ -96,6 +102,14 @@ _TAGS_OF = (
"JOIN tags t ON t.id=mit.tag_id WHERE mit.byte_id IN ({marks}) "
"ORDER BY mit.byte_id,mit.position"
)
_MARK = f"INSERT OR IGNORE INTO {AUTOTAG_TABLE}(byte_id) VALUES(?)" # nosec B608
_MARKED_OF = (
f"SELECT byte_id FROM {AUTOTAG_TABLE} WHERE byte_id IN ({{marks}})" # nosec B608
)
_MARKED_LOCATORS = (
f"SELECT DISTINCT f.locator FROM {AUTOTAG_TABLE} m " # nosec B608
f"JOIN {BYTES_TABLE} b ON b.id=m.byte_id JOIN {FILES_TABLE} f ON f.id=b.file_id"
)
_RECORDS = (
"SELECT f.locator,b.id,b.size,b.mtime_ns,t.name " # nosec B608
f"FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b ON b.file_id=f.id "
@@ -301,14 +315,17 @@ class AnnotationOperations:
expected_epoch: str,
expected_tag_revision: int,
tags: list,
mark_autotagged: bool = False,
) -> dict:
"""Replace the set of these exact bytes, guarded by epoch and revision.
Creates the owner and byte record when the bytes are new (an explicit
empty set is a real save). The identical set at the current revision,
or one behind it (a lost-response retry), is ``replayed`` and writes
nothing; any other revision mismatch is ``tag-conflict`` with the
current set and no writes.
no tag; any other revision mismatch is ``tag-conflict`` with the
current set and no writes. ``mark_autotagged`` marks the bytes
auto-tagged on a saved or replayed receipt (the Tag image dialog's
save after Generate suggestions).
"""
_validate_target(locator, sha256, size, mtime_ns)
_validate_epoch(expected_epoch)
@@ -329,6 +346,8 @@ class AnnotationOperations:
):
if refingerprinted:
conn.execute(_BUMP_INDEX)
if mark_autotagged:
conn.execute(_MARK, (byte_id,))
return _receipt(epoch, sha256, current, revision, True)
if expected_tag_revision != revision:
raise AnnotationCommandError(
@@ -337,6 +356,8 @@ class AnnotationOperations:
{"sha256": sha256, "tags": current, "tag_revision": revision},
)
revision = self._write_image_tags(conn, byte_id, tags)
if mark_autotagged:
conn.execute(_MARK, (byte_id,))
return _receipt(epoch, sha256, tags, revision, False)
def add_image_tags(
@@ -348,16 +369,22 @@ class AnnotationOperations:
mtime_ns: int,
expected_epoch: str,
tags: list,
mark_autotagged: bool = False,
) -> dict:
"""Union ``tags`` into the set of these exact bytes, keeping stored spelling.
``added`` counts the names that were new (case-insensitively). A union
over ``MAX_IMAGE_TAGS`` is ``tag-limit`` with the current set and no
writes; a union that adds nothing to an existing record is ``replayed``.
``mark_autotagged`` (a tagging model's result) also marks the bytes
auto-tagged, replayed or not; only such a call may pass no tags (the
model found none: the image is marked and its set is unchanged).
"""
_validate_target(locator, sha256, size, mtime_ns)
_validate_epoch(expected_epoch)
tags = normalize_image_tags(tags)
if not tags and not mark_autotagged:
raise ValueError("tags must name at least one tag")
with self._pair_transaction() as conn:
epoch = self._library_epoch(conn, expected_epoch)
now = utc_now_iso()
@@ -375,6 +402,8 @@ class AnnotationOperations:
epoch,
{"sha256": sha256, "tags": current, "tag_revision": revision},
)
if mark_autotagged:
conn.execute(_MARK, (byte_id,))
if not created and not added:
if refingerprinted:
conn.execute(_BUMP_INDEX)
@@ -403,6 +432,41 @@ class AnnotationOperations:
conn.execute(_BUMP_INDEX)
return cursor.rowcount > 0
@staticmethod
def _validated_stats(stats: object) -> dict:
if not isinstance(stats, dict):
raise ValueError(
"stats must map locators to (size, mtime_ns) integer pairs"
)
for locator, fingerprint in stats.items():
_validate_fingerprint(locator, fingerprint)
return stats
@staticmethod
def _current_byte_records(conn: sqlite3.Connection, stats: dict) -> dict:
"""``{locator: byte_id}`` of each locator's current byte record.
The one rule every cheap read uses: a record is current while its
verified ``(size, mtime_ns)`` equals the file's present fingerprint
in ``stats``; the most recently verified wins (ties on ``(verified_at,
byte_id)``). A locator without a current record is left out. Lookups
go in chunks of 500 locators.
"""
locators = list(stats)
chosen: dict[str, tuple[tuple[str, str], str]] = {}
for start in range(0, len(locators), _LOOKUP_CHUNK):
chunk = locators[start : start + _LOOKUP_CHUNK]
marks = ",".join("?" * len(chunk))
for locator, byte_id, size, mtime_ns, verified_at in conn.execute(
_FINGERPRINTS.format(marks=marks), chunk
):
if (size, mtime_ns) != stats[locator]:
continue
key = (verified_at, byte_id)
if locator not in chosen or key > chosen[locator][0]:
chosen[locator] = (key, byte_id)
return {locator: byte_id for locator, (_key, byte_id) in chosen.items()}
def current_image_tags(self, stats: dict) -> dict:
"""Image tags of the files whose current ``(size, mtime_ns)`` is verified.
@@ -411,29 +475,11 @@ class AnnotationOperations:
exactly that verified fingerprint; the most recently verified record
wins when several do. Lookups go in chunks of 500 locators.
"""
if not isinstance(stats, dict):
raise ValueError(
"stats must map locators to (size, mtime_ns) integer pairs"
)
for locator, fingerprint in stats.items():
_validate_fingerprint(locator, fingerprint)
locators = list(stats)
chosen: dict[str, tuple[tuple[str, str], str]] = {}
result: dict[str, list[str]] = {}
stats = self._validated_stats(stats)
with self._annotation_read() as conn:
for start in range(0, len(locators), _LOOKUP_CHUNK):
chunk = locators[start : start + _LOOKUP_CHUNK]
marks = ",".join("?" * len(chunk))
for locator, byte_id, size, mtime_ns, verified_at in conn.execute(
_FINGERPRINTS.format(marks=marks), chunk
):
if (size, mtime_ns) != stats[locator]:
continue
key = (verified_at, byte_id)
if locator not in chosen or key > chosen[locator][0]:
chosen[locator] = (key, byte_id)
owners = {byte_id: locator for locator, (_key, byte_id) in chosen.items()}
result = {locator: [] for locator in chosen}
current = self._current_byte_records(conn, stats)
owners = {byte_id: locator for locator, byte_id in current.items()}
result: dict[str, list[str]] = {locator: [] for locator in current}
byte_ids = list(owners)
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
@@ -442,6 +488,34 @@ class AnnotationOperations:
result[owners[byte_id]].append(name)
return result
def autotag_state(self, stats: dict) -> dict:
"""``{locator: bool}``: is each file's current byte record auto-tagged?
``stats`` as for :meth:`current_image_tags`, whose rule picks the
current record; a locator without one is False. One read snapshot.
"""
stats = self._validated_stats(stats)
with self._annotation_read() as conn:
current = self._current_byte_records(conn, stats)
byte_ids = list(dict.fromkeys(current.values()))
marked: set[str] = set()
for start in range(0, len(byte_ids), _LOOKUP_CHUNK):
chunk = byte_ids[start : start + _LOOKUP_CHUNK]
marks = ",".join("?" * len(chunk))
marked.update(
row[0]
for row in conn.execute(_MARKED_OF.format(marks=marks), chunk)
)
return {locator: current.get(locator) in marked for locator in stats}
def autotagged_locators(self) -> set:
"""Locators with at least one auto-tagged byte record, current or not.
No fingerprint is checked (the Tag output images review's estimate).
"""
with self._annotation_read() as conn:
return {row[0] for row in conn.execute(_MARKED_LOCATORS)}
def iter_image_tag_records(self) -> Iterator[tuple[str, int, int, list[str]]]:
"""Stream every byte record as ``(locator, size, mtime_ns, tags)``.
+140
View File
@@ -0,0 +1,140 @@
"""Auto-tagged marks (#379): which image bytes a tagging model has handled.
One table, ``media_image_autotags``: one row per byte record of the image
annotation group (``media_annotation_bytes``) means "a tagging model looked at
these exact bytes and its result was stored", or that the bytes were found to
be untaggable. "Skip already tagged" skips an image whose current byte record
is marked; hand tags never mark. Rows are written only by the image tag store
(``database/annotations.py``), in the transaction of the tag write they
record.
The group installs like ``image_favorites``: optional in old backups,
required once a database is current, and never backfilled. Like the Needs
review group it is valid only beside a valid image annotation group, because
every mark cites one byte record (``ON DELETE CASCADE``). Validation reads
only: the catalog compared after whitespace compaction, the columns and the
foreign key through the PRAGMAs, then one bounded value pass and the foreign
key check.
"""
import re
import sqlite3
from typing import Optional
from .annotation_schema import BYTES_TABLE
from .annotation_schema import TABLES as ANNOTATION_TABLES
from .annotation_schema import _catalog as _annotation_catalog
from .annotation_schema import _uuid, annotation_schema_error
AUTOTAG_TABLE = "media_image_autotags"
_DDL = (
f"CREATE TABLE {AUTOTAG_TABLE} (byte_id TEXT PRIMARY KEY "
f"REFERENCES {BYTES_TABLE}(id) ON DELETE CASCADE) WITHOUT ROWID"
)
# (cid, name, declared type, not null, default, primary key position): the
# key of a WITHOUT ROWID table is implicitly NOT NULL.
_COLUMNS = [(0, "byte_id", "TEXT", 1, None, 1)]
# (column, parent table, parent column, on delete)
_FOREIGN_KEYS = {("byte_id", BYTES_TABLE, "id", "CASCADE")}
NEEDS_ANNOTATIONS = "Image auto-tag marks need the image annotation tables"
INVALID_SCHEMA = "Image auto-tag marks have an invalid schema"
INVALID_VALUES = "Image auto-tag marks contain invalid values"
MISSING = "Missing image auto-tag marks schema"
def _compact(sql: str) -> str:
return re.sub(r"\s+", "", sql).lower()
def _catalog(conn: sqlite3.Connection) -> list:
return conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? OR tbl_name=?",
(AUTOTAG_TABLE, AUTOTAG_TABLE),
).fetchall()
def autotag_without_annotations_error(conn: sqlite3.Connection) -> Optional[str]:
"""Catalog only: the marks table present without its annotation tables.
Startup asks this before the annotation group installs, which would make
a marks table copied without it look valid. It reads no rows.
"""
if not _catalog(conn):
return None
present = {
name for kind, name, _sql in _annotation_catalog(conn) if kind == "table"
}
if present != set(ANNOTATION_TABLES):
return NEEDS_ANNOTATIONS
return None
def autotag_schema_error(
conn: sqlite3.Connection,
*,
allow_missing: bool = True,
annotations_valid: bool = False,
) -> Optional[str]:
"""Reject an unsupported marks table or invalid marks; reads only.
A database without the table is acceptable for backups that predate it
unless ``allow_missing`` is false. A present table requires a present,
valid image annotation group; ``annotations_valid`` says the caller has
just validated it (startup's annotation install), so only its presence is
checked again.
"""
objects = _catalog(conn)
if not objects:
return None if allow_missing else MISSING
if annotations_valid:
if autotag_without_annotations_error(conn):
return NEEDS_ANNOTATIONS
elif annotation_schema_error(conn, allow_missing=False):
return NEEDS_ANNOTATIONS
actual = {(kind, name, _compact(sql or "")) for kind, name, sql in objects}
if actual != {("table", AUTOTAG_TABLE, _compact(_DDL))}:
return INVALID_SCHEMA
columns = [
(row[0], row[1], row[2].upper(), row[3], row[4], row[5])
for row in conn.execute(f"PRAGMA table_info({AUTOTAG_TABLE})")
]
if columns != _COLUMNS:
return INVALID_SCHEMA
foreign_keys = {
(row[3], row[2], row[4], row[6])
for row in conn.execute(f"PRAGMA foreign_key_list({AUTOTAG_TABLE})")
}
if foreign_keys != _FOREIGN_KEYS:
return INVALID_SCHEMA
# Only this module's constants are interpolated into the SQL below.
if conn.execute(
f"SELECT 1 FROM {AUTOTAG_TABLE} " # nosec B608
f"WHERE NOT ({_uuid('byte_id')}) LIMIT 1"
).fetchone():
return INVALID_VALUES
if conn.execute(f"PRAGMA foreign_key_check({AUTOTAG_TABLE})").fetchone():
return INVALID_VALUES
return None
def migrate_autotag_state(conn: sqlite3.Connection) -> None:
"""Install the table atomically; validate before and after creating it.
Runs after ``migrate_annotation_state`` (which has just validated the
annotation group). Creates one empty table: no row is read or copied.
"""
conn.execute("SAVEPOINT media_autotag_schema")
try:
error = autotag_schema_error(conn, annotations_valid=True)
if error:
raise sqlite3.DatabaseError(error)
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
error = autotag_schema_error(conn, allow_missing=False, annotations_valid=True)
if error:
raise sqlite3.DatabaseError(error)
conn.execute("RELEASE media_autotag_schema")
except BaseException:
conn.execute("ROLLBACK TO media_autotag_schema")
conn.execute("RELEASE media_autotag_schema")
raise
+15
View File
@@ -23,6 +23,11 @@ from . import restore_stage
from .library_schema import library_schema_error, migrate_library_state
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
from .annotation_schema import annotation_schema_error, migrate_annotation_state
from .autotag_schema import (
autotag_schema_error,
autotag_without_annotations_error,
migrate_autotag_state,
)
from .review_schema import (
migrate_review_state,
review_schema_error,
@@ -338,8 +343,13 @@ class PromptModel:
review_error = review_without_annotations_error(conn)
if review_error:
raise sqlite3.DatabaseError(review_error)
# The auto-tag marks cite annotation bytes the same way (#379).
autotag_error = autotag_without_annotations_error(conn)
if autotag_error:
raise sqlite3.DatabaseError(autotag_error)
migrate_annotation_state(conn)
migrate_review_state(conn)
migrate_autotag_state(conn)
# Last: the older generated_images rebuilds run only before file_path.
migrate_workflow_documents(conn)
@@ -1225,6 +1235,11 @@ class PromptModel:
)
if review_error:
return False, review_error
autotag_error = autotag_schema_error(
conn, allow_missing=not require_library_state
)
if autotag_error:
return False, autotag_error
documents_error = workflow_documents_schema_error(
conn, allow_missing=not require_library_state, documents=documents
)
+11
View File
@@ -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
View File
@@ -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:
+46 -145
View File
@@ -15,12 +15,11 @@ from urllib.parse import quote
from .job_routes import busy_response
from .jobs import (
AUTOTAG_BATCH_JOB_KIND,
AUTOTAG_GROUP_NAMES,
MODEL_DOWNLOAD_JOB_KIND,
JobBusy,
)
from .admin import _plural, _scan_location, _scan_roots
from .maintenance import disk_plan, short_space_message
from .maintenance import disk_plan
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .. import model_gate
@@ -29,7 +28,7 @@ from aiohttp import web
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
# Tag output images (#328): the native prompt-tag batch.
# Tag output images (#328, per image since #379).
AUTOTAG_JOB_TITLE = "Tag output images"
AUTOTAG_PREVIEW_LEASE = "Tag output images review"
AUTOTAG_OWNER_KIND = "autotag-job"
@@ -200,6 +199,18 @@ async def _refusal(title):
yield {"type": "error", "status": "error", "message": message}
def wd14_device(service) -> str:
"""Worker thread: ``"gpu"`` when WD14 runs (or would run) on CUDA, else
``"cpu"``; a service without the probe is ``"cpu"``."""
probe = getattr(service, "wd14_device", None)
if not callable(probe):
return "cpu"
try:
return "gpu" if probe() == "gpu" else "cpu"
except Exception:
return "cpu"
class AutotagRoutesMixin:
"""Mixin providing auto-tagging API endpoints."""
@@ -802,6 +813,8 @@ class AutotagRoutesMixin:
async def _preview_autotag_batch(self, request):
from ..autotag import get_autotag_service
from ..config import AutotagConfig
from ..tagging_defaults import WD14_MODEL_TYPES, wd14_seconds_per_image
data, error = await self._batch_body(
request, ("model_type", "existing"), BATCH_PREVIEW_BODY_MAX
@@ -813,11 +826,14 @@ class AutotagRoutesMixin:
return self._bad_request(message)
model_type, existing = data["model_type"], data["existing"]
policy = self._safety_backup_policy()
service = get_autotag_service()
try:
candidates = await self._run_in_executor(self.db.autotag_batch_candidates)
status = await self._run_in_executor(
get_autotag_service().get_models_status
)
targets = await self._autotag_targets()
marked = await self._run_in_executor(self.db.autotagged_locators)
status = await self._run_in_executor(service.get_models_status)
device = None
if model_type in WD14_MODEL_TYPES:
device = await self._run_in_executor(wd14_device, service)
disk, _short, _size = await self._run_in_executor(
functools.partial(
disk_plan,
@@ -831,16 +847,21 @@ class AutotagRoutesMixin:
return web.json_response(
{"success": False, "error": self._public_error(e)}, status=500
)
prompts = {row["prompt_id"]: row["prompt_tags"] for row in candidates}
tagged = sum(1 for tags in prompts.values() if self._real_tags(tags))
to_tag = len(prompts) - tagged if existing == "skip" else len(prompts)
images = len(targets)
tagged = sum(1 for item in targets if item.locator in marked)
to_tag = images - tagged if existing == "skip" else images
estimate = None
if device is not None:
estimate = round(to_tag * wd14_seconds_per_image(device), 3)
return web.json_response(
{
"success": True,
"images": len(candidates),
"prompts": len(prompts),
"images": images,
"already_tagged": tagged,
"to_tag": to_tag,
"estimate_seconds": estimate,
"estimate_device": device,
"suggestion_limit": AutotagConfig.suggestion_limit(),
"model": {
"type": model_type,
"label": config["label"],
@@ -862,9 +883,9 @@ class AutotagRoutesMixin:
In order: 400 body or options, 400 ``review-required``; 409
``model-unavailable``; 507 ``insufficient-space``; 409 ``busy``
(another records job, the legacy batch's hold included); 409
``model-busy``. No await follows the busy check.
``model-busy``. No await follows the busy check. The job tags each
output image (``_run_autotag_batch``), never a prompt (#379).
"""
from ..autotag import get_autotag_service
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
from .annotation_suggest import OptionError, validate_options
@@ -873,7 +894,7 @@ class AutotagRoutesMixin:
)
if error is not None:
return error
config, message = self._batch_choice(data)
_config, message = self._batch_choice(data)
if message:
return self._bad_request(message)
model_type, existing, options = (
@@ -897,144 +918,24 @@ class AutotagRoutesMixin:
{"success": False, "code": "review-required", "error": REVIEW_REQUIRED},
status=400,
)
service = get_autotag_service()
backup_on = self._safety_backup_policy() == "on"
try:
status = await self._run_in_executor(service.get_models_status)
candidates = await self._run_in_executor(self.db.autotag_batch_candidates)
_disk, short, _size = await self._run_in_executor(
functools.partial(
disk_plan, self.db.model.db_path, backup=backup_on, vacuum=False
)
job, refusal = await self._start_autotag(
model_type=model_type,
existing=existing,
options=options,
keep_in_memory=True,
title=AUTOTAG_JOB_TITLE,
owner_kind=AUTOTAG_OWNER_KIND,
)
except Exception as e:
self.logger.exception("Tag output images could not start")
return web.json_response(
{"success": False, "error": self._public_error(e)}, status=500
)
if not (status.get(model_type) or {}).get("downloaded"):
return web.json_response(
{
"success": False,
"code": "model-unavailable",
"error": f"{config['label']} is not downloaded. "
"Download it in System › Auto-tagging.",
},
status=409,
)
if backup_on and short is not None:
return web.json_response(
{
"success": False,
"code": "insufficient-space",
"error": short_space_message(short),
},
status=507,
)
busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records")
if busy is not None:
return busy_response(JobBusy(busy))
owner = model_gate.new_owner(AUTOTAG_OWNER_KIND)
if not model_gate.try_acquire(owner):
return model_busy_response()
opts = {
"model_type": model_type,
"existing": existing,
"keep_in_memory": True,
"gate_owner": owner,
"general_threshold": options.get("general_threshold"),
"character_threshold": options.get("character_threshold"),
"prompt": options.get("instructions"),
}
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
AUTOTAG_JOB_TITLE,
admission="records",
cancellable=True,
scope={
"model_type": model_type,
"existing": existing,
"images": len(candidates),
"label": config["label"],
},
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(
self._run_autotag_job, service, opts, backup_on
),
)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return busy_response(refused)
raise
job.task.add_done_callback(lambda _task: model_gate.release(owner))
if refusal is not None:
return refusal.response
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _run_autotag_job(self, service, opts, backup_on, job):
ok, _backup = await self._safety_backup_step(job, self.db, backup_on)
if not ok:
return
try:
await self._tag_output_images(service, opts, job)
except Exception:
self.logger.exception("Tag output images failed")
job.update(status_line=_INTERNAL_ERROR)
job.log_line(f"Tagging failed · {_INTERNAL_ERROR}")
job.finish("failed")
finally:
if backup_on:
await self._prune_backup_pool(job, self.db)
async def _tag_output_images(self, service, opts, job):
roots = _scan_roots(self._get_all_output_dirs())
job.log_line(
f"Tagging started · {job.scope['label']} · "
+ ("skip prompts already tagged" if opts["existing"] == "skip" else "merge")
)
job.update(status_line="Loading the tagging model")
def on_item(index, total, group, image_path, detail):
path, root, root_index = _scan_location(image_path or "", roots)
job.update(
processed=index + 1,
total=total,
progress=100 * (index + 1) // total,
status_line=f"Tagging {index + 1} of {total}",
)
job.record(
group,
{
"path": path,
"root": root,
"root_index": root_index,
"detail": detail,
},
f"{path}: {detail}",
)
job.publish({"type": "progress", "progress": job.progress})
final = None
async for event in self._autotag_batch_events(
service,
{**opts, "cancelled": lambda: job.cancelled, "on_item": on_item},
):
if event.get("type") in ("complete", "error"):
final = event
if final is not None and final["type"] == "error":
job.update(status_line=final["message"])
job.log_line(f"Tagging failed · {final['message']}")
job.finish("failed")
elif job.cancelled: # a cancel wins over a run that then completed
job.update(status_line=BATCH_CANCELLED)
job.log_line(BATCH_CANCELLED)
job.finish("cancelled")
else:
done = f"Tagged {_plural(job.counters.get('tagged', 0), 'prompt')}"
job.update(status_line=done, progress=100)
job.log_line(done)
job.finish("finished")
def _gated_runner(self, owner):
"""Executor calls under the model gate *owner* holds (plain when None)."""
if owner is None:
+289
View File
@@ -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,
}
+390 -24
View File
@@ -18,13 +18,21 @@ the suggestion worker and the storage call ``add_image_tags``. Files are
handled one at a time and the job stops at a file boundary when cancelled,
keeping what it already wrote. Rows and log lines carry root-relative paths
and public root labels only.
Auto-tag marks each image it ran the model on as auto-tagged (#379), with the
union of its tags. The ``autotag`` job ("Tag output images", started by
``POST /jobs/autotag``) runs the same per-file loop over every still image the
output folders list (:meth:`ImageTagJobsMixin._run_autotag_batch`): image
tags only, never a prompt tag or prompt text.
"""
import asyncio
import dataclasses
import functools
import os
import time
from dataclasses import dataclass, field
from typing import NamedTuple
from aiohttp import web
@@ -45,10 +53,17 @@ from .annotations import (
_submit,
_target,
)
from .autotag_routes import model_busy_response
from .images import _public_path
from .autotag_routes import BATCH_CANCELLED, MODEL_BUSY_ERROR, model_busy_response
from .images import _current_fingerprint, _listing_posix, _public_path
from .job_routes import busy_response
from .jobs import IMAGE_TAG_GROUP_NAMES, IMAGE_TAG_JOB_KIND, JobBusy
from .jobs import (
AUTOTAG_BATCH_JOB_KIND,
AUTOTAG_GROUP_NAMES,
IMAGE_TAG_GROUP_NAMES,
IMAGE_TAG_JOB_KIND,
JobBusy,
)
from .maintenance import disk_plan, short_space_message
from .prompts import bad_request
try:
@@ -107,6 +122,14 @@ READ_FAILED = "The file could not be read."
SOURCE_CHANGED = "The source changed. Refresh the image collection."
STORAGE_RESTORED = "Prompt storage was restored; start the job again."
BUSY_RETRY = "The file could not be read now. Run the job again for it."
# Tag output images (#379): the per-image batch.
ALREADY_AUTOTAGGED = "Already auto-tagged"
LISTING_FAILED = "The output folders could not be listed. Try again."
LISTING_STATUS = "Listing output images…"
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
# The pre-pass stats and looks up this many targets at a time, checking for a
# cancel between chunks.
PREPASS_CHUNK = 500
_monotonic = time.monotonic # the job's clock (a test can fake it)
@@ -246,6 +269,92 @@ async def _capacity_retried(job, attempt):
return await attempt()
class Refusal(NamedTuple):
"""Why a Tag output images start was refused.
``response`` is the native route's JSON answer; ``message`` the sentence
a legacy stream frame carries.
"""
status: int
code: str
message: str
response: web.Response
@dataclass(frozen=True)
class AutotagRun:
"""What one Tag output images job runs with (validated at its start)."""
model_type: str
existing: str
options: dict
keep_in_memory: bool
backup_on: bool
owner: object
@dataclass(frozen=True)
class _BatchTarget:
"""One still image of the batch: the job target and its private keys."""
target: tuple # (root_index, root_scope, relative): the image-tags target
path: str # the listed file, for the pre-pass stat (never sent out)
locator: str # the image tag key: normcase(join(realpath(root), relative))
label: str # the root's public label
def _still_targets(api, index, root, files, seen: set) -> list:
"""Worker thread: one root's listed still images as _BatchTargets.
Newest first (the listing's order). A locator an earlier root already
produced (overlapping roots) is dropped; ``seen`` collects them.
"""
scope = api._gallery_root_scope(root, index)
label = _public_path(root)
real = os.path.realpath(root)
prefix = os.path.join(str(root), "")
found = []
for path, _mtime in files:
relative = _listing_posix(root, prefix, path)
if is_video_path(relative):
continue
locator = os.path.normcase(os.path.join(real, *relative.split("/")))
if locator in seen:
continue
seen.add(locator)
found.append(_BatchTarget((index, scope, relative), str(path), locator, label))
return found
def _prepass_chunk(db, chunk, skip_marked: bool, max_file: int):
"""Worker thread: ``(remaining, skips)`` for a chunk of _BatchTargets.
Stats each file (no open, no hash): gone is "File not found", over the
capture limit "Too large to verify"; under "Skip already tagged" a file
whose current bytes are marked is "Already auto-tagged". ``skips`` are
``(target, detail)`` pairs in target order.
"""
stats, skips, alive = {}, [], []
for item in chunk:
fingerprint = _current_fingerprint(item.path)
if fingerprint is None:
skips.append((item, FILE_NOT_FOUND))
elif fingerprint[0] > max_file:
skips.append((item, TOO_LARGE))
else:
stats[item.locator] = fingerprint
alive.append(item)
marked = db.autotag_state(stats) if skip_marked and stats else {}
remaining = []
for item in alive:
if marked.get(item.locator):
skips.append((item, ALREADY_AUTOTAGGED))
else:
remaining.append(item)
return remaining, skips
def _load_model(service, model_type: str, use_gpu: bool) -> None:
"""Worker thread: load (or swap to) *model_type*; never downloads."""
if service.is_model_loaded() and service.get_loaded_model_type() == model_type:
@@ -254,6 +363,25 @@ def _load_model(service, model_type: str, use_gpu: bool) -> None:
raise RuntimeError(f"{model_type} could not be loaded")
def _refusal(status: int, code: str, message: str) -> Refusal:
response = web.json_response(
{"success": False, "code": code, "error": message}, status=status
)
return Refusal(status, code, message, response)
def _busy_refusal(busy: JobBusy) -> Refusal:
title = getattr(busy.running, "title", "") or "Another job"
return Refusal(409, "busy", f"{title} already running", busy_response(busy))
def _batch_cancelled(job) -> str:
"""Say the batch was cancelled; its end state (the runner ends the job)."""
job.update(status_line=BATCH_CANCELLED)
job.log_line(BATCH_CANCELLED)
return "cancelled"
class ImageTagJobsMixin:
"""The ``image-tags`` job kind: start route and runner."""
@@ -301,6 +429,216 @@ class ImageTagJobsMixin:
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return web.json_response({"success": True, "job": job.summary()}, status=202)
# -- Tag output images (#379) ---------------------------------------------
async def _autotag_targets(self) -> list:
"""Every still image the output folders list, as _BatchTargets.
Root by root in configured order, each newest first (the shared
listing, ``_get_gallery_files``, used as it is even when stale). Videos
are not targets; a file under two overlapping roots is one target.
"""
seen: set = set()
found = []
for index, root in enumerate(list(self._get_all_output_dirs())):
files = await self._get_gallery_files(root)
found.extend(
await self._run_in_executor(
_still_targets, self, index, root, files, seen
)
)
return found
async def _start_autotag(
self, *, model_type, existing, options, keep_in_memory, title, owner_kind
):
"""Register a Tag output images job: ``(job, None)`` or ``(None, Refusal)``.
``options`` are already validated (``validate_options``). The checks
run in this order: 409 ``model-unavailable``; 507
``insufficient-space`` (only when the safety backup is on); 409
``busy`` (another records job); 409 ``model-busy``. No await follows
the busy check. The gate is taken before the job registers and
released when the task ends, also before its runner ever starts.
"""
from ..autotag import MODELS, get_autotag_service
backup_on = self._safety_backup_policy() == "on"
status = await self._run_in_executor(get_autotag_service().get_models_status)
_disk, short, _size = await self._run_in_executor(
functools.partial(
disk_plan, self.db.model.db_path, backup=backup_on, vacuum=False
)
)
label = MODELS[model_type]["label"]
if not (status.get(model_type) or {}).get("downloaded"):
message = (
f"{label} is not downloaded. Download it in System › Auto-tagging."
)
return None, _refusal(409, "model-unavailable", message)
if backup_on and short is not None:
return None, _refusal(507, "insufficient-space", short_space_message(short))
busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records")
if busy is not None:
return None, _busy_refusal(JobBusy(busy))
owner = model_gate.new_owner(owner_kind)
if not model_gate.try_acquire(owner):
response = model_busy_response()
return None, Refusal(409, "model-busy", MODEL_BUSY_ERROR, response)
run = AutotagRun(
model_type, existing, options, keep_in_memory, backup_on, owner
)
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
title,
admission="records",
cancellable=True,
scope={"model_type": model_type, "existing": existing, "label": label},
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(self._run_autotag_batch, run),
)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return None, _busy_refusal(refused)
raise
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return job, None
async def _run_autotag_batch(self, run: AutotagRun, job) -> None:
"""The ``autotag`` job: backup, model, listing, pre-pass, per-image loop.
``job.cancelled`` is checked between phases (and per pre-pass chunk
and per image). Each phase publishes ``{"type": "phase", "phase"}``.
With ``keep_in_memory`` false the model is unloaded under the gate.
The job ends only after that cleanup and the gate release, so a job
that reads as ended never still holds the gate.
"""
state = "failed"
try:
state = await self._autotag_batch_phases(run, job)
except Exception:
self.logger.exception("Tag output images failed")
job.update(status_line=_INTERNAL_ERROR)
job.log_line(f"Tagging failed · {_INTERNAL_ERROR}")
finally:
try:
if not run.keep_in_memory:
await self._unload_batch_model(run.owner)
if run.backup_on:
await self._prune_backup_pool(job, self.db)
finally:
model_gate.release(run.owner)
job.publish({"type": state, "progress": job.progress})
job.finish(state)
async def _unload_batch_model(self, owner) -> None:
from ..autotag import get_autotag_service
service = get_autotag_service()
try:
if service.is_model_loaded():
await model_gate.run(owner, service.unload_model)
except Exception:
self.logger.exception("The tagging model could not be unloaded")
async def _autotag_batch_phases(self, run: AutotagRun, job) -> str:
"""Run the phases; the job's end state (the caller ends the job)."""
job.publish({"type": "phase", "phase": "backup"})
ok, _backup = await self._safety_backup_step(job, self.db, run.backup_on)
if not ok:
return "failed"
if job.cancelled:
return _batch_cancelled(job)
job.publish({"type": "phase", "phase": "model"})
job.update(status_line="Loading the tagging model")
spec = _Spec("auto", (), model_type=run.model_type, options=run.options)
service = await self._prepare_tagging_model(job, spec, run.owner)
if service is None:
return "failed"
if job.cancelled:
return _batch_cancelled(job)
job.publish({"type": "phase", "phase": "listing"})
job.update(status_line=LISTING_STATUS)
epoch = await self._run_in_executor(self.db.get_execution_epoch)
try:
targets = await self._autotag_targets()
except Exception:
self.logger.exception("Tag output images could not list the outputs")
job.update(status_line=LISTING_FAILED)
job.log_line(f"! {LISTING_FAILED}")
return "failed"
total = len(targets)
job.scope["images"] = total
job.update(total=total)
policy = (
"skip images already auto-tagged" if run.existing == "skip" else "merge"
)
job.log_line(
f"Tagging started · {job.scope.get('label') or run.model_type} · "
f"{_plural(total, 'image')} · {policy}"
)
if job.cancelled:
return _batch_cancelled(job)
remaining = await self._autotag_prepass(job, targets, run.existing == "skip")
if remaining is None:
return _batch_cancelled(job)
done = total - len(remaining)
spec = dataclasses.replace(
spec, targets=tuple(item.target for item in remaining)
)
loop_run = _Run(job, spec, epoch, run.owner, service)
if not await self._tag_each_file(loop_run, done_before=done):
job.update(status_line=STORAGE_RESTORED)
job.log_line(f"Tagging stopped · {STORAGE_RESTORED}")
return "failed"
if job.cancelled:
return _batch_cancelled(job)
finished = f"Tagged {_plural(job.counters.get('tagged', 0), 'image')}"
job.update(status_line=finished, progress=100)
job.log_line(finished)
return "finished"
async def _autotag_prepass(self, job, targets, skip_marked: bool):
"""The targets left for the model, or None when cancelled meanwhile.
Opens and hashes nothing: one stat per file and one indexed read per
chunk. Skips are counted with ``record_counted`` (exact counters, rows
up to the cap, no log line each).
"""
max_file = self._capture_store().max_file
remaining, skipped = [], 0
total = len(targets)
for start in range(0, total, PREPASS_CHUNK):
if job.cancelled:
return None
chunk = targets[start : start + PREPASS_CHUNK]
left, skips = await self._run_in_executor(
_prepass_chunk, self.db, chunk, skip_marked, max_file
)
remaining.extend(left)
if skips:
job.record_counted(
"skipped",
len(skips),
[
{
"path": item.target[2],
"root": item.label,
"root_index": item.target[0],
"detail": detail,
}
for item, detail in skips
],
)
skipped += len(skips)
job.update(processed=skipped, progress=skipped * 100 // total)
job.publish({"type": "progress", "progress": job.progress})
if skipped:
job.log_line(f"Skipped {_plural(skipped, 'image')} before tagging")
return remaining
# -- runner ----------------------------------------------------------------
def _job_roots(self, targets) -> dict:
@@ -331,28 +669,39 @@ class ImageTagJobsMixin:
job.finish("failed")
return
if not await self._tag_each_file(run):
job.update(status_line=STORAGE_RESTORED)
job.log_line(f"{verb} stopped · {STORAGE_RESTORED}")
job.finish("failed")
return
state = "cancelled" if job.cancelled else "finished"
job.log_line(f"{verb} {state} · {_plural(job.processed, 'file')} processed")
# An ended job never keeps the loop's "about X min left" (#379).
ended = f"{verb} {state} · {_plural(job.processed, 'file')} processed"
job.update(status_line=ended)
job.log_line(ended)
job.publish({"type": state, "progress": job.progress})
job.finish(state)
finally:
if owner is not None:
model_gate.release(owner)
async def _tag_each_file(self, run: _Run) -> bool:
"""Tag the targets in order; False when the job had to stop."""
job, total = run.job, len(run.spec.targets)
async def _tag_each_file(self, run: _Run, *, done_before: int = 0) -> bool:
"""Tag the targets in order; False when the job had to stop.
``done_before`` files were already counted (the batch's pre-pass): file
n of this loop is "file done_before + n" of the job, and the time left
uses this loop's rate only.
"""
job, count = run.job, len(run.spec.targets)
total = done_before + count
run.roots = await self._run_in_executor(self._job_roots, run.spec.targets)
started = _monotonic()
for number, target in enumerate(run.spec.targets, 1):
if job.cancelled:
break
# The scan's estimate (D56), with each finished file as one batch.
left = _time_left(number - 1, total, number - 1, _monotonic() - started)
job.update(status_line=f"Processing file {number:,} of {total:,}{left}")
left = _time_left(number - 1, count, number - 1, _monotonic() - started)
shown = done_before + number
job.update(status_line=f"Processing file {shown:,} of {total:,}{left}")
job.publish({"type": "progress", "progress": job.progress})
stopped = False
try:
@@ -363,7 +712,7 @@ class ImageTagJobsMixin:
self.logger.exception("Image tagging failed for one file")
group, detail = "errors", SAVE_FAILED
self._record_tag_outcome(run, target, group, detail)
job.update(processed=number, progress=number * 100 // total)
job.update(processed=shown, progress=shown * 100 // total)
if stopped:
return False
return True
@@ -439,9 +788,17 @@ class ImageTagJobsMixin:
store.release(capture.token)
async def _auto_tag_capture(self, run: _Run, store, capture, root, relative):
"""Suggest on the capture, then union only onto the same current bytes."""
"""Suggest on the capture, then union only onto the same current bytes.
The bytes are marked auto-tagged with the union, also when the model
returned no names; captured bytes no model can read are marked too
(and stay skipped), so a "Skip already tagged" run passes them by.
"""
if not capture.inferable:
return "skipped", NOT_A_STILL
group, detail = await self._union_image_tags(
capture, [], run.epoch, mark_autotagged=True
)
return (group, detail) if group == "errors" else ("skipped", NOT_A_STILL)
if store.acquire(capture.token) is None:
raise _JobStopped # every capture was invalidated (restore)
spec = run.spec
@@ -467,21 +824,30 @@ class ImageTagJobsMixin:
if facts.locator != capture.locator or facts.sha256 != capture.sha256:
return "skipped", FILE_CHANGED
names = [str(name) for name, _score in pairs]
if not names:
return "unchanged", NO_NEW_TAGS
return await self._union_image_tags(facts, names, run.epoch)
return await self._union_image_tags(
facts, names, run.epoch, mark_autotagged=True
)
async def _union_image_tags(self, facts, tags: list, epoch: str):
"""``add_image_tags`` on the verified bytes, as a (group, detail)."""
async def _union_image_tags(
self, facts, tags: list, epoch: str, *, mark_autotagged: bool = False
):
"""``add_image_tags`` on the verified bytes, as a (group, detail).
``mark_autotagged`` (a model's result) marks the bytes auto-tagged in
the same write; then ``tags`` may be empty.
"""
try:
receipt = await self._run_in_executor(
self.db.add_image_tags,
locator=facts.locator,
sha256=facts.sha256,
size=facts.size,
mtime_ns=facts.mtime_ns,
expected_epoch=epoch,
tags=tags,
functools.partial(
self.db.add_image_tags,
locator=facts.locator,
sha256=facts.sha256,
size=facts.size,
mtime_ns=facts.mtime_ns,
expected_epoch=epoch,
tags=tags,
mark_autotagged=mark_autotagged,
)
)
except LibraryCommandError as exc:
if exc.code == "storage-changed":
+2 -1
View File
@@ -129,8 +129,9 @@ def inspect_current_file(
profile = (
PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE
)
# The file name anchors on the saver that wrote it (#381).
evidence = inspect_prompt_evidence(
inspection.metadata, role_saved, profile=profile
inspection.metadata, role_saved, profile=profile, file_name=path.name
)
return {
"file": {
+207
View File
@@ -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)
+51 -9
View File
@@ -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(
{
+19 -2
View File
@@ -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)
+18
View File
@@ -756,6 +756,24 @@ class AutoTagService:
self.logger.info("Model unloaded, memory freed")
def wd14_device(self) -> str:
"""``"gpu"`` when WD14 runs (or would run) on CUDA, else ``"cpu"``.
A loaded WD14 session answers from its own first provider; otherwise
ONNX Runtime's available providers say whether CUDA would be offered
(a session can still fall back to the processor). Loads no model.
"""
tagger = self._tagger
if tagger is not None and tagger[0] == "wd14":
providers = tagger[1][0].get_providers()
return "gpu" if providers[:1] == ["CUDAExecutionProvider"] else "cpu"
try:
import onnxruntime as ort
except ImportError:
return "cpu"
available = ort.get_available_providers()
return "gpu" if "CUDAExecutionProvider" in available else "cpu"
def is_model_loaded(self) -> bool:
"""Check if a model is currently loaded."""
return self._tagger is not None
+17 -1
View File
@@ -13,7 +13,23 @@ LLM_MODEL_TYPES = frozenset({"gguf", "hf"})
MODEL_TYPES = WD14_MODEL_TYPES | LLM_MODEL_TYPES
MAX_INSTRUCTION_CHARS = 4000
MAX_INSTRUCTION_BYTES = 16 * 1024
# What a batch does with a prompt that already has tags (#328).
# Tag output images' review estimate (#379): seconds per image for the WD14
# engines, by where ONNX Runtime runs them. The graphics card figure was
# measured in PR 1's live check (RTX 4090, CUDA: Auto-tag selected on 100
# images in 16.7 s with the model load, 0.167 s/image). The processor figure
# is an upper bound from the maintainer's cancelled CPU run (19 min / 697
# images, backup, model load and skips included), kept as the fallback. The
# language-model engines have no figure.
WD14_SECONDS_PER_IMAGE = 1.6
WD14_GPU_SECONDS_PER_IMAGE = 0.17
def wd14_seconds_per_image(device: str) -> float:
"""The estimate's rate for ``"gpu"`` or ``"cpu"`` (anything else)."""
return WD14_GPU_SECONDS_PER_IMAGE if device == "gpu" else WD14_SECONDS_PER_IMAGE
# What a batch does with an image that already has tags (#328, #379).
EXISTING_POLICIES = ("skip", "merge", "replace_after_review")
# The language-model tag template every page starts from until one is saved.
+70
View File
@@ -676,3 +676,73 @@ export function measureInspector(selector) {
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
};
}
// #249 System S7 Task 20, shared since S12 (#329): the maintenance plan dialog's measurement and its D100 mapping,
// moved unchanged from system-maintenance-concept.spec.mjs so the rotated-log review uses the same ones.
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
export function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const compare = body.querySelector('.compare-grid');
const item = compare?.querySelector('.compare-item');
const notes = [...body.querySelectorAll(':scope > p.small')];
const note = notes.at(-1);
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
tree: [...body.querySelector(':scope > details').children].map(name) },
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
image: css(item.querySelector('img'), 'width', 'objectFit'),
label: css(item.querySelector('label'), 'fontSize', 'color'),
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
type: item.querySelector('input').type } },
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
export const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
/**
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
*/
export function mapPlanConcept(reference, title, plan) {
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
: `Create and verify before changes (${plan.backup.destination_label})`;
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
const [cancel, apply] = reference.actions.buttons;
return {
...reference,
tree,
heading: { ...reference.heading, text: title },
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
['Backup', backup], ['Media on disk', plan.media.label]] },
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
backup: BACKUP_BOX,
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
};
}
+283
View File
@@ -0,0 +1,283 @@
"""Run a browser-test fixture seeder off the live database, then publish once (#384).
The seeders make hundreds of small commits. On a slow CI disk the live
database's commits queue behind WAL checkpoint syncs and a reset took over
30 s. ``ScratchSeeder.run`` instead:
1. copies the live database into a scratch database (SQLite backup API) whose
connections never sync (a crash loses only the copy);
2. runs the unchanged seeder against an API proxy whose ``db`` is the scratch
database; everything else (files, thumbnails, caches, command state) is
the live API's, written exactly as before;
3. replaces the rows of every table the seed changed in the live database, in
one transaction on the live connection. Tables the seed left alone are not
touched, so state another fixture owns stays as it was.
A commit by any other live connection while the seed ran fails the reset
instead of being overwritten (``PRAGMA data_version``).
"""
from __future__ import annotations
import hashlib
import os
import shutil
import sqlite3
import tempfile
import weakref
from pathlib import Path
from typing import Any, Callable, TypeVar
T = TypeVar("T")
# Set to 1 to run seeders on the live database as before #384 (the equivalence
# proof, tests/browser/reset_equivalence.py, compares both ways).
SEED_ON_LIVE_ENV = "PM_TEST_SEED_ON_LIVE"
SCRATCH_SCHEMA = "reset_seed"
# API calls a seed makes that must see the published rows (see _SeedApi).
DEFERRED_CALLS = frozenset({"invalidate_gallery_cache"})
# Linux's shared-memory tmpfs (present in the Playwright image and on runners).
MEMORY_ROOT = Path("/dev/shm")
# The largest fixtures' scratch copy is about 8 MB (database plus a WAL that
# autocheckpoints near 4 MB). Docker's default /dev/shm is 64 MB, shared with
# the browsers and the other workers; with less free than this, the scratch
# copy goes on disk instead.
MEMORY_FREE_BYTES = 32 * 1024 * 1024
# Scratch folders are named <prefix><owner pid>-<random>.
SCRATCH_PREFIX = "pm-reset-"
def _quote(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _tables(conn: sqlite3.Connection, schema: str = "main") -> list[str]:
rows = conn.execute(
f"SELECT name, sql FROM {schema}.sqlite_master WHERE type = 'table' "
"ORDER BY name"
).fetchall()
for name, sql in rows:
if sql and sql.lstrip().upper().startswith("CREATE VIRTUAL"):
raise NotImplementedError(f"virtual table {name} cannot be copied")
return [name for name, _sql in rows]
def _columns(conn: sqlite3.Connection, table: str, schema: str = "main") -> list[str]:
"""Writable columns (hidden and generated ones excluded), plus ``rowid``
when the table has a rowid that no INTEGER PRIMARY KEY column carries."""
info = conn.execute(f"PRAGMA {schema}.table_xinfo({_quote(table)})").fetchall()
columns = [row[1] for row in info if row[6] == 0]
keys = [row for row in info if row[5]]
sql = conn.execute(
f"SELECT sql FROM {schema}.sqlite_master WHERE type = 'table' AND name = ?",
(table,),
).fetchone()[0]
without_rowid = sql is not None and "WITHOUT ROWID" in sql.upper()
integer_key = len(keys) == 1 and str(keys[0][2]).upper() == "INTEGER"
if not without_rowid and not integer_key and table != "sqlite_sequence":
columns.insert(0, "rowid")
return columns
def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, str]:
"""A digest of every table's rows (rowids included), by table name."""
result = {}
for table in _tables(conn, schema):
selected = ", ".join(
_quote(c) if c != "rowid" else "rowid"
for c in _columns(conn, table, schema)
)
digest = hashlib.sha256()
rows = conn.execute(f"SELECT {selected} FROM {schema}.{_quote(table)}")
for row in sorted(repr(tuple(row)) for row in rows):
digest.update(row.encode("utf-8", "surrogatepass"))
digest.update(b"\n")
result[table] = digest.hexdigest()
return result
def _process_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except OSError:
return True # it exists but belongs to someone else
return True
def remove_orphaned_scratch(root: Path) -> None:
"""Remove scratch folders of servers that died without cleaning up.
A server killed outright (SIGKILL, a crash) never runs ``close``, and
tmpfs keeps its folder in memory until reboot. The folder name carries
the owner's process id; a folder whose process is gone is removed.
"""
if os.name != "posix":
return # os.kill(pid, 0) would terminate the process on Windows
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
if pid.isdigit() and not _process_alive(int(pid)):
shutil.rmtree(folder, ignore_errors=True)
class _SeedApi:
"""The live API, except that ``db`` is the scratch database.
Cache drops the seed asks for (``DEFERRED_CALLS``) are recorded and made
after the publish: dropped earlier, a request in between would cache the
live database's old rows again.
"""
def __init__(self, api: Any, db: Any) -> None:
object.__setattr__(self, "_api", api)
object.__setattr__(self, "db", db)
object.__setattr__(self, "deferred", [])
def __getattr__(self, name: str) -> Any:
if name in DEFERRED_CALLS:
def record(*args: Any, **kwargs: Any) -> None:
self.deferred.append((name, args, kwargs))
return record
return getattr(self._api, name)
def __setattr__(self, name: str, value: Any) -> None:
setattr(self._api, name, value)
class ScratchSeeder:
"""Seed into a scratch copy and publish the changed tables in one commit."""
def __init__(
self,
api: Any,
directory: Path,
*,
on_live: bool = False,
memory_root: Path | None = MEMORY_ROOT,
) -> None:
self._api = api
self._fallback = Path(directory)
self._memory_root = memory_root
self._directory: Path | None = None
self._on_live = on_live
self._scratch = None
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
def run(self, seed: Callable[[Any], T]) -> T:
"""Run ``seed(api)`` (blocking; call it in an executor thread)."""
if self._on_live:
return seed(self._api)
live = self._api.db.model.get_connection()
if live.in_transaction:
raise RuntimeError("the live connection has an open transaction")
version = live.execute("PRAGMA data_version").fetchone()[0]
scratch = self._scratch_database()
target = self._scratch_connection(scratch)
with target.using():
live.backup(target)
before = table_digests(target)
target_api = _SeedApi(self._api, scratch)
result = seed(target_api)
target = self._scratch_connection(scratch)
if target.in_transaction:
raise RuntimeError("the seed left a scratch transaction open")
after = table_digests(target)
changed = [table for table in after if after[table] != before.get(table)]
if set(before) != set(after):
raise RuntimeError("the seed changed the database schema")
if live.execute("PRAGMA data_version").fetchone()[0] != version:
raise RuntimeError("the live database changed while the seed ran")
self._publish(live, scratch.model.db_path, changed, version)
for name, args, kwargs in target_api.deferred:
getattr(self._api, name)(*args, **kwargs)
return result
def _scratch_database(self):
if self._scratch is None:
from database.operations import PromptDatabase
self._directory = self._scratch_directory()
self._scratch = PromptDatabase(str(self._directory / "seed.db"))
return self._scratch
def _scratch_connection(self, scratch) -> sqlite3.Connection:
"""The calling thread's scratch connection, set never to sync."""
conn = scratch.model.get_connection()
if conn not in self._unsynced:
conn.execute("PRAGMA synchronous = OFF")
self._unsynced.add(conn)
return conn
@staticmethod
def _publish(
live: sqlite3.Connection, path: str, tables: list[str], version: int
) -> None:
"""Replace ``tables`` with the scratch rows in one live transaction.
``version`` is the live ``data_version`` read before the seed. It is
checked again under the write lock: a writer whose transaction was
open during the seed commits before ``BEGIN IMMEDIATE`` returns, and
its rows must fail the reset instead of being overwritten.
"""
if not tables:
return
foreign_keys = live.execute("PRAGMA foreign_keys").fetchone()[0]
# Rows are copied as they are; no cascade may reach a table left alone.
live.execute("PRAGMA foreign_keys = OFF")
try:
live.execute(f"ATTACH DATABASE ? AS {SCRATCH_SCHEMA}", (path,))
try:
live.execute("BEGIN IMMEDIATE")
try:
if live.execute("PRAGMA data_version").fetchone()[0] != version:
raise RuntimeError(
"the live database changed while the seed ran"
)
for table in tables:
columns = ", ".join(
_quote(c) if c != "rowid" else "rowid"
for c in _columns(live, table)
)
name = _quote(table)
live.execute(f"DELETE FROM main.{name}")
live.execute(
f"INSERT INTO main.{name} ({columns}) "
f"SELECT {columns} FROM {SCRATCH_SCHEMA}.{name}"
)
live.commit()
except BaseException:
live.rollback()
raise
finally:
live.execute(f"DETACH DATABASE {SCRATCH_SCHEMA}")
finally:
live.execute(f"PRAGMA foreign_keys = {int(foreign_keys)}")
def close(self) -> None:
"""Close every scratch connection, then remove the scratch files."""
if self._on_live:
return
if self._scratch is not None:
self._scratch.close_all()
self._scratch = None
if self._directory is not None:
shutil.rmtree(self._directory, ignore_errors=True)
self._directory = None
def _scratch_directory(self) -> Path:
"""A folder in memory (tmpfs) where there is one, else ``directory``.
Even unsynced, scratch writes on the runner's disk wait whenever the
kernel is writing back the pages they rewrite.
"""
root = self._memory_root
if root is not None and root.is_dir() and os.access(root, os.W_OK):
remove_orphaned_scratch(root)
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
self._fallback.mkdir(parents=True, exist_ok=True)
return self._fallback
+78
View File
@@ -0,0 +1,78 @@
"""Empty a browser-test media folder without waiting for the disk (#384).
A reset used to ``shutil.rmtree`` the output and input folders the last
fixture wrote. Unlinking a file whose pages are still being written back
waits for that write, so on a slow CI disk a reset stalled in ``rmtree``.
``replace_folder`` renames the old folder into ``<parent>/reset-trash`` (one
metadata change) and makes a new empty one; a background thread deletes the
trash, and ``close_trash`` stops that thread and removes what is left before
the server's temp root goes.
"""
from __future__ import annotations
import queue
import shutil
import threading
import uuid
from pathlib import Path
TRASH_NAME = "reset-trash"
class _Trash:
def __init__(self, directory: Path) -> None:
self.directory = directory
self._queue: "queue.SimpleQueue[Path | None]" = queue.SimpleQueue()
self._thread = threading.Thread(
target=self._work, name="pm-reset-trash", daemon=True
)
self._thread.start()
def _work(self) -> None:
while (folder := self._queue.get()) is not None:
shutil.rmtree(folder, ignore_errors=True)
def discard(self, folder: Path) -> None:
self.directory.mkdir(exist_ok=True)
moved = self.directory / f"{folder.name}-{uuid.uuid4().hex}"
folder.rename(moved)
self._queue.put(moved)
def close(self) -> None:
self._queue.put(None)
self._thread.join()
shutil.rmtree(self.directory, ignore_errors=True)
_trashes: dict[Path, _Trash] = {}
# Trash folders whose server has cleaned up: a reset still running then
# deletes in place instead of starting a thread nobody would stop.
_closed: set[Path] = set()
_lock = threading.Lock()
def replace_folder(folder: Path) -> None:
"""Make ``folder`` an empty folder; its old content is deleted later."""
folder = Path(folder)
if folder.exists():
directory = folder.parent / TRASH_NAME
with _lock:
trash = _trashes.get(directory)
if trash is None and directory not in _closed:
trash = _trashes[directory] = _Trash(directory)
if trash is not None:
trash.discard(folder)
if folder.exists():
shutil.rmtree(folder)
folder.mkdir()
def close_trash(parent: Path) -> None:
"""Stop deleting under ``parent``'s trash and remove it (blocking)."""
directory = Path(parent) / TRASH_NAME
with _lock:
_closed.add(directory)
trash = _trashes.pop(directory, None)
if trash is not None:
trash.close()
+279
View File
@@ -0,0 +1,279 @@
"""Prove the scratch-seeded resets leave the state the old resets left (#384).
Run by hand on Linux (the browser server's platform):
python -m tests.browser.reset_equivalence
It starts the browser server three times: twice seeding on the live database
as before #384 (``PM_TEST_SEED_ON_LIVE=1``) and once with the scratch seeder.
Each server gets the same sequence: every reset kind of ``/__test__/reset``
and ``/__test__/concept/reset``, with other fixtures' resets and API writes
between them. After every reset it reads every table's rows (by rowid), the
output and input file trees (size, SHA-256, mtime) and the response body.
Values that differ between the two old runs are wall-clock values (created_at,
an unpinned concept "now", file mtimes); they are masked. Everything else must
be equal between the old runs and the new one. Exit status 0 means equal.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
READY_TIMEOUT_S = 60
CLOCK = "<clock>"
RESETS = [
("/__test__/reset", {}),
("/__test__/reset", {"extra_images": 2}),
("/__test__/reset", {"extra_images": 108}),
("/__test__/reset", {"include_video": True}),
("/__test__/reset", {"prompt_inspector": True}),
("/__test__/reset", {"native_library": True}),
("/__test__/reset", {"native_library": True, "extra_prompts": 125}),
("/__test__/reset", {"native_library": True, "extra_variants": 41}),
("/__test__/reset", {"workspace_visual": True}),
("/__test__/reset", {"full_inspector": True}),
(
"/__test__/reset",
{"full_inspector": True, "include_video": True, "extra_images": 2},
),
("/__test__/reset", {"chronological_history": True}),
("/__test__/reset", {"chronological_history": True, "history_runs": 85}),
("/__test__/concept/reset", {}),
("/__test__/concept/reset", {"extra_images": 100}),
("/__test__/concept/reset", {"seedless_images": 2, "short_seedless": True}),
("/__test__/concept/reset", {"now": 1_780_000_000}),
]
# Writes by other fixtures and the API between resets; the resets must keep or
# replace them exactly as before.
DIRTY = [
("/__test__/tagging/reset", {}),
("/__test__/system/reset", None),
(
"/__test__/saved-views/raw",
{"page": "images", "name": "dirty", "state": "{}"},
),
]
class Server:
def __init__(self, on_live: bool) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="pm-equivalence-")
env = {**os.environ, "TMPDIR": self.tmp.name}
env.update(TEMP=self.tmp.name, TMP=self.tmp.name)
env.pop("PM_TEST_SEED_ON_LIVE", None)
if on_live:
env["PM_TEST_SEED_ON_LIVE"] = "1"
self.process = subprocess.Popen(
[
sys.executable,
"-m",
"tests.browser.server",
"--port",
"0",
"--exit-on-stdin-eof",
],
cwd=REPO_ROOT,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
line = self.process.stdout.readline().decode()
if not line.startswith("PM_TEST_SERVER_PORT="):
raise RuntimeError(f"no port line: {line!r}")
self.base = f"http://127.0.0.1:{int(line.split('=', 1)[1])}"
deadline = time.monotonic() + READY_TIMEOUT_S
while True:
try:
self.post_json("GET", "/prompt_manager/test", None)
break
except OSError:
if time.monotonic() > deadline or self.process.poll() is not None:
raise
time.sleep(0.1)
(self.root,) = Path(self.tmp.name).glob("pm-browser-*")
def post_json(self, method: str, path: str, data) -> dict:
body = None if data is None else json.dumps(data).encode()
request = urllib.request.Request(
self.base + path,
data=body if method == "POST" else None,
method=method,
headers={"Content-Type": "application/json"} if body else {},
)
with urllib.request.urlopen(request, timeout=120) as response: # nosec B310
text = response.read().decode()
try:
return json.loads(text)
except ValueError:
return {"text": text[:200]}
def stop(self) -> None:
self.process.stdin.close()
self.process.wait(timeout=READY_TIMEOUT_S)
self.process.stdout.close()
self.tmp.cleanup()
def read_tables(path: Path) -> dict:
conn = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
try:
tables = {}
names = [
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
]
for name in names:
quoted = '"' + name.replace('"', '""') + '"'
columns = [row[1] for row in conn.execute(f"PRAGMA table_info({quoted})")]
try:
cursor = conn.execute(f"SELECT rowid, * FROM {quoted} ORDER BY rowid")
columns = ["rowid", *columns]
except sqlite3.OperationalError:
cursor = conn.execute(f"SELECT * FROM {quoted} ORDER BY 1")
rows = [
[value.hex() if isinstance(value, bytes) else value for value in row]
for row in cursor
]
tables[name] = {"columns": columns, "rows": rows}
return tables
finally:
conn.close()
def read_files(root: Path) -> dict:
files = {}
for top in ("output", "input"):
for path in sorted((root / top).rglob("*")):
if path.is_file():
stat = path.stat()
files[path.relative_to(root).as_posix()] = [
stat.st_size,
hashlib.sha256(path.read_bytes()).hexdigest(),
stat.st_mtime_ns,
]
return files
def flatten(value, prefix="") -> dict:
if isinstance(value, dict):
out = {}
for key, item in value.items():
out.update(flatten(item, f"{prefix}/{key}"))
return out
if isinstance(value, list):
out = {f"{prefix}#len": len(value)}
for index, item in enumerate(value):
out.update(flatten(item, f"{prefix}[{index}]"))
return out
return {prefix: value}
def snapshot(server: Server, response: dict) -> dict:
flat = {}
for table, data in read_tables(server.root / "browser.db").items():
flat[f"table {table}#rows"] = len(data["rows"])
for index, row in enumerate(data["rows"]):
for column, value in zip(data["columns"], row):
flat[f"table {table}[{index}].{column}"] = value
for name, (size, digest, mtime) in read_files(server.root).items():
flat[f"file {name}"] = [size, digest]
flat[f"file {name}#mtime"] = mtime
flat["files#count"] = sum(
1 for key in flat if key.startswith("file ") and "#" not in key
)
for key, value in flatten(response).items():
flat[f"response{key}"] = value
# Each server has its own temp root and a random storage epoch; name them
# instead of masking every column that carries them.
epoch = flat.get("table execution_history_metadata[0].storage_epoch")
names = [(str(server.root), "<root>")]
if isinstance(epoch, str) and epoch:
names.append((epoch, "<epoch>"))
for key, value in flat.items():
if isinstance(value, str):
for text, name in names:
value = value.replace(text, name)
flat[key] = value
return flat
def run(on_live: bool) -> list[dict]:
server = Server(on_live)
try:
states = []
for index, (path, data) in enumerate(RESETS * 2):
dirty_path, dirty_data = DIRTY[index % len(DIRTY)]
server.post_json("POST", dirty_path, dirty_data)
started = time.monotonic()
response = server.post_json("POST", path, data)
elapsed = time.monotonic() - started
print(f" {'old' if on_live else 'new'} {path} {data}: {elapsed:.2f}s")
states.append(snapshot(server, response))
return states
finally:
server.stop()
def column_key(key: str) -> str:
"""``table t[3].created_at`` -> ``table t.created_at`` (mask whole columns)."""
if key.startswith("table ") and "[" in key:
head, _, tail = key.partition("[")
return head + tail.partition("]")[2]
return key
def main() -> int:
old_a, old_b, new = run(True), run(True), run(False)
failures = 0
masked_all = set()
for step, (path, data) in enumerate(RESETS * 2):
a, b, n = old_a[step], old_b[step], new[step]
masked = {
column_key(key)
for key in set(a) | set(b)
if a.get(key, CLOCK) != b.get(key, CLOCK)
}
masked_all |= masked
keys = set(a) | set(n)
diffs = [
key
for key in sorted(keys)
if column_key(key) not in masked
and a.get(key, "<missing>") != n.get(key, "<missing>")
]
tables = len(
{k.split("[")[0].split("#")[0] for k in a if k.startswith("table ")}
)
status = "EQUAL" if not diffs else f"DIFFERENT ({len(diffs)} values)"
print(
f"{status}: {path} {json.dumps(data)}: {tables} tables, "
f"{a['files#count']} files, {len(keys)} values, "
f"{len(masked)} masked columns"
)
for key in diffs[:20]:
old, now = a.get(key, "<missing>"), n.get(key, "<missing>")
print(f" {key}: old {old!r} new {now!r}")
failures += bool(diffs)
print("masked (differ between the two old runs):")
for key in sorted(masked_all):
print(f" {key}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+69 -38
View File
@@ -19,8 +19,10 @@ from aiohttp.web_runner import GracefulExit
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import close_trash, replace_folder
from tests.browser.request_hold import register_request_holds
from tests.browser import slow_reset_watch
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
from tests.browser.workspace_activity_fixture import register_activity_commands
from tests.browser.workspace_system_fixture import register_system_commands
from tests.browser.workspace_integrations_fixture import (
@@ -45,6 +47,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 +205,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,8 +231,13 @@ 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)
# Resets seed a scratch copy and publish it in one commit (#384).
seeder = ScratchSeeder(
api, root / "reset-scratch", on_live=os.environ.get(SEED_ON_LIVE_ENV) == "1"
)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -238,6 +251,7 @@ def create_app(root: Path) -> web.Application:
routes.get(path)(canonical_asset)
def seed(
target,
extra_images: int = 0,
include_video: bool = False,
prompt_inspector: bool = False,
@@ -245,7 +259,7 @@ def create_app(root: Path) -> web.Application:
extra_prompts: int = 0,
extra_variants: int = 0,
) -> None:
with api.db.model.get_connection() as conn:
with target.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_favorites",
@@ -264,8 +278,7 @@ def create_app(root: Path) -> web.Application:
):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
for index, (directory, text, tag) in enumerate(
[
("output", "Cat portrait", "cats"),
@@ -308,28 +321,30 @@ def create_app(root: Path) -> web.Application:
image.save(image_path, pnginfo=info)
stamp = time.time() - index * 10
os.utime(image_path, (stamp, stamp))
pid = api.db.save_prompt(
pid = target.db.save_prompt(
text, tags=[tag], prompt_hash=generate_prompt_hash(text)
)
api.db.link_image_to_prompt(pid, str(image_path))
target.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
video_prompt_id = pid
for number in range(extra_images):
extra = root / directory / "bulk" / f"{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
target.db.link_image_to_prompt(pid, str(extra))
if native_library:
positive = api.db.get_prompt_by_hash(generate_prompt_hash("Cat portrait"))
positive = target.db.get_prompt_by_hash(
generate_prompt_hash("Cat portrait")
)
positive_id = positive["id"]
api.db.update_prompt_metadata(
target.db.update_prompt_metadata(
positive_id, category="Animals", tags=["cats", "studio"]
)
variants = []
for index, negative in enumerate(
["blurry, watermark", "low quality", "low contrast", ""]
):
pair_id = api.db.save_prompt_pair(
pair_id = target.db.save_prompt_pair(
"Cat portrait",
negative,
title=f"Portrait variant {index + 1}",
@@ -337,28 +352,30 @@ def create_app(root: Path) -> web.Application:
tags=["studio"] if index == 0 else ["outdoors"],
)
variants.append(pair_id)
with api.db.model.get_connection() as conn:
with target.db.model.get_connection() as conn:
images = conn.execute(
"SELECT id FROM generated_images WHERE prompt_id=? ORDER BY id",
(positive_id,),
).fetchall()
for index, image in enumerate(images[:2]):
api.db.link_image_to_pair(
target.db.link_image_to_pair(
variants[index], image["id"], {"source": "fixture", "anchor": index}
)
api.db.link_image_to_pair(
target.db.link_image_to_pair(
variants[0], images[0]["id"], {"source": "fixture second evidence"}
)
api.db.record_prompt_pair_use(variants[0], times=3)
target.db.record_prompt_pair_use(variants[0], times=3)
for number in range(extra_variants):
api.db.save_prompt_pair("Cat portrait", f"Negative extra {number:04}")
target.db.save_prompt_pair(
"Cat portrait", f"Negative extra {number:04}"
)
legacy = root / "output" / "legacy-unassigned.png"
shutil.copyfile(root / "output" / "shared" / "same.png", legacy)
api.db.link_image_to_prompt(positive_id, str(legacy))
api.db.save_prompt("Dog landscape", category="Animals", tags=["dogs"])
api.db.save_prompt_pair("Dual role portrait", "Cat portrait")
target.db.link_image_to_prompt(positive_id, str(legacy))
target.db.save_prompt("Dog landscape", category="Animals", tags=["dogs"])
target.db.save_prompt_pair("Dual role portrait", "Cat portrait")
for number in range(extra_prompts):
api.db.save_prompt(
target.db.save_prompt(
f"Library extra {number:04}", category="Bulk", tags=["bulk"]
)
if prompt_inspector:
@@ -379,8 +396,8 @@ def create_app(root: Path) -> web.Application:
from py.api.thumbnail_settings import ThumbnailSettings
# 150 px previews in the default format (#317 thumbnail settings).
api._generate_gallery_thumbnails_sync(
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
target._generate_gallery_thumbnails_sync(
target._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
)
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
@@ -388,8 +405,8 @@ def create_app(root: Path) -> web.Application:
shutil.copyfile(
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", video
)
api.db.link_image_to_prompt(video_prompt_id, str(video))
api.invalidate_gallery_cache()
target.db.link_image_to_prompt(video_prompt_id, str(video))
target.invalidate_gallery_cache()
history_fixture = None
@@ -415,6 +432,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()
@@ -434,7 +452,10 @@ def create_app(root: Path) -> web.Application:
raise web.HTTPBadRequest()
if chronological_history:
manifest = await api._run_in_executor(
lambda: seed_workspace_history(api, root, history_runs=history_runs)
seeder.run,
lambda target: seed_workspace_history(
target, root, history_runs=history_runs
),
)
history_fixture = manifest
return web.json_response({"success": True, **manifest})
@@ -492,25 +513,31 @@ def create_app(root: Path) -> web.Application:
raise web.HTTPBadRequest()
if workspace_visual:
history_fixture = None
await api._run_in_executor(seed_workspace_visual, api, root)
await api._run_in_executor(
seeder.run, lambda target: seed_workspace_visual(target, root)
)
return web.json_response({"success": True})
if full_inspector:
history_fixture = None
await api._run_in_executor(
lambda: seed_workspace_inspector(
api, root, extra_images=extra, include_video=include_video
)
seeder.run,
lambda target: seed_workspace_inspector(
target, root, extra_images=extra, include_video=include_video
),
)
return web.json_response({"success": True})
history_fixture = None
await api._run_in_executor(
seed,
extra,
include_video,
prompt_inspector,
native_library,
extra_prompts,
extra_variants,
seeder.run,
lambda target: seed(
target,
extra,
include_video,
prompt_inspector,
native_library,
extra_prompts,
extra_variants,
),
)
return web.json_response({"success": True})
@@ -550,14 +577,15 @@ def create_app(root: Path) -> web.Application:
reveal_stub.reset()
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
api,
seeder.run,
lambda target: seed_workspace_concept(
target,
root,
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
short_seedless=data.get("short_seedless", False),
now=data.get("now"),
)
),
)
return web.json_response({"success": True, **manifest})
@@ -625,9 +653,12 @@ def create_app(root: Path) -> web.Application:
async def cleanup(application: web.Application) -> None:
# A scan of an ambiguous fixture file captures it for Needs review.
api.close_annotation_captures()
seeder.close()
api.db.close_all()
# Old media folders still waiting for deletion go before the temp root.
close_trash(root)
seed()
seed(api)
# Open Needs review change streams end first, as on the real host.
app.on_shutdown.append(api.close_review_events)
app.on_cleanup.append(cleanup)
@@ -0,0 +1,396 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate, measureReview, mapPlanConcept }
from './concept-inventory.mjs';
// #249 System S12 Task 7 (#329): System › Diagnostics & logs (app.js renderSystem 694–695) and its log cleanup review
// (the concept's `maintenancePlan("rotated logs")`, 953–958) against the immutable concept, on the concept-shaped
// fixture with the logger writing into this server's own root and seeded with the concept's sample (four lines, a
// 1.4 MB active log, three rotated copies, console logging off). Each concept measurement is mapped only by the recorded
// deviations (D109, D110, and D100 for the review), each commented where it applies; after that the difference must be
// empty, and a removed term, a renamed button, a reordered option, a primary Refresh, a squeezed select or a swapped
// token must be noticed.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
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__/jobs/restart')).toBeOK();
await expect(await request.post('/__test__/logs/reset')).toBeOK();
await expect(await request.post('/__test__/logs/seed', { data: { mode: 'concept' } })).toBeOK();
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/jobs/restart');
await request.post('/__test__/logs/reset');
await request.post('/__test__/system/reset');
expect(page.conceptErrors).toEqual([]);
});
const REPORT_URL = '/prompt_manager/system/diagnostics';
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
const LINE_FORMAT = /^\d\d:\d\d:\d\d [A-Z]+ +\S/;
/** Runs in the page: Diagnostics & logs under `mainSelector`: every surface's text, controls, geometry and tokens. */
function measureDiagnostics([mainSelector, lineFormat]) {
const format = new RegExp(lineFormat);
const layout = document.querySelector(`${mainSelector} .settings-layout`);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const tokens = (node, ...names) => Object.fromEntries(names.map(key => [key, getComputedStyle(node).getPropertyValue(key).trim()]));
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const button = node => ({ text: text(node), className: node.className });
const control = row => {
const select = row.querySelector(':scope > select');
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
return toggle ? { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
disabled: toggle.disabled } : null;
};
const operations = parent => [...parent.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
actions: [...row.querySelectorAll('.row-actions > button')].map(button) }));
const toolbarItem = node => ({ name: name(node), text: node.localName === 'select' || node.localName === 'input' ? null : text(node),
for: node.localName === 'label' ? node.htmlFor : null, id: node.id || null,
options: node.localName === 'select' ? [...node.options].map(option => option.textContent) : null,
// The chosen option, so a wrong default level is noticed.
selected: node.localName === 'select' ? node.selectedOptions[0]?.textContent ?? null : null,
input: node.localName === 'input' ? [node.type, node.min, node.max, node.value] : null,
className: node.localName === 'button' ? node.className : null });
const surfaces = [...layout.querySelectorAll(':scope > section.surface')];
const pre = layout.querySelector('pre');
const lines = (pre?.textContent ?? '').split('\n').filter(Boolean);
const details = layout.querySelector('details');
const firstRow = layout.querySelector('.setting-row');
const toolbar = layout.querySelector('.toolbar');
return {
layout: [...layout.children].map(name),
surfaces: surfaces.map(surface => {
const heading = surface.querySelector(':scope > .panel-heading');
const grid = surface.querySelector(':scope > .meta-grid');
return {
tree: [...surface.children].map(name),
heading: text(surface.querySelector(':scope > h2, :scope > .panel-heading > h2')),
panelHeading: heading && [...heading.children].map(node => [name(node), text(node)]),
grid: grid && [...grid.children].map(entry => [name(entry), text(entry.querySelector('dt')), text(entry.querySelector('dd'))]),
buttons: [...surface.querySelectorAll(':scope > button')].map(button),
toolbar: surface.querySelector(':scope > .toolbar') && [...surface.querySelector(':scope > .toolbar').children].map(toolbarItem),
pre: surface.querySelector(':scope > pre') && { tabIndex: pre.tabIndex, tree: [...pre.children].map(name), lines,
format: lines.every(line => format.test(line)) },
notes: [...surface.querySelectorAll(':scope > p')].map(node => [name(node), text(node), node.getAttribute('role'),
node.getAttribute('aria-label')]),
rows: [...surface.querySelectorAll(':scope > .setting-row')].map(row => ({ tree: [...row.children].map(name),
text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
control: control(row) })),
details: surface.querySelector(':scope > details') && {
summary: text(details.querySelector(':scope > summary')), tree: [...details.children].map(name),
notes: [...details.querySelectorAll(':scope > p')].map(node => [name(node), text(node)]),
buttons: [...details.querySelectorAll(':scope > button')].map(button),
files: [...details.querySelectorAll(':scope > .operation-list > .operation')].map(row => ({
title: text(row.querySelector('h3')), detail: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(item =>
[text(item), item.getAttribute('aria-label')]) })),
operations: operations(details) },
operations: operations(surface),
};
}),
tokens: tokens(surfaces[0], '--line', '--panel', '--accent'),
surface: css(surfaces[1], 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius',
'marginTop'),
grid: css(layout.querySelector('.meta-grid'), 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
toolbar: css(toolbar, 'display', 'flexWrap', 'alignItems', 'columnGap', 'rowGap', 'marginBottom'),
pre: css(pre, 'fontFamily', 'fontSize', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'paddingTop', 'overflowX'),
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
'borderTopColor'),
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop'),
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
summary: css(details.querySelector('summary'), 'fontSize', 'fontWeight', 'color', 'cursor'),
small: css(details.querySelector(':scope > p.small'), 'fontSize', 'color'),
// Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows
// that option whole (the browser's own select sizing, arrow included).
selectsFit: [...layout.querySelectorAll('select')].map(select => {
const copy = select.cloneNode(false);
copy.removeAttribute('id');
copy.append(new Option(select.selectedOptions[0]?.textContent ?? ''));
Object.assign(copy.style, { position: 'absolute', visibility: 'hidden', width: 'max-content', minWidth: '0',
maxWidth: 'none', flex: 'none' });
select.parentElement.append(copy);
const needed = copy.getBoundingClientRect().width;
copy.remove();
return select.getBoundingClientRect().width + 0.5 >= needed;
}),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
const plural = (count, one, many = `${one}s`) => `${count.toLocaleString('en-US')} ${count === 1 ? one : many}`;
/** The server's size label (1 KB = 1024 bytes), as the section prints it. */
function sizeText(bytes) {
if (bytes < 1024) return plural(bytes, 'byte');
if (bytes < 2 ** 20) return `${(bytes / 2 ** 10).toFixed(1)} KB`;
if (bytes < 2 ** 30) return `${(bytes / 2 ** 20).toFixed(1)} MB`;
return `${(bytes / 2 ** 30).toFixed(1)} GB`;
}
const KINDS = Object.freeze({ active: 'Active log', rotated: 'Rotated copy', other: 'Other file' });
const RENDERER_NONE = 'No ComfyUI window is open in this browser, so its renderer is not known.';
const COPY = Object.freeze({
testLink: 'Write a placeholder image record to check that linking works.',
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.',
clear: 'Empty the active log and delete its rotated copies.',
retention: 'Rotates at 10 MB · keeps 5 rotated copies · file logging on · memory buffer 1,000 entries',
});
/**
* The concept's Diagnostics & logs with the recorded deviations applied, for the server's `report` and the seeded
* `files` (`/logs/files`). The concept's own sample values the fixture reproduces stay as they are: the four log lines,
* the Viewer.js line, "prompt_manager.log · 1.4 MB · rotated copies: 3", INFO and console logging off.
*/
function mapDiagnosticsConcept(reference, { report, files, stats }) {
const [diagnostics, logs] = reference.surfaces;
const [levelLabel, level, refresh, download] = logs.toolbar;
const [runtime, consoleRow] = logs.rows;
const database = report.checks.database;
const { configured, watching, unavailable } = report.sources;
const sources = configured === 0 ? `ComfyUI output folder · ${watching ? 'watching' : 'manual scan'}`
: unavailable > 0 ? `${plural(configured, 'source')} · ${unavailable} not available`
: `${plural(configured, 'source')} · ${watching} watching`;
const errors = (stats.level_counts.ERROR ?? 0) + (stats.level_counts.CRITICAL ?? 0);
return {
...reference,
surfaces: [
{ ...diagnostics,
// D110: the Test image link operation follows the concept's Image-link diagnostics.
tree: [...diagnostics.tree, 'div.operation'],
// D110: live values. Database and Sources from `GET /system/diagnostics`, Host renderers is D106's
// renderer line (no ComfyUI window in this browser); the pinned Viewer.js line is the concept's own.
grid: diagnostics.grid.map(([entry, term, value]) => [entry, term, {
Database: `Readable · ${sizeText(report.database_info.database_size_bytes)} · ${plural(database.image_count, 'image')}`,
Sources: sources,
'Host renderers': RENDERER_NONE }[term] ?? value]),
// D110: the buttons drop the concept's "sample".
buttons: diagnostics.buttons.map(item => ({ ...item, text: item.text.replace('Download sample diagnostic report',
'Download diagnostic report') })),
operations: [
// D110: "Inspect" opens the read-only Inspect a file page.
...diagnostics.operations.map(row => ({ ...row, actions: row.actions.map(item => ({ ...item, text: 'Inspect' })) })),
// D110: the legacy synthetic link (S12-11), enabled: the fixture has a most recent prompt.
{ ...diagnostics.operations[0], title: 'Test image link', description: COPY.testLink,
actions: [{ ...diagnostics.operations[0].actions[0], text: 'Create test link…' }] },
] },
{ ...logs,
// D109: the statistics line follows the viewer.
tree: [logs.tree[0], logs.tree[1], logs.tree[2], 'p.small', ...logs.tree.slice(3)],
toolbar: [
levelLabel,
// D109: all six minimum levels (the concept has three).
{ ...level, options: ['All levels', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] },
// D109: the added Lines control, 1–500, default 100.
{ name: 'label.small', text: 'Lines', for: 'log-limit', id: null, options: null, selected: null, input: null,
className: null },
{ name: 'input', text: null, for: null, id: 'log-limit', options: null, selected: null,
input: ['number', '1', '500', '100'], className: null },
refresh,
// D109: the active log, not a sample.
{ ...download, text: 'Download log' },
],
// D109: "Log statistics" (errors are ERROR + CRITICAL).
notes: [['p.small', `${plural(stats.buffer_count, 'entry', 'entries')} in memory · ${plural(errors, 'error')} · `
+ `${plural(stats.level_counts.WARNING ?? 0, 'warning')} · ${plural(stats.log_files_count, 'file')} · `
+ `${sizeText(stats.total_log_size)} on disk`, 'status', 'Log statistics']],
rows: [
// D109: CRITICAL after the concept's levels; the value lasts until the server restarts.
{ ...runtime, text: [runtime.text[0], ['p', COPY.runtimeLevel, null]],
control: { ...runtime.control, options: [...runtime.control.options, 'CRITICAL'] } },
// D109: what the console setting does and that it lasts until the server restarts (off, as the
// concept's sample, by the fixture's seed).
{ ...consoleRow, text: [consoleRow.text[0], ['p', COPY.console, null]] },
],
details: { ...logs.details,
// D109: the retention line, the files and Clear all logs follow the concept's line and review.
tree: [...logs.details.tree, 'p.small', 'div.operation-list', 'div.operation'],
notes: [...logs.details.notes, ['p.small', COPY.retention]],
files: files.map(row => ({ title: row.filename, detail: `${sizeText(row.size)} · ${row.modified.slice(0, 16)
.replace('T', ' ')} · ${KINDS[row.kind]}`, actions: [['Download', `Download ${row.filename}`]] })),
operations: [{ tree: ['div', 'div.row-actions'], title: 'Clear all logs', description: COPY.clear,
actions: [{ text: 'Clear all logs…', className: 'danger' }] }] } },
],
};
}
async function conceptDiagnostics(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
await expect(page.locator('#main .settings-layout > section.surface')).toHaveCount(2);
await page.mouse.move(0, 0);
return page.evaluate(measureDiagnostics, ['#main', LINE_FORMAT.source]);
}
/** Production's section once every read answered, with the server's report, files and statistics it shows. */
async function productionDiagnostics(page, request, theme) {
await openProduction(page, 'system/diagnostics', theme);
const main = page.locator('#workspace-main');
await expect(main.locator('.settings-layout > section.surface')).toHaveCount(2);
await expect(main.locator('.meta-grid dd')).not.toContainText(['Loading…']);
await expect(main.locator('.meta-grid dd').last()).toHaveText(RENDERER_NONE);
await expect(main.locator('#log-lines')).not.toHaveText('Loading…');
await expect(main.locator('#log-stats')).not.toHaveText('Loading…');
await expect(main.locator('details > p.small')).not.toContainText(['Loading…']);
await expect(main.locator('#runtime-level')).toBeEnabled();
await expect(main.locator('.operation').filter({ hasText: 'Create test link…' }).locator('button')).not.toHaveAttribute('aria-disabled', 'true');
await page.mouse.move(0, 0);
const production = await page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]);
const read = async url => (await request.get(url)).json();
const report = await read(REPORT_URL);
const { files } = await read('/prompt_manager/logs/files');
const { stats } = await read('/prompt_manager/logs/stats');
return { production, report, files, stats };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`diagnostics and logs match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptDiagnostics(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-diagnostics-${theme}-${width}.png`), fullPage: true });
const { production, ...live } = await productionDiagnostics(page, request, theme);
await page.screenshot({ path: testInfo.outputPath(`production-diagnostics-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapDiagnosticsConcept(concept, live), production)).toEqual([]);
expect(production.surfaces[1].pre.format).toBe(true);
expect(production.pageScroll).toBe(false);
});
}
// Below the concept's smallest size: on a 320px phone the Level and Runtime log level selects still show their whole
// option, without page scroll.
for (const theme of THEMES) test(`diagnostics selects fit at 320 wide ${theme}`, async ({ page, request }) => {
await page.setViewportSize({ width: 320, height: 700 });
const { production } = await productionDiagnostics(page, request, theme);
expect(production.selectsFit).toEqual([true, true]);
expect(production.pageScroll).toBe(false);
});
/**
* D100 with D109's rule for this plan: the server's `rotated_logs` plan in the concept's dialog, "Rotated log copies"
* as its `h3`, the plan's grid values (its Backup row says none is needed), "Listed files (N)", the hold line, no D55
* backup box (nothing in the library changes) and "Apply reviewed operation".
*/
function mapCleanupConcept(reference, plan) {
const mapped = mapPlanConcept(reference, 'Rotated log copies', plan);
return {
...mapped,
// D109: no backup box for a plan that changes no library record.
tree: mapped.tree.filter(item => item !== 'div.notice'),
grid: { ...mapped.grid, rows: mapped.grid.rows.map(([term, value]) =>
[term, term === 'Backup' ? 'Not needed: no library record changes' : value]) },
backup: null,
// D109: the plan lists files, not records.
details: { ...mapped.details, summary: `Listed files (${plan.details.length})` },
};
}
async function conceptCleanup(page, theme) {
await conceptDiagnostics(page, theme);
await page.locator('#main details > summary').click();
await page.locator('#main [data-plan="rotated logs"]').click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
async function productionCleanup(page, request, theme) {
await productionDiagnostics(page, request, theme);
await page.locator('#workspace-main details > summary').click();
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main').getByRole('button', { name: 'Review log cleanup', exact: true }).click();
const plan = await (await answer).json();
await expect(page.locator('dialog#modal[open] #modal-body > h3')).toHaveText('Rotated log copies');
await page.mouse.move(0, 0);
return { plan, production: await page.evaluate(measureReview) };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`log cleanup review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptCleanup(page, theme);
expect(concept.heading.text).toBe('rotated logs');
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-log-cleanup-${theme}-${width}.png`) });
const { plan, production } = await productionCleanup(page, request, theme);
expect(plan.operation).toBe('rotated_logs');
await page.screenshot({ path: testInfo.outputPath(`production-log-cleanup-${theme}-${width}.png`) });
expect(diffInventory(mapCleanupConcept(concept, plan), production)).toEqual([]);
});
}
// Every path where production differs from the unmapped concept: D110's live values (Viewer.js is the concept's own),
// renamed buttons and added operation, and D109's toolbar, statistics line, runtime rows and log files.
const DEVIATED_PATHS = [
'surfaces.0.buttons.0', 'surfaces.0.grid.0', 'surfaces.0.grid.1', 'surfaces.0.grid.3', 'surfaces.0.operations.0',
'surfaces.0.operations.1', 'surfaces.0.operations.length', 'surfaces.0.tree.4', 'surfaces.0.tree.length',
'surfaces.1.details.files', 'surfaces.1.details.notes', 'surfaces.1.details.operations', 'surfaces.1.details.tree',
'surfaces.1.notes.0', 'surfaces.1.notes.length', 'surfaces.1.rows.0', 'surfaces.1.rows.1', 'surfaces.1.toolbar.1',
'surfaces.1.toolbar.2', 'surfaces.1.toolbar.3', 'surfaces.1.toolbar.4', 'surfaces.1.toolbar.5', 'surfaces.1.toolbar.length',
'surfaces.1.tree.3', 'surfaces.1.tree.5', 'surfaces.1.tree.6', 'surfaces.1.tree.length'].sort();
test('diagnostics comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptDiagnostics(page, 'dark');
const { production, ...live } = await productionDiagnostics(page, request, 'dark');
const expected = mapDiagnosticsConcept(concept, live);
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only where D109 and D110 are recorded.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 4).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual(DEVIATED_PATHS);
const measure = () => page.evaluate(measureDiagnostics, ['#workspace-main', LINE_FORMAT.source]);
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed term.
const undoTerm = await mutate(page, { remove: ['#workspace-main .meta-grid > div:nth-child(2)'] });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.0.grid.length']));
await undoTerm();
// A renamed button.
await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download report'; });
expect(await paths()).toEqual(['surfaces.0.buttons.0.text']);
await page.evaluate(() => { document.querySelector('#workspace-main section.surface > button').textContent = 'Download diagnostic report'; });
// A reordered level option.
await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.append(select.options[1]); });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.1.options.1']));
await page.evaluate(() => { const select = document.querySelector('#workspace-main #log-level'); select.insertBefore(select.options[5], select.options[1]); });
// A wrong default level.
await page.evaluate(() => { document.querySelector('#workspace-main #log-level').value = 'INFO'; });
expect(await paths()).toEqual(['surfaces.1.toolbar.1.selected']);
await page.evaluate(() => { document.querySelector('#workspace-main #log-level').value = ''; });
// A primary Refresh.
await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.add('ca-primary'); });
expect(await paths()).toEqual(expect.arrayContaining(['surfaces.1.toolbar.4.className']));
await page.evaluate(() => { document.querySelectorAll('#workspace-main .toolbar > button')[0].classList.remove('ca-primary'); });
// A select squeezed until its chosen option is cut.
const undoSqueeze = await mutate(page, { css: '#workspace-main #runtime-level { max-width: 30px !important; min-width: 0 !important; }' });
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.1']));
await undoSqueeze();
// A swapped token.
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['tokens.--line', 'row.borderTopColor']));
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
// The log cleanup review: a changed grid term, a backup box that came back and a grid token are noticed.
const conceptReview = await conceptCleanup(page, 'dark');
const { plan } = await productionCleanup(page, request, 'dark');
const expectedReview = mapCleanupConcept(conceptReview, plan);
const reviewPaths = async () => diffInventory(expectedReview, await page.evaluate(measureReview)).map(item => item.path);
expect(await reviewPaths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Safety'; });
expect(await reviewPaths()).toEqual(['grid.rows.2.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[2].textContent = 'Backup'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await reviewPaths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
expect(await reviewPaths()).toEqual([]);
});
@@ -1,6 +1,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate, measureReview, mapPlanConcept }
from './concept-inventory.mjs';
// #249 System S7 Task 20: System › Maintenance (app.js renderSystem 688–690), its plan dialog (`maintenancePlan`,
// 953–958) for each operation and its duplicates dialog (`duplicates`, 1457–1461) against the immutable concept, on
@@ -103,47 +104,6 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
});
}
/** Runs in the page: the open review dialog (plan or duplicates): its title, body and the concept's tokens. */
function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const compare = body.querySelector('.compare-grid');
const item = compare?.querySelector('.compare-item');
const notes = [...body.querySelectorAll(':scope > p.small')];
const note = notes.at(-1);
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
heading: body.querySelector(':scope > h3') && { text: text(body.querySelector(':scope > h3')),
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
grid: grid && { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
details: body.querySelector(':scope > details') && { summary: text(body.querySelector(':scope > details > summary')),
tree: [...body.querySelector(':scope > details').children].map(name) },
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
compare: compare && { tree: [...compare.children].map(name), ...css(compare, 'display', 'gridTemplateColumns', 'columnGap'),
items: [...compare.children].map(entry => ({ tree: [...entry.children].map(name), label: text(entry.querySelector('label')),
checked: entry.querySelector('input[type=radio]').checked, image: entry.querySelector('img')?.className ?? null })),
item: css(item, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
image: css(item.querySelector('img'), 'width', 'objectFit'),
label: css(item.querySelector('label'), 'fontSize', 'color'),
radio: { name: item.querySelector('input[type=radio]').name.startsWith('keep-') ? 'keep-*' : 'other',
type: item.querySelector('input').type } },
note: note && { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
// The concept's plan names (`data-plan`) and production's operation, row and button for each.
const PLANS = Object.freeze([
['duplicate prompts', 'duplicate_prompts', 'Duplicate prompt text'],
@@ -151,32 +111,6 @@ const PLANS = Object.freeze([
['unused prompt components', 'unused_components', 'Unused prompt components'],
['missing-file links', 'missing_links', 'Missing-file links'],
]);
const BACKUP_BOX = Object.freeze({ tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] });
/**
* D100: the server's plan in the concept's dialog: the operation's row title as its `h3` (the concept prints its
* `data-plan` name), the four rows with the plan's values, the listed records (`details`), the hold line in the
* concept's `p.small`, the D55 backup box before the actions, and "Apply reviewed operation". No sample note.
*/
function mapPlanConcept(reference, title, plan) {
const backup = plan.backup.policy === 'off' ? 'No safety backup (the policy is off)'
: `Create and verify before changes (${plan.backup.destination_label})`;
const tree = ['h3', 'dl.meta-grid', ...(plan.details.length ? ['details'] : []), 'p.small', 'div.notice', 'div.dialog-actions'];
const [cancel, apply] = reference.actions.buttons;
return {
...reference,
tree,
heading: { ...reference.heading, text: title },
grid: { ...reference.grid, rows: [['Scope', plan.scope.label],
['Protected', plan.protected.reasons.length ? plan.protected.reasons.join(' · ') : 'Nothing is protected'],
['Backup', backup], ['Media on disk', plan.media.label]] },
details: plan.details.length ? { summary: `Listed records (${plan.details.length})`, tree: ['summary', 'pre'] } : null,
backup: BACKUP_BOX,
note: { ...reference.note, text: 'Nothing changes until you apply. This review holds for 15 minutes.' },
actions: { ...reference.actions, buttons: [cancel, { ...apply, text: 'Apply reviewed operation' }] },
};
}
async function conceptPlan(page, theme, name) {
await conceptMaintenance(page, theme);
+32 -14
View File
@@ -33,7 +33,7 @@ test.afterEach(async ({ page, request, browserName }) => {
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
const BATCH = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
function measureTagging(mainSelector) {
@@ -272,11 +272,19 @@ function measureReview() {
}
const number = value => value.toLocaleString('en-US');
/** DEC11 (#379): the Time row the preview's estimate reads as (WD14 on the test server's processor). */
function timeRow(plan) {
const minutes = Math.max(1, Math.ceil(plan.estimate_seconds / 60));
const text = minutes < 90 ? `about ${minutes} min`
: `about ${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;
return `${text} on a processor; a graphics card can be faster.`;
}
/**
* D134: the server's batch preview in the concept's review: "Review tag output images" with "Tag output images" as its
* `h3`, the model, policy, counts and settings in the concept's `dl.meta-grid` (Backup in the concept's wording), the
* upper-bound sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start tagging".
* D134 (#379): the server's batch preview in the concept's review: "Review tag output images" with "Tag output images"
* as its `h3`, the model, policy, image counts, time estimate and settings in the concept's `dl.meta-grid` (Backup in
* the concept's wording), the estimate sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start
* tagging".
*/
function mapBatchConcept(reference, plan) {
const [cancel, start] = reference.actions.buttons;
@@ -286,13 +294,14 @@ function mapBatchConcept(reference, plan) {
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
heading: { ...reference.heading, text: 'Tag output images' },
grid: { ...reference.grid, rows: [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', number(plan.images)], ['Prompts', number(plan.prompts)], ['Already tagged', number(plan.already_tagged)],
['Prompts to tag', `up to ${number(plan.to_tag)}`], ['Settings', 'General 0.35 · Character 0.85'],
['Images', number(plan.images)], ['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`], ['Time', timeRow(plan)],
['Settings', `General 0.35 · Character 0.85 · up to ${number(plan.suggestion_limit)} tags per image`],
['Backup', 'Create and verify before changes']] },
backup: { tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] },
note: { ...reference.note, text: 'Nothing is written until you start. Missing files and files outside the output folders '
+ 'are counted, so the prompts to tag are an upper bound; Activity reports what was tagged.' },
note: { ...reference.note, text: 'Nothing is written until you start. The number to tag is an estimate: images changed '
+ 'since they were tagged are tagged again, and missing files are skipped. Activity reports what was tagged.' },
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start tagging' }] },
};
}
@@ -308,8 +317,8 @@ async function conceptReview(page, theme) {
async function productionReview(page, theme) {
await productionTagging(page, theme);
// Every concept-fixture prompt with an image is already tagged, so under the saved Skip there is nothing to start
// (the Cancel-only review). Merge reviews a batch with work: the full review the concept's shell is compared with.
// Merge reviews every listed still image (#379): a batch with work, the full review the concept's shell is compared
// with.
await page.locator('#workspace-main #tag-existing').selectOption('merge');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
@@ -326,8 +335,9 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
const { plan, production } = await productionReview(page, theme);
// Under Merge every prompt with a linked image is tagged again: the batch has work to review.
// Under Merge every listed still image is tagged again: the batch has work to review.
expect(plan.to_tag).toBeGreaterThan(0);
expect(plan.estimate_device).toBe('cpu');
await page.screenshot({ path: testInfo.outputPath(`production-tagging-review-${theme}-${width}.png`) });
expect(diffInventory(mapBatchConcept(concept, plan), production)).toEqual([]);
});
@@ -372,9 +382,17 @@ test('tag output images review comparison detects mutations', async ({ page }) =
const expected = mapBatchConcept(concept, plan);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'To tag'; });
expect(await paths()).toEqual(['grid.rows.5.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'Prompts to tag'; });
// Every label of the grid is compared (#379's rows included): a changed one is named by its row.
for (const [index, [term]] of expected.grid.rows.entries()) {
await page.evaluate(([at, label]) => {
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
}, [index, `${term}!`]);
expect(await paths()).toEqual([`grid.rows.${index}.0`]);
await page.evaluate(([at, label]) => {
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
}, [index, term]);
}
expect(await paths()).toEqual([]);
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
+5
View File
@@ -42,8 +42,13 @@ test('actual gallery form saves a prompt that the admin page can edit and reload
await page.locator(`[data-action="edit"][data-prompt-id="${id}"]`).click();
const editor = page.locator(`.prompt-text[data-id="${id}"]`);
await editor.fill('Browser edited portrait');
// The page shows the edit at once and saves it after: reload only once the save answered (a reload that wins the
// race abandons the PUT, as seen on WebKit in CI).
const savedEdit = page.waitForResponse(item => item.request().method() === 'PUT'
&& item.url().endsWith(`/prompt_manager/prompts/${id}`));
await editor.press('Enter');
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
expect((await savedEdit).ok()).toBe(true);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Browser edited portrait' })).toBeVisible();
const saved = await request.post('/prompt_manager/save', { data: { text: 'Browser saved landscape' } });
@@ -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);
}
});
+26 -17
View File
@@ -315,22 +315,28 @@ test('Preview tagging review without a selection toasts the D115 sentence', asyn
await expect(tagDialog(page)).toHaveCount(0);
});
test('Tag output images reviews the scope and starts a batch job', async ({ page, request }) => {
test('Tag output images reviews the images, tags each one and never a prompt (#379)', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/tagging/snapshot');
await openTagging(page);
// The fixture's one prompt is already tagged: under Merge it is tagged again with what its images give.
await page.locator('#tag-existing').selectOption('merge');
// The saved policy is Skip: no image is auto-tagged yet, so every listed still image is to tag.
const previewed = page.waitForResponse(item => new URL(item.url()).pathname === PREVIEW_URL);
await reviewButton(page).click();
const answer = await previewed;
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge' });
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip' });
const plan = await answer.json();
// Four still images (two linked, a copy, an unlinked one); the video is not a target.
expect([plan.images, plan.already_tagged, plan.to_tag]).toEqual([4, 0, 4]);
expect(plan.prompts).toBeUndefined();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Tag output images');
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', String(plan.images)], ['Prompts', String(plan.prompts)], ['Already tagged', String(plan.already_tagged)],
['Prompts to tag', `up to ${plan.to_tag}`], ['Settings', 'General 0.35 · Character 0.85'],
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
['Images', '4'], ['Already auto-tagged', '0'], ['Images to tag', 'about 4'],
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
['Settings', `General 0.35 · Character 0.85 · up to ${plan.suggestion_limit} tags per image`],
['Backup', 'Create and verify before changes']]);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Nothing is written until you start. The number to '
+ 'tag is an estimate: images changed since they were tagged are tagged again, and missing files are skipped. '
+ 'Activity reports what was tagged.');
await expect(dialog(page).locator('#modal-body > .notice h3')).toHaveText('Back up the database first (recommended)');
// The review wrote nothing.
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
@@ -338,15 +344,23 @@ test('Tag output images reviews the scope and starts a batch job', async ({ page
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/autotag');
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
const start = await started;
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge',
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
const { job } = await start.json();
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(page.locator('#workspace-main h1')).toHaveText('Tag output images');
const finished = await ended(request, job.id);
expect(finished.state).toBe('finished');
const after = await serverJson(request, '/__test__/tagging/snapshot');
expect(after.prompt_tags.length).toBeGreaterThan(before.prompt_tags.length);
expect(finished.status_line).toBe('Tagged 4 images');
// Each image got its own tags; no prompt, pair or link row changed.
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
// Under Skip every image is now auto-tagged: nothing to start.
await openTagging(page);
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every output image is already auto-tagged.');
expect((await gridOf(page)).slice(2, 5)).toEqual([['Images', '4'], ['Already auto-tagged', '4'], ['Images to tag', 'about 0']]);
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
});
test('Replace after review shows only Cancel with the reason', async ({ page, request }) => {
@@ -355,18 +369,13 @@ test('Replace after review shows only Cancel with the reason', async ({ page, re
await page.locator('#tag-existing').selectOption('replace_after_review');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images linked', 'Prompts', 'Already tagged',
'Prompts to tag', 'Settings', 'Backup']);
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images', 'Already auto-tagged',
'Images to tag', 'Time', 'Settings', 'Backup']);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Replacing tags needs a review of each image. '
+ 'Use Preview tagging review, or choose Skip or Merge for a batch run.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
// Under Skip the fixture's only prompt is already tagged: nothing to start either.
await page.locator('#tag-existing').selectOption('skip');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every prompt with an output image is already tagged.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.filter(item => item.kind === 'autotag')).toEqual([]);
});
+47 -3
View File
@@ -1,6 +1,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools/vendor/legacy-coverage.mjs';
// System › Overview (#249 System S2 Task 6) on the concept-shaped real fixture: the metrics, rows, Statistics dialog and
// Health report read the server, never sample values (D93, D95, D96). The hooks are workspace_system_fixture.py's.
@@ -412,15 +413,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);
@@ -793,3 +795,45 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
videoMute: true, showImageInfo: true });
});
// TESTING.md "Legacy System capability coverage" (#249 System S12, #329): each `covered` row's native control is on its
// System section, found by role and accessible name (a `term` by its text, a disclosure by its summary). Collapsed
// `details` are opened first, so a control inside one counts once it is shown. tests/vendor/legacy-coverage.test.mjs
// checks the rest of each row.
test('every covered legacy System capability has its native control', async ({ page, request }) => {
// Download is offered for a model that is not on disk ("Download again" otherwise): the selected default's file is
// missing here.
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
try {
await findControls(page);
} finally {
await request.post('/__test__/tagging/reset', { data: {} });
}
});
async function findControls(page) {
const covered = readCoverageTable().filter(row => row.Status === 'covered')
.map(row => ({ capability: row.Capability, ...parseControl(row['Native control']) }));
// A covered row without a control is one of the recorded exemptions, never silently skipped.
expect(covered.filter(row => !row.section).map(row => row.capability)).toEqual(Object.keys(CONTROL_EXEMPTIONS));
const controls = covered.filter(row => row.section);
expect(controls.length).toBeGreaterThan(40);
const sections = [...new Set(controls.map(row => row.section))];
for (const section of sections) {
await page.goto(`${workspace}#/system/${section}`);
const main = page.locator('#workspace-main');
await expect(layoutOf(page).locator(':scope > *').first()).toBeVisible();
for (const details of await main.locator('details:not([open]) > summary').all()) await details.click();
for (const { capability, role, name, row } of controls.filter(item => item.section === section)) {
// A name several rows share is looked up inside the row the table names.
const within = row ? main.locator('.operation').filter({ has: page.locator(`h3:text-is("${row}")`) }) : main;
const exact = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
// A `dt` (term) and a `details` disclosure (group) have no name from content: the term's text, the summary's.
const control = role === 'term' ? within.getByRole('term').filter({ hasText: exact })
: role === 'group' ? within.locator('details').filter({ has: page.locator(':scope > summary', { hasText: exact }) })
.or(within.getByRole('group', { name, exact: true }))
: within.getByRole(role, { name, exact: true });
await expect(control.first(), `${capability}: ${section} · ${role} · "${name}"${row ? ` in "${row}"` : ''}`).toBeVisible();
}
}
}
+15 -18
View File
@@ -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');
}
+2 -3
View File
@@ -16,7 +16,6 @@ import json
import math
import os
from pathlib import Path
import shutil
import time
from types import SimpleNamespace
from typing import TYPE_CHECKING
@@ -34,6 +33,7 @@ from utils.execution_contexts import (
from utils.execution_history_events import make_finish, make_start
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
from utils.pair_execution import EXECUTION_KEY
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -300,8 +300,7 @@ def _clear(api: PromptManagerAPI, root: Path) -> None:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
def _record_run(
@@ -0,0 +1,267 @@
"""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``).
``{mode: "concept"}`` reproduces the concept's Logs sample (app.js 695)
for the comparison: the buffer holds only its four lines, the active log
is 1.4 MB with three rotated copies ("prompt_manager.log · 1.4 MB · rotated
copies: 3") and console logging is off, as the concept's toggle;
- ``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)
# The concept's "prompt_manager.log · 1.4 MB · rotated copies: 3": the active
# log stays under 1.45 MB while the server writes its own lines.
CONCEPT_ACTIVE = int(1.4 * 1024 * 1024)
CONCEPT_ROTATED = (3072, 2048, 1024)
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_concept() -> None:
manager = _manager()
log_dir = folder()
manager.update_config({"console_logging": False})
with manager._buffer_lock:
manager._log_buffer.clear()
for index, (time_text, level, message) in enumerate(CONCEPT_RECORDS):
record = _record(time_text, level, message, index)
manager.add_to_buffer(record, record.getMessage())
with open(log_dir / ACTIVE, "ab") as handle:
handle.write(b"x" * CONCEPT_ACTIVE)
for number, size in enumerate(CONCEPT_ROTATED, start=1):
moment = ROTATED_TIME - datetime.timedelta(hours=number)
_touch(log_dir / f"{ACTIVE}.{number}", size, moment)
def seed(data) -> None:
if data.get("mode") == "concept":
seed_concept()
return
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
@@ -3,11 +3,11 @@
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -51,8 +51,7 @@ def seed_library_editor(api: PromptManagerAPI, root: Path) -> dict:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
epoch = db.get_execution_epoch()
receipt = db.create_library_recipe(
EDITOR_POSITIVE,
+2 -2
View File
@@ -52,6 +52,7 @@ from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -225,8 +226,7 @@ def seed_review(
conn.execute(f"DELETE FROM {table}")
api.close_annotation_captures()
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
shutil.rmtree(root / "temp" / "review", ignore_errors=True)
output = root / "output"
files = []
+2 -2
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -98,8 +99,7 @@ def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> di
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
output = root / "output"
_png(output / FILES[0][0], (196, 140, 92))
_png(output / FILES[1][0], (150, 110, 80))
+2 -3
View File
@@ -5,13 +5,13 @@ from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from PIL import Image, ImageDraw
from PIL.PngImagePlugin import PngInfo
from utils.hashing import generate_prompt_hash
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -129,8 +129,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
):
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
for parent_index, (positive, negatives) in enumerate(zip(_POSITIVES, _NEGATIVES)):
category = ("Architecture", "Landscape", "Abstract")[parent_index % 3]
tags = ["geometric", ("warm", "cool")[parent_index % 2]]
@@ -7,10 +7,10 @@ in concept order, so tag creation order and category first use match the mock.
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from aiohttp import web
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
@@ -120,8 +120,7 @@ def seed_vocabulary(
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
replace_folder(root / name)
epoch = db.get_execution_epoch()
recipes = []
for recipe in CONCEPT_RECIPES:
+107
View File
@@ -0,0 +1,107 @@
{
"generation_params": [
"case:basic",
"case:shared-prompt-pipeline",
"tests/fixtures/inspection_parity/f02.png",
"tests/fixtures/inspection_parity/f03.png",
"tests/fixtures/inspection_parity/f06c.png",
"tests/fixtures/inspection_parity/f06e.png",
"tests/fixtures/inspection_parity/f07a.png",
"tests/fixtures/inspection_parity/f07b.png",
"tests/fixtures/inspection_parity/f08.png",
"tests/fixtures/inspection_parity/f09.png",
"tests/fixtures/inspection_parity/f10.png",
"tests/fixtures/inspection_parity/f11.png",
"tests/fixtures/inspection_parity/f15.png",
"tests/fixtures/inspection_parity/f16.png",
"tests/fixtures/inspection_parity/f23.png"
],
"inspection_parity": [
"f02",
"f03",
"f06c",
"f06e",
"f07a",
"f07b",
"f08",
"f09",
"f10",
"f11",
"f14",
"f15",
"f16",
"f23"
],
"local_resolver": [
"anchor-first-stage-no-latent",
"anchor-same-links",
"anchor-same-links-unanchored",
"built-chars",
"class-type-missing",
"class-type-number",
"combine-equal",
"controlnet-slot1",
"custom-cfg",
"duplicate-key",
"encode",
"int-id",
"latent-encode",
"overwritten-bad-workflow",
"pair-execution",
"pair-execution-bad-json",
"pair-execution-mismatch",
"pair-execution-negative-changed",
"pair-execution-other-node",
"pair-execution-truncated",
"pair-execution-two-records",
"pair-execution-version-float",
"pair-execution-version-true",
"pair-graph",
"pass-through-unknown-role-inputs",
"passthrough",
"prompt-and-parameters",
"prompt-manager",
"select-detailer-chain-same",
"select-detailer-no-sampler-upstream",
"select-detailer-output-walk-exhausted",
"select-detailer-same-prompt",
"select-edge-hires-fix-denoised-slot",
"select-edge-hires-fix-lineage-cycle",
"select-edge-hires-fix-two-refiners",
"select-flux-concat",
"select-flux-reroute-input",
"select-flux-three-stage",
"select-hires-fix",
"select-hires-fix-both-saved",
"select-hires-fix-reroute",
"select-hires-fix-unlisted",
"select-multistage-latent",
"select-preview-dropped",
"select-preview-through-bad-node",
"select-runtime-flux-other-full-prompt",
"select-runtime-flux-trigger",
"select-runtime-search-list-append",
"select-runtime-through-chained",
"select-runtime-through-concat",
"select-runtime-through-text-encode",
"select-runtime-traced-prepend",
"select-runtime-trigger-append-single",
"select-runtime-trigger-pair",
"select-runtime-trigger-prepend",
"select-three-identical-one-advanced",
"select-two-identical",
"select-unconsumed-stage-kept",
"several-samplers",
"smz-false",
"text-at-limit",
"unknown-encoder",
"workflow-type-dict",
"workflow-type-in-subgraph",
"workflow-type-list",
"workflow-type-without-id",
"zeroed"
],
"unknown_nodes": [
"unknown-role-inputs"
]
}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -40,7 +40,7 @@ const semantics = compileSemantics(JSON.parse(fs.readFileSync(path.join(here, '.
const { extractGenerationFacts } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
/** The local evidence for one set of record texts. */
const resolved = records => ({ evidence: resolveLocalEvidence(records, semantics) });
const resolved = (records, fileName = null) => ({ evidence: resolveLocalEvidence(records, semantics, fileName) });
/** The local wire's evidence and six facts for one read (no legacy fill, as in the vectors' own harness). */
function localPayload(read, file, classification) {
@@ -59,14 +59,14 @@ const output = {};
for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) {
const { name } = entry;
if (entry.mode === 'resolve') {
if (entry.records !== undefined) { output[name] = resolved(entry.records); continue; }
if (entry.records !== undefined) { output[name] = resolved(entry.records, entry.fileName ?? null); continue; }
const blob = await fs.openAsBlob(entry.path);
const { classification, result } = await readLocalFile(blob, { pngMetadata });
const read = classification === null ? null : toInspectionRead(classification, result).read;
if (classification === null) { output[name] = { unreadable: true }; continue; }
// The evidence is the local wire's own (buildLocalPayload), and it equals resolveLocalEvidence of the records.
const local = localPayload(result, { name: path.basename(entry.path), size: blob.size }, classification);
const direct = read === null ? unreadableEvidence() : resolved(read.records).evidence;
const direct = read === null ? unreadableEvidence() : resolved(read.records, path.basename(entry.path)).evidence;
if (JSON.stringify(direct) !== JSON.stringify(local.evidence)) throw new Error(`${name}: the wire's roles are not the resolver's`);
output[name] = read === null ? { unreadable: true, ...local } : local;
continue;
+3 -1
View File
@@ -155,7 +155,9 @@ test('committed server answers equal the local evidence', () => {
value !== null && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
let compared = 0;
for (const {name, records, evidence} of vectors) {
assert.deepEqual(canonical(resolver.resolveLocalEvidence(records, semantics)), canonical(evidence), name);
// A case may name the inspected file (`__file_name`, #381 saver anchoring); it is no record.
const {__file_name: fileName = null, ...roles} = records;
assert.deepEqual(canonical(resolver.resolveLocalEvidence(roles, semantics, fileName)), canonical(evidence), name);
compared++;
}
assert.ok(compared > 280, `compared ${compared}`);
+1 -1
View File
@@ -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' });
});
+9
View File
@@ -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 });
});
+35 -9
View File
@@ -360,19 +360,23 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
} finally { scope.dispose(); dom.restore(); }
});
const PLAN = { images: 3, prompts: 2, already_tagged: 1, to_tag: 1,
const PLAN = { images: 3, already_tagged: 1, to_tag: 2, estimate_seconds: 3.2, estimate_device: 'cpu', suggestion_limit: 10,
model: { type: 'wd14-swinv2', label: 'WD14 SwinV2', size_label: '~400 MB', downloaded: true }, existing: 'skip',
review_required: false, backup: { policy: 'on' }, disk: { free_bytes: 10 * 2 ** 30, required_bytes: 2 ** 20, sufficient: true } };
test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', async () => {
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_LINKED_IMAGES } = await tagging;
test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5)', async () => {
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_IMAGES } = await tagging;
assert.deepEqual(batchGrid(PLAN, HOST), [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
['Images linked', '3'], ['Prompts', '2'], ['Already tagged', '1'], ['Prompts to tag', 'up to 1'],
['Settings', 'General 0.35 · Character 0.85'], ['Backup', 'Create and verify before changes']]);
['Images', '3'], ['Already auto-tagged', '1'], ['Images to tag', 'about 2'],
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
const llm = { ...HOST, autotag_model_type: 'gguf' };
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' } }, llm).slice(-2),
[['Settings', 'Template of 27 characters'], ['Backup', 'No safety backup (the policy is off)']]);
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
assert.equal(existingLabel('odd'), 'odd');
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
@@ -385,9 +389,31 @@ test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', as
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false } }), 'WD14 SwinV2 is not downloaded. Download it first.');
assert.equal(batchCloseNote({ ...PLAN, disk: { free_bytes: 2 ** 20, required_bytes: 2 ** 21, sufficient: false } }),
'Not enough free disk space: the safety backup needs about 2.0 MB and 1.0 MB is free. Nothing is written until there is room.');
assert.equal(batchCloseNote({ ...PLAN, prompts: 0, to_tag: 0 }), NO_LINKED_IMAGES);
assert.equal(batchCloseNote({ ...PLAN, images: 0, to_tag: 0 }), NO_IMAGES);
assert.equal(batchCloseNote({ ...PLAN, images: undefined }), NO_IMAGES);
assert.equal(NO_IMAGES, 'The output folders contain no images to tag.');
assert.equal(batchCloseNote({ ...PLAN, to_tag: 0 }), NOTHING_TO_TAG);
assert.equal(NOTHING_TO_TAG, 'Every prompt with an output image is already tagged.');
assert.equal(NOTHING_TO_TAG, 'Every output image is already auto-tagged.');
});
test('the review Time row (DEC11, #379): minutes, hours and the engine it runs on', async () => {
const { durationText, timeText, TIME_UNKNOWN } = await tagging;
assert.equal(durationText(1), 'about 1 min');
assert.equal(durationText(0), 'about 1 min');
assert.equal(durationText(89), 'about 89 min');
assert.equal(durationText(90), 'about 1 h 30 min');
assert.equal(durationText(120), 'about 2 h');
assert.equal(durationText(631), 'about 10 h 31 min');
assert.equal(durationText(Number.NaN), 'about 1 min');
// 23,645 images at 1.6 s on a processor (the design's CUJ 1).
assert.equal(timeText({ estimate_seconds: 37832, estimate_device: 'cpu' }),
'about 10 h 31 min on a processor; a graphics card can be faster.');
assert.equal(timeText({ estimate_seconds: 59, estimate_device: 'gpu' }), 'about 1 min on the graphics card.');
assert.equal(timeText({ estimate_seconds: 0, estimate_device: 'cpu' }), 'about 1 min on a processor; a graphics card can be faster.');
for (const plan of [{ estimate_seconds: null }, {}, { estimate_seconds: -1 }, { estimate_seconds: Infinity }, null]) {
assert.equal(timeText(plan), TIME_UNKNOWN);
}
assert.equal(TIME_UNKNOWN, 'Language-model engines take longer per image. Activity shows the time left once tagging starts.');
});
test('showBatchPlan opens the review with the backup box, or the Cancel-only review', async () => {
+62 -2
View File
@@ -26,6 +26,8 @@ VECTORS_PATH = (
/ "local-inspect"
/ "resolver-vectors.json"
)
# A case may name the inspected file (#381 saver anchoring); it is no record.
FILE_NAME = "__file_name"
# Committed record sets stay small; the large bound cases are built at test time.
MAX_VECTOR_CHARS = 20_000
COMMITTED_FUZZ = (0, 60)
@@ -906,6 +908,60 @@ def golden_cases():
},
),
)
# #381: unknown nodes between the prompt and the sampler pass through.
try:
from tests import unknown_node_graphs
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import unknown_node_graphs
for name, graph in unknown_node_graphs.shapes().items():
add(f"pass-through-{name}", graph)
for name, records in {
**unknown_node_graphs.recorded_shapes(),
**unknown_node_graphs.review_shapes(),
}.items():
cases[f"pass-through-{name}"] = records
try:
from tests import saver_anchor_graphs as anchor
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
import saver_anchor_graphs as anchor
for name, graph, file_name in [
("branch-3", anchor.branch_bench(), "s45_branch_3_00002_.png"),
("branch-unanchored", anchor.branch_bench(), None),
("branch-no-match", anchor.branch_bench(), "photo_00001_.png"),
("branch-linked", anchor.linked_prefix_bench(), "s45_branch_2_00007_.png"),
(
"branch-unknown-saver",
anchor.branch_bench(saver="VendorSaveImage"),
"s45_branch_4_00001.webp",
),
("shared-prefix", anchor.shared_prefix(), "IMG_GEN_00371_.png"),
("same-links", anchor.two_stage_same_conditioning(), "final_00003_.png"),
("same-links-unanchored", anchor.two_stage_same_conditioning(), None),
(
"other-links",
anchor.two_stage_same_conditioning(["91", 0]),
"final_00003_.png",
),
("hidden-earlier", anchor.hidden_earlier_prompt(), "out_00001_.png"),
(
"hidden-override",
anchor.hidden_earlier_prompt(override=True),
"out_00001_.png",
),
] + [
(f"first-stage-{kind}", anchor.first_stage_variant(kind), "final_00003_.png")
for kind in (
"no-latent",
"literal-latent",
"missing-latent",
"cyclic-latent",
"stage-shaped",
"detailer",
)
]:
cases[f"anchor-{name}"] = {"prompt": _dump(graph)}
if file_name is not None:
cases[f"anchor-{name}"][FILE_NAME] = file_name
cases["parameters-only"] = {
"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Sampler: euler"
}
@@ -1059,7 +1115,8 @@ def boundary_cases():
["7", 0],
{
"\U0001f600"
* 70: {"class_type": long_name, "inputs": {"text": "x"}},
# No text and no links: still named, not passed through (#381).
* 70: {"class_type": long_name, "inputs": {"seed": 1}},
"7": _encode("b"),
},
)
@@ -1387,7 +1444,10 @@ def committed_vectors():
"name": name,
"records": records,
"evidence": inspect_prompt_evidence(
records, None, profile=INSPECTION_PROFILE
{key: value for key, value in records.items() if key != FILE_NAME},
None,
profile=INSPECTION_PROFILE,
file_name=records.get(FILE_NAME),
),
}
for name, records in cases.items()
+216
View File
@@ -0,0 +1,216 @@
"""Synthetic graphs for saver anchoring and shared-conditioning stages (#381).
``branch_bench`` reproduces the maintainer's ``gpu_unlock_bench`` run: four
KSampler branches with different prompts, each saved by its own SaveImage whose
literal prefix names the branch (``bench/run/s45_branch_N``). ``shared_prefix``
is the dataset run's shape: every branch's saver uses one prefix, so the file
name cannot name the branch. ``two_stage_same_conditioning`` reproduces the
ANIMA run: a hires stage whose latent passes an unlisted node and reads the
same positive and negative links as the first stage. Texts are neutral.
"""
BRANCH_TEXTS = (
"a knit beanie on a wooden table",
"an old fishing boat at dawn",
"a red vintage car in the rain",
"a cozy library with tall shelves",
)
NEGATIVE = "blurry, lowres"
def _encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["1", 1]}}
def _ksampler(positive, negative, latent, denoise=1.0):
return {
"class_type": "KSampler",
"inputs": {
"seed": 1,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": denoise,
"model": ["1", 0],
"positive": positive,
"negative": negative,
"latent_image": latent,
},
}
def _base():
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
"2": {"class_type": "EmptyLatentImage", "inputs": {"width": 64}},
"3": _encode(NEGATIVE),
}
def branch_bench(prefixes=None, saver="SaveImage"):
"""Four branches; ``prefixes`` replaces each saver's ``filename_prefix``."""
prefixes = prefixes or [
f"bench/run/s45_branch_{index}" for index in range(1, len(BRANCH_TEXTS) + 1)
]
graph = _base()
for index, text in enumerate(BRANCH_TEXTS, start=1):
base = index * 10
graph[str(base)] = _encode(text)
graph[str(base + 1)] = _ksampler([str(base), 0], ["3", 0], ["2", 0])
graph[str(base + 2)] = {
"class_type": "VAEDecode",
"inputs": {"samples": [str(base + 1), 0], "vae": ["1", 2]},
}
graph[str(base + 3)] = {
"class_type": saver,
"inputs": {
"filename_prefix": prefixes[index - 1],
"images": [str(base + 2), 0],
},
}
return graph
def linked_prefix_bench():
"""The branch run with each prefix coming from a PrimitiveString."""
graph = branch_bench(prefixes=[["90", 0], ["91", 0], ["92", 0], ["93", 0]])
for index in range(4):
graph[str(90 + index)] = {
"class_type": "PrimitiveString",
"inputs": {"value": f"bench/run/s45_branch_{index + 1}"},
}
return graph
def shared_prefix():
"""Every branch saved with one prefix: the file name names no branch."""
return branch_bench(prefixes=["dataset/IMG_GEN"] * len(BRANCH_TEXTS))
def two_stage_same_conditioning(second_positive=None):
"""Base and hires stage on the same links; the hires latent passes an
unlisted node (``VendorGpuCleaner``), so the stages' lineage is unknown."""
graph = _base()
graph["90"] = _encode(BRANCH_TEXTS[0])
graph["63"] = _ksampler(["90", 0], ["3", 0], ["2", 0])
graph["30"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["63", 0], "vae": ["1", 2]},
}
graph["98"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "draft", "images": ["30", 0]},
}
graph["110"] = {"class_type": "VendorGpuCleaner", "inputs": {"anything": ["63", 0]}}
graph["108"] = {
"class_type": "LatentUpscaleBy",
"inputs": {
"upscale_method": "nearest-exact",
"scale_by": 1.2,
"samples": ["110", 0],
},
}
graph["114"] = _ksampler(second_positive or ["90", 0], ["3", 0], ["108", 0], 0.5)
graph["113"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["114", 0], "vae": ["1", 2]},
}
graph["115"] = {
"class_type": "VendorGpuCleaner",
"inputs": {"anything": ["113", 0]},
}
graph["107"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "final", "images": ["115", 0]},
}
if second_positive is not None:
graph["91"] = _encode(BRANCH_TEXTS[1])
return graph
def hidden_earlier_prompt(override=False):
"""Review shape: the skipped same-links stage continues a different prompt.
10 (cat) -> 11 (dog) -> unlisted LatentBlend -> 13 (dog, same links as 11)
-> VAEDecode -> SaveImage ``out``. With ``override``, 11 is a
``KSampler (Efficient)`` with a linked ``script``.
"""
graph = _base()
graph["5"] = _encode("cat X")
graph["6"] = _encode("dog Y")
graph["10"] = _ksampler(["5", 0], ["3", 0], ["2", 0])
graph["11"] = _ksampler(["6", 0], ["3", 0], ["10", 0])
if override:
graph["11"] = _ksampler(["6", 0], ["3", 0], ["2", 0])
graph["11"]["class_type"] = "KSampler (Efficient)"
graph["11"]["inputs"]["script"] = ["40", 0]
graph["40"] = {"class_type": "XY Plot", "inputs": {"grid_spacing": 0}}
graph["12"] = {
"class_type": "LatentBlend",
"inputs": {"samples1": ["11", 0], "samples2": ["2", 0], "blend_factor": 0.5},
}
graph["13"] = _ksampler(["6", 0], ["3", 0], ["12", 0], 0.5)
graph["14"] = {
"class_type": "VAEDecode",
"inputs": {"samples": ["13", 0], "vae": ["1", 2]},
}
graph["15"] = {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "out", "images": ["14", 0]},
}
return graph
def deep_prefixes(chain):
"""Review shape: two branches whose savers both trace the prefix ``dup``.
Saver A's prefix runs through ``chain`` DisplayText nodes; saver B's
through one more, so a short visit allowance can finish only one trace.
"""
graph = branch_bench(prefixes=[["p0", 0], ["q", 0], "x_1", "x_2"])
graph["pz"] = {"class_type": "StringConstant", "inputs": {"string": "dup"}}
previous = "pz"
for index in range(chain - 1, -1, -1):
graph[f"p{index}"] = {
"class_type": "DisplayText",
"inputs": {"text": [previous, 0]},
}
previous = f"p{index}"
graph["q"] = {"class_type": "DisplayText", "inputs": {"text": ["p0", 0]}}
return graph
def first_stage_variant(kind):
"""``two_stage_same_conditioning`` whose first stage (63) is not plain.
``no-latent`` drops its latent input (still plain); ``literal-latent``,
``missing-latent``, ``cyclic-latent``, ``stage-shaped`` and ``detailer``
each make it something other than a plain first stage, so the later
stage stays unattributed.
"""
graph = two_stage_same_conditioning()
inputs = graph["63"]["inputs"]
if kind == "no-latent":
del inputs["latent_image"]
elif kind == "literal-latent":
inputs["latent_image"] = "latent"
elif kind == "missing-latent":
inputs["latent_image"] = ["999", 0]
elif kind == "cyclic-latent":
graph["70"] = {"class_type": "VendorLatentOp", "inputs": {"samples": ["63", 0]}}
inputs["latent_image"] = ["70", 0]
elif kind == "stage-shaped":
graph["70"] = {
"class_type": "VendorSampler",
"inputs": {
"model": ["1", 0],
"positive": ["90", 0],
"latent_image": ["2", 0],
},
}
inputs["latent_image"] = ["70", 0]
elif kind == "detailer":
graph["63"]["class_type"] = "FaceDetailer"
inputs["image"] = ["30", 0]
return graph
+3 -1
View File
@@ -35,6 +35,8 @@ REVIEW_TABLES = (
"media_review_items",
"media_review_metadata",
)
# The auto-tag marks (#379) cite annotation bytes too; an old backup lacks them.
CITING_TABLES = REVIEW_TABLES + ("media_image_autotags",)
# The exact DDL the plan pins; the module must produce precisely these objects.
DDL = {
METADATA: (
@@ -214,7 +216,7 @@ class AnnotationSchemaTests(unittest.TestCase):
"""
with closing(sqlite3.connect(path)) as raw, raw:
raw.execute("PRAGMA foreign_keys=OFF")
for table in REVIEW_TABLES + tuple(reversed(TABLES)):
for table in CITING_TABLES + tuple(reversed(TABLES)):
raw.execute(f"DROP TABLE {table}")
def test_fresh_install_and_reopen_is_idempotent(self):
+136
View File
@@ -561,6 +561,142 @@ class CurrentImageTagsTests(AnnotationTestCase):
)
class AutotagMarkTests(AnnotationTestCase):
"""The auto-tagged mark (#379): per byte record, written with the tags."""
def marked(self):
conn = self.db.model.get_connection()
return sorted(
row[0] for row in conn.execute("SELECT * FROM media_image_autotags")
)
def byte_id(self, loc, digest=DIGEST_A):
conn = self.db.model.get_connection()
return conn.execute(
"SELECT b.id FROM media_annotation_bytes b JOIN media_annotation_files f "
"ON f.id=b.file_id WHERE f.locator=? AND b.sha256=?",
(loc, digest),
).fetchone()[0]
def test_hand_tags_never_mark(self):
loc = self.locator("a.png")
self.replace(loc, tags=["keeper"])
self.add(loc, tags=["more"])
self.assertEqual(self.marked(), [])
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: False})
self.assertEqual(self.db.autotagged_locators(), set())
def test_model_union_marks_including_replayed_and_empty_results(self):
loc = self.locator("a.png")
receipt = self.add(loc, tags=["bird"], mark_autotagged=True)
self.assertEqual((receipt["added"], receipt["replayed"]), (1, False))
self.assertEqual(self.marked(), [self.byte_id(loc)])
# A replayed union and an empty result still mark (idempotently).
other = self.locator("b.png")
self.replace(other, DIGEST_B, ["keeper"])
replayed = self.add(other, DIGEST_B, ["KEEPER"], mark_autotagged=True)
self.assertTrue(replayed["replayed"])
empty = self.locator("c.png")
nothing = self.add(empty, tags=[], mark_autotagged=True)
self.assertEqual((nothing["tags"], nothing["added"]), ([], 0))
self.add(empty, tags=[], mark_autotagged=True)
self.assertEqual(
self.marked(),
sorted(
[self.byte_id(loc), self.byte_id(other, DIGEST_B), self.byte_id(empty)]
),
)
self.assertEqual(
self.db.autotag_state(
{loc: (SIZE, MTIME), other: (SIZE, MTIME), empty: (SIZE, MTIME)}
),
{loc: True, other: True, empty: True},
)
self.schema_clean()
def test_empty_union_without_mark_is_refused(self):
with self.assertRaises(ValueError):
self.add(self.locator("a.png"), tags=[])
self.assertEqual(self.counts()["media_annotation_files"], 0)
def test_tag_limit_union_writes_no_tag_and_no_mark(self):
loc = self.locator("a.png")
self.replace(loc, tags=[f"t{i}" for i in range(100)])
before = self.rows(*ANNOTATION_TABLES, "tags")
with self.assertRaises(AnnotationCommandError) as ctx:
self.add(loc, tags=["one more"], mark_autotagged=True)
self.assertEqual(ctx.exception.code, "tag-limit")
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
self.assertEqual(self.marked(), [])
def test_replace_marks_on_saved_and_replayed_receipts_not_on_conflict(self):
loc = self.locator("a.png")
saved = self.replace(loc, tags=["a"], mark_autotagged=True)
self.assertFalse(saved["replayed"])
self.assertEqual(self.marked(), [self.byte_id(loc)])
other = self.locator("b.png")
self.replace(other, DIGEST_B, ["x"])
replayed = self.replace(
other, DIGEST_B, ["x"], revision=1, mark_autotagged=True
)
self.assertTrue(replayed["replayed"])
third = self.locator("c.png")
self.replace(third, tags=["y"])
with self.assertRaises(AnnotationCommandError):
self.replace(third, tags=["z"], revision=5, mark_autotagged=True)
self.assertEqual(
self.marked(), sorted([self.byte_id(loc), self.byte_id(other, DIGEST_B)])
)
def test_mark_follows_the_bytes_not_the_file(self):
loc = self.locator("a.png")
self.add(loc, tags=["bird"], mark_autotagged=True)
# The file's bytes changed: the new record is current and unmarked.
self.replace(loc, DIGEST_B, ["hand"], mtime_ns=MTIME + 1)
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME + 1)}), {loc: False})
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: True})
# The review's estimate counts any marked record of the locator.
self.assertEqual(self.db.autotagged_locators(), {loc})
missing = self.locator("none.png")
self.assertEqual(self.db.autotag_state({missing: (1, 1)}), {missing: False})
def test_state_and_tags_choose_the_same_current_record(self):
loc = self.locator("a.png")
# Two records with the same verified fingerprint: the newest wins.
self.replace(loc, DIGEST_A, ["old bytes"])
with patch.object(
annotations_module, "utc_now_iso", return_value="2099-01-01T00:00:00"
):
self.add(loc, DIGEST_B, ["new bytes"], mark_autotagged=True)
stats = {loc: (SIZE, MTIME)}
self.assertEqual(self.db.current_image_tags(stats), {loc: ["new bytes"]})
self.assertEqual(self.db.autotag_state(stats), {loc: True})
with patch.object(
annotations_module, "utc_now_iso", return_value="2199-01-01T00:00:00"
):
self.add(loc, DIGEST_A, ["older bytes again"])
self.assertEqual(
self.db.current_image_tags(stats), {loc: ["old bytes", "older bytes again"]}
)
self.assertEqual(self.db.autotag_state(stats), {loc: False})
def test_marks_never_touch_prompt_rows(self):
conn = self.db.model.get_connection()
prompt_id = self.db.save_prompt("a beach", tags=["beach"])
before = [
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
for table in ("prompts", "prompt_tags")
]
self.add(self.locator("a.png"), tags=["bird"], mark_autotagged=True)
self.replace(self.locator("b.png"), DIGEST_B, ["waves"], mark_autotagged=True)
after = [
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
for table in ("prompts", "prompt_tags")
]
self.assertIsNotNone(prompt_id)
self.assertEqual(after, before)
class AnnotationIsolationTests(AnnotationTestCase):
def test_cascade_from_vocabulary_delete_keeps_byte_records(self):
loc = self.locator("a.png")
+9 -163
View File
@@ -1893,13 +1893,15 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
self.assertEqual(self.service.generate_calls, [])
self.assertEqual(self.service.load_calls, [])
async def test_preview_counts_skip_and_merge(self):
async def test_preview_counts_listed_still_images_not_prompts(self):
self._seed()
skip = await (await self._preview(existing="skip")).json()
# t, f1, f2 and m are listed; gone.png is missing, o.png is outside.
self.assertEqual(
{k: skip[k] for k in ("images", "prompts", "already_tagged", "to_tag")},
{"images": 6, "prompts": 5, "already_tagged": 1, "to_tag": 4},
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 4, "already_tagged": 0, "to_tag": 4},
)
self.assertNotIn("prompts", skip)
self.assertEqual(
skip["model"],
{
@@ -1916,7 +1918,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
set(skip["disk"]), {"free_bytes", "required_bytes", "sufficient"}
)
merge = await (await self._preview(existing="merge")).json()
self.assertEqual(merge["to_tag"], 5)
self.assertEqual(merge["to_tag"], 4)
leaked = [s for s in _strings(skip) if _absolute(s)]
self.assertEqual(leaked, [])
@@ -1924,7 +1926,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
self._seed()
data = await (await self._preview(existing="replace_after_review")).json()
self.assertTrue(data["review_required"])
self.assertEqual(data["prompts"], 5)
self.assertEqual(data["images"], 4)
async def test_preview_refuses_bad_bodies(self):
for body in (
@@ -1979,119 +1981,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
"WD14 ViT is not downloaded. Download it in System › Auto-tagging.",
)
async def test_job_writes_what_legacy_writes(self):
ids = self._seed()
await self._run_job(existing="skip")
native = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
# The same library through the legacy batch, in a fresh database.
for name, pid in ids.items():
self.api.db.update_prompt_metadata(
pid,
tags=(
["existing"]
if name == "tagged"
else (["auto-scanned"] if name == "marked" else [])
),
)
resp = await self.client.post(
"/prompt_manager/autotag/start?model_type=wd14-vit&skip_tagged=true"
)
self.assertEqual(self._sse_events(await resp.text())[-1]["type"], "complete")
legacy = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
self.assertEqual(native, legacy)
self.assertEqual(native["tagged"], ["existing"])
self.assertEqual(native["fresh"], ["1girl", "smile"])
async def test_job_outcomes_follow_the_mapping(self):
ids = self._seed()
self.api.db.update_prompt_metadata(
ids["marked"], tags=["auto-scanned", "1girl", "smile"]
)
def details(job):
return {
group: sorted(row["detail"] for row in rows)
for group, rows in job.outcomes.items()
}
merged = await self._run_job(existing="merge")
self.assertEqual(merged.state, "finished")
self.assertEqual(
details(merged),
{
"tagged": ["Added 2 tags", "Added 2 tags"],
"unchanged": ["No new tags"],
"skipped": [
"File not found",
"Outside the output folders",
"Prompt handled by another image",
],
"errors": [],
},
)
self.assertEqual(merged.status_line, "Tagged 2 prompts")
skipped = await self._run_job(existing="skip")
self.assertEqual(details(skipped)["tagged"], [])
self.assertEqual(details(skipped)["skipped"].count("Prompt already tagged"), 3)
self.assertEqual(skipped.status_line, "Tagged 0 prompts")
async def test_tagging_errors_are_fixed_strings(self):
self._seed()
self.service.generate_error = RuntimeError("/srv/secret/model exploded")
job = await self._run_job(existing="merge")
self.assertTrue(job.outcomes["errors"])
self.assertEqual(
{r["detail"] for r in job.outcomes["errors"]}, {"Tagging failed"}
)
self.assertNotIn("/srv/secret", json.dumps(job.report()))
async def test_job_progress_is_determinate(self):
self._seed()
seen = []
original = self.service.generate_tags
def generate(*args, **kwargs):
job = self.api._jobs.active("autotag")
seen.append((job.processed, job.total))
return original(*args, **kwargs)
self.service.generate_tags = generate
job = await self._run_job(existing="merge")
self.assertEqual((job.processed, job.total, job.progress), (6, 6, 100))
self.assertTrue(all(total == 6 for _p, total in seen[1:]), seen)
self.assertTrue(job.cancellable)
async def test_job_cancel_stops_before_the_next_image_and_keeps_writes(self):
ids = self._seed()
started = threading.Event()
proceed = threading.Event()
calls = []
def slow(image_path, **kwargs):
calls.append(image_path)
started.set()
proceed.wait(30)
return ["slow"]
self.service.generate_tags = slow
resp = await self._start_job(existing="merge")
job = self.api._jobs.get((await resp.json())["job"]["id"])
try:
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.assertTrue((await cancel.json())["cancellation_requested"])
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), 30)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Cancelled. Tags already added were kept.")
self.assertEqual(len(calls), 1)
tagged = [n for n, pid in ids.items() if "slow" in self._tags(pid)]
self.assertEqual(len(tagged), 1)
self.assertIsNone(model_gate.holder())
self.assertEqual(self.service.unload_calls, 0)
async def test_job_refused_while_legacy_batch_holds(self):
async with self.api._jobs.hold(
"autotag", "Tag output images (legacy page)", admission="records"
@@ -2103,41 +1992,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
self.assertEqual(data["running"]["title"], "Tag output images (legacy page)")
self.assertIsNone(model_gate.holder())
async def test_legacy_refused_while_job_runs(self):
self._seed()
started, proceed = threading.Event(), threading.Event()
def slow(image_path, **kwargs):
started.set()
proceed.wait(30)
return ["slow"]
self.service.generate_tags = slow
resp = await self._start_job(existing="merge")
job = self.api._jobs.get((await resp.json())["job"]["id"])
try:
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
legacy = await self.client.post("/prompt_manager/autotag/start")
# Both legacy pages read a stream: the refusal is one error frame
# that names the running job.
self.assertEqual(legacy.status, 200)
self.assertEqual(
self._sse_events(await legacy.text()),
[
{
"type": "error",
"status": "error",
"message": "Tag output images already running",
}
],
)
unload = await self.client.post("/prompt_manager/autotag/unload")
self.assertEqual((await unload.json())["operation"], "Tag output images")
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), 30)
async def test_job_releases_gate_when_start_is_refused(self):
class Running:
id = "other"
@@ -2185,7 +2039,7 @@ class TestTagOutputImagesJob(BatchJobTestCase):
("database folder", "the safety backup", 10, 1),
10,
)
with patch("py.api.autotag_routes.disk_plan", return_value=short):
with patch("py.api.image_tag_jobs.disk_plan", return_value=short):
resp = await self._start_job()
data = await resp.json()
self.assertEqual(resp.status, 507)
@@ -2203,14 +2057,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
inside = [row for row in rows if row["root_index"] == 0]
self.assertTrue(inside)
async def test_native_batch_leaves_custom_prompt_unchanged(self):
self._seed()
self.service.custom_prompt = "legacy page prompt"
await self._run_job(existing="merge", model_type="gguf")
self.assertEqual(self.service.custom_prompt, "legacy page prompt")
prompts = {call[1] for call in self.service.generate_calls}
self.assertEqual(prompts, {"Tags please."})
# The legacy batch's per-image frames over BatchJobTestCase's library, newest
# first, with every throttle check passing (pinned from main's generator).
@@ -2342,7 +2188,7 @@ class TestTagOutputImagesEdges(BatchJobTestCase):
async def test_unexpected_error_fails_the_job_with_a_reason(self):
self._seed()
with patch.object(
self.api.db, "autotag_batch_candidates", side_effect=[[], RuntimeError("x")]
self.api, "_autotag_prepass", side_effect=RuntimeError("/srv/x")
):
job = await self._run_job(existing="merge")
self.assertEqual(job.state, "failed")
+24
View File
@@ -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"))
+409
View File
@@ -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()
+419
View File
@@ -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
View File
@@ -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")
+3 -2
View File
@@ -190,7 +190,8 @@ class TestPromptInspector(ImageAPITestCase):
"inputs": {"positive": ["5", 0], "negative": ["2", 0]},
}
else:
source["1"]["class_type"] = "UnknownEncoder"
# No text and no links: nothing passes through it (#381).
source["1"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
png(
path,
prompt=source,
@@ -215,7 +216,7 @@ class TestPromptInspector(ImageAPITestCase):
result["prompts"]["negative"], self.side("", "embedded-parameters")
)
source = graph()
source["2"]["class_type"] = "UnknownEncoder"
source["2"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
png(path, prompt=source)
_, result = await self.inspect()
self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
+696
View File
@@ -0,0 +1,696 @@
"""Tag output images tags each image, never its prompt (#379, PR 1).
``POST /prompt_manager/jobs/autotag`` runs the ``image-tags`` loop over every
still image the output folders list, linked to a prompt or not: each image
gets its own model tags in its image tag set and is marked auto-tagged.
"Skip already tagged" skips images whose current bytes are marked, counted
before the loop without opening them. Prompt tags and prompt text never
change. No real model is loaded: the engine is a fake that answers per image.
"""
import asyncio
import json
import ntpath
import os
import posixpath
import re
import sqlite3
import threading
from pathlib import Path
from unittest.mock import patch
try:
from test_api_images import make_png
from test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
except ImportError:
from tests.test_api_images import make_png
from tests.test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
from database.execution_history_schema import rotate_history_epoch
from py import model_gate
from py.api import admin as admin_module
from py.api import image_tag_jobs as jobs_module
from py.config import PromptManagerConfig
from utils.hashing import generate_prompt_hash
JOB = "/prompt_manager/jobs/autotag"
PREVIEW = "/prompt_manager/autotag/batch/preview"
WAIT = 30.0
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
CANCELLED = "Cancelled. Tags already added were kept."
_DRIVE = re.compile(r"^[A-Za-z]:[\\/]")
class PerImageTagger(HookedTagger):
"""Answers each image from its own bytes: ``by_bytes`` maps exact file
contents to the names the "model" sees; any other image gets SCORED."""
def __init__(self):
super().__init__()
self.by_bytes = {}
def generate_tag_suggestions(self, image_path, **kwargs):
result = super().generate_tag_suggestions(image_path, **kwargs)
names = self.by_bytes.get(self.read_bytes[-1])
if names is None:
return result
return [(name, 0.9) for name in names][: kwargs["limit"]]
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)
def _absolute(text):
return posixpath.isabs(text) or ntpath.isabs(text) or bool(_DRIVE.match(text))
class BatchTestCase(ImageTagJobTestCase):
async def get_application(self):
app = await super().get_application()
self.tagger = PerImageTagger()
patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger)
patcher.start()
self.addCleanup(patcher.stop)
backup = PromptManagerConfig.SAFETY_BACKUP
self.addCleanup(setattr, PromptManagerConfig, "SAFETY_BACKUP", backup)
PromptManagerConfig.SAFETY_BACKUP = False
return app
# -- helpers ---------------------------------------------------------
def image(self, name, color, sees=None):
path = make_png(self.output_dir / name, size=(8, 6), color=color)
if sees is not None:
self.tagger.by_bytes[path.read_bytes()] = list(sees)
return path
async def relist(self):
"""Walk the output folder again (a cached listing is used as it is)."""
await self.api._listing(self.output_dir, fresh=True)
def prompt(self, text, tags=()):
return self.api.db.save_prompt(
text=text,
category=None,
tags=list(tags),
rating=None,
prompt_hash=generate_prompt_hash(text),
)
def link(self, prompt_id, path):
return self.api.db.link_image_to_prompt(prompt_id, str(path))
def prompt_rows(self):
conn = sqlite3.connect(self._temp_db.name)
try:
return {
table: conn.execute(f"SELECT * FROM {table} ORDER BY 1,2").fetchall()
for table in ("prompts", "prompt_tags")
}
finally:
conn.close()
def marked(self):
conn = sqlite3.connect(self._temp_db.name)
try:
return {
row[0]
for row in conn.execute(
"SELECT f.locator FROM media_image_autotags m JOIN "
"media_annotation_bytes b ON b.id=m.byte_id JOIN "
"media_annotation_files f ON f.id=b.file_id"
)
}
finally:
conn.close()
async def preview(self, existing="skip", model_type="wd14-vit"):
response = await self.client.post(
PREVIEW, json={"model_type": model_type, "existing": existing}
)
return response.status, await self.json_of(response)
async def start_batch(self, existing="skip", model_type="wd14-vit", options=None):
if options is None:
options = (
dict(WD14)
if model_type.startswith("wd14")
else {"instructions": "Tags please."}
)
response = await self.client.post(
JOB,
json={"model_type": model_type, "existing": existing, "options": options},
)
return response.status, await self.json_of(response)
async def run_batch(self, **kwargs):
status, data = await self.start_batch(**kwargs)
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
class TestPerImageWrites(BatchTestCase):
async def test_two_images_of_one_prompt_get_their_own_tags_and_no_prompt_changes(
self,
):
prompt_id = self.prompt("a girl at the beach", tags=["beach"])
a = self.image("a.png", (10, 20, 30), sees=["1girl", "bird"])
b = self.image("b.png", (40, 50, 60), sees=["1girl", "waves"])
self.link(prompt_id, a)
self.link(prompt_id, b)
before = self.prompt_rows()
job = await self.run_batch(existing="skip")
self.assertEqual(job.state, "finished", job.status_line)
self.assertEqual(self.stored("a.png")["tags"], ["1girl", "bird"])
self.assertEqual(self.stored("b.png")["tags"], ["1girl", "waves"])
self.assertEqual(self.prompt_rows(), before)
self.assertEqual(self.api.db.get_prompt_by_id(prompt_id)["tags"], ["beach"])
self.assertEqual(self.marked(), {self.locator("a.png"), self.locator("b.png")})
self.assertEqual(
job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0}
)
self.assertEqual(job.status_line, "Tagged 2 images")
self.assertEqual((job.processed, job.total, job.progress), (2, 2, 100))
self.assertEqual(job.scope["images"], 2)
# Inference read the private captures, one per image.
self.assertEqual(len(self.tagger.calls), 2)
async def test_unlinked_images_are_tagged_and_videos_are_not_targets(self):
self.image("free.png", (1, 2, 3), sees=["tree"])
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
job = await self.run_batch()
self.assertEqual(self.stored("free.png")["tags"], ["tree"])
self.assertEqual(job.total, 1)
self.assertEqual(job.status_line, "Tagged 1 image")
rows = [row["path"] for rows in job.outcomes.values() for row in rows]
self.assertNotIn("clip.mp4", rows)
async def test_hand_tags_do_not_block_a_skip_run(self):
path = self.image("c.png", (7, 7, 7), sees=["bird"])
self.saved_set("c.png", path.read_bytes(), ["keeper"])
job = await self.run_batch(existing="skip")
self.assertEqual(self.stored("c.png")["tags"], ["keeper", "bird"])
self.assertEqual(job.counters["tagged"], 1)
async def test_skip_counts_marked_images_without_opening_them_and_merge_retags(
self,
):
self.image("a.png", (1, 1, 1), sees=["bird"])
self.image("b.png", (2, 2, 2), sees=["cat"])
first = await self.run_batch()
self.assertEqual(first.counters["tagged"], 2)
self.tagger.calls.clear()
captures = []
store = self.api._capture_store()
real_capture = store.capture
def spy(*args, **kwargs):
captures.append(args)
return real_capture(*args, **kwargs)
with patch.object(store, "capture", side_effect=spy):
skipped = await self.run_batch(existing="skip")
self.assertEqual(captures, [])
self.assertEqual(self.tagger.calls, [])
self.assertEqual(
skipped.counters, {"tagged": 0, "unchanged": 0, "skipped": 2, "errors": 0}
)
self.assertEqual(
sorted(self.rows(skipped, "skipped")),
[("a.png", "Already auto-tagged"), ("b.png", "Already auto-tagged")],
)
self.assertFalse(any("Already auto-tagged" in line for line in skipped.log))
self.assertEqual((skipped.processed, skipped.total), (2, 2))
self.assertEqual(skipped.status_line, "Tagged 0 images")
self.tagger.by_bytes[(self.output_dir / "a.png").read_bytes()] = ["bird", "sky"]
merged = await self.run_batch(existing="merge")
self.assertEqual(len(self.tagger.calls), 2)
self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"])
self.assertEqual(self.rows(merged, "tagged"), [("a.png", "Added 1 tag")])
self.assertEqual(self.rows(merged, "unchanged"), [("b.png", "No new tags")])
async def test_changed_bytes_are_tagged_again(self):
self.image("a.png", (1, 1, 1), sees=["bird"])
await self.run_batch()
self.image("a.png", (9, 9, 9), sees=["cat"])
job = await self.run_batch(existing="skip")
self.assertEqual(self.rows(job, "tagged"), [("a.png", "Added 1 tag")])
self.assertEqual(self.stored("a.png")["tags"], ["cat"])
async def test_an_image_with_no_model_tags_is_marked_and_skipped_next_time(self):
self.image("plain.png", (3, 3, 3), sees=[])
job = await self.run_batch()
self.assertEqual(self.rows(job, "unchanged"), [("plain.png", "No new tags")])
self.assertEqual(self.marked(), {self.locator("plain.png")})
again = await self.run_batch()
self.assertEqual(
self.rows(again, "skipped"), [("plain.png", "Already auto-tagged")]
)
async def test_unsupported_still_is_marked_and_skipped_by_the_next_run(self):
(self.output_dir / "broken.png").write_bytes(b"not an image")
job = await self.run_batch()
self.assertEqual(
self.rows(job, "skipped"), [("broken.png", "Not a supported still image")]
)
self.assertEqual(self.marked(), {self.locator("broken.png")})
again = await self.run_batch()
self.assertEqual(
self.rows(again, "skipped"), [("broken.png", "Already auto-tagged")]
)
self.assertEqual(self.tagger.calls, [])
async def test_too_large_is_skipped_before_any_copy(self):
self.image("big.png", (5, 5, 5))
self.api._capture_store().max_file = 16
store = self.api._capture_store()
with patch.object(store, "capture", side_effect=AssertionError("copied")):
job = await self.run_batch()
self.assertEqual(
self.rows(job, "skipped"), [("big.png", "Too large to verify")]
)
self.assertEqual(self.marked(), set())
async def test_overlapping_roots_list_a_file_once(self):
self.image("sub/a.png", (4, 4, 4), sees=["bird"])
sub = self.output_dir / "sub"
self.api._get_all_output_dirs = lambda: [self.output_dir, sub]
job = await self.run_batch()
self.assertEqual(job.total, 1)
self.assertEqual(len(self.tagger.calls), 1)
self.assertEqual(self.rows(job, "tagged"), [("sub/a.png", "Added 1 tag")])
status, data = await self.preview()
self.assertEqual((status, data["images"]), (200, 1))
async def test_rows_log_and_status_carry_no_absolute_paths(self):
self.image("a.png", (1, 1, 1))
(self.output_dir / "broken.png").write_bytes(b"not an image")
job = await self.run_batch()
leaked = [s for s in _strings(job.report()) if _absolute(s)]
self.assertEqual(leaked, [])
self.assertNotIn(str(self.output_dir), json.dumps(job.report()))
class TestBatchLifecycle(BatchTestCase):
async def test_cancel_after_one_image_keeps_it_and_the_rerun_skips_it(self):
for n in range(3):
self.image(f"{n}.png", (n, n, n), sees=[f"t{n}"])
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
status, data = await self.start_batch()
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
try:
await self.wait_for(entered)
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.assertEqual(cancel.status, 200)
finally:
self.tagger.block = None
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, CANCELLED)
self.assertEqual(job.counters["tagged"], 1)
self.assertEqual(len(self.marked()), 1)
self.assertIsNone(model_gate.holder())
lines = []
real_update = type(job).update
def spy(self_job, **kwargs):
if kwargs.get("status_line"):
lines.append(kwargs["status_line"])
return real_update(self_job, **kwargs)
with patch.object(type(job), "update", spy):
rerun = await self.run_batch()
self.assertEqual(rerun.counters["skipped"], 1)
self.assertEqual(rerun.counters["tagged"], 2)
self.assertIn("Processing file 2 of 3", lines)
self.assertIn("Processing file 3 of 3", lines)
self.assertNotIn("Processing file 1 of 3", lines)
self.assertEqual((rerun.processed, rerun.total), (3, 3))
async def test_cancel_in_each_phase_writes_nothing(self):
self.image("a.png", (1, 1, 1))
for phase in (
"_safety_backup_step",
"_prepare_tagging_model",
"_autotag_targets",
):
with self.subTest(phase):
real = getattr(self.api, phase)
async def cancelling(*args, _real=real, **kwargs):
result = await _real(*args, **kwargs)
self.api._jobs.active("autotag").cancelled = True
return result
with patch.object(self.api, phase, side_effect=cancelling):
job = await self.run_batch()
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, CANCELLED)
self.assertEqual(self.marked(), set())
self.assertEqual(self.tagger.calls, [])
self.assertIsNone(model_gate.holder())
async def test_cancel_between_prepass_chunks_keeps_counts(self):
for n in range(3):
self.image(f"{n}.png", (n, n, n))
await self.run_batch()
seen = []
real = jobs_module._prepass_chunk
def chunk(db, items, skip_marked, max_file):
result = real(db, items, skip_marked, max_file)
job = self.api._jobs.active("autotag")
seen.append(job.processed)
job.cancelled = True # cancel after the first chunk
return result
with (
patch.object(jobs_module, "PREPASS_CHUNK", 1),
patch.object(jobs_module, "_prepass_chunk", side_effect=chunk),
):
job = await self.run_batch()
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.counters["skipped"], 1)
self.assertEqual((job.processed, job.total), (1, 3))
self.assertEqual(seen, [0])
async def test_the_job_ends_only_after_its_cleanup(self):
self.image("a.png", (1, 1, 1))
PromptManagerConfig.SAFETY_BACKUP = True
states = []
async def prune(job, _db):
states.append((job.state, model_gate.holder() is not None))
with (
patch("py.api.maintenance.take_safety_backup", return_value="db.backup_x"),
patch.object(self.api, "_prune_backup_pool", side_effect=prune),
):
job = await self.run_batch()
self.assertEqual(job.state, "finished")
self.assertEqual(states, [("running", True)])
self.assertIsNone(model_gate.holder())
async def test_prepass_publishes_its_progress(self):
for n in range(2):
self.image(f"{n}.png", (n, n, n))
await self.run_batch()
published = []
real = type(self.api._jobs).start
def start(registry, *args, **kwargs):
job = real(registry, *args, **kwargs)
original = job.publish
job.publish = lambda payload: (
published.append(dict(payload)),
original(payload),
)
return job
with patch.object(type(self.api._jobs), "start", start):
await self.run_batch()
self.assertIn({"type": "progress", "progress": 100}, published)
self.assertEqual(published[-1], {"type": "finished", "progress": 100})
async def test_restore_mid_run_fails_the_job(self):
self.image("a.png", (1, 1, 1))
self.image("b.png", (2, 2, 2))
def restore(_path):
conn = sqlite3.connect(self._temp_db.name)
try:
rotate_history_epoch(conn)
finally:
conn.close()
self.tagger.on_generate = restore
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "Prompt storage was restored; start the job again."
)
async def test_listing_error_fails_the_job(self):
self.image("a.png", (1, 1, 1))
with patch.object(
self.api, "_get_gallery_files", side_effect=OSError("/secret/out")
):
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "The output folders could not be listed. Try again."
)
self.assertNotIn("/secret", json.dumps(job.report()))
async def test_model_that_cannot_load_fails_the_job(self):
self.image("a.png", (1, 1, 1))
self.tagger.load_error = RuntimeError("no CUDA")
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "Auto-tagging failed. Check server logs for details."
)
self.assertIsNone(model_gate.holder())
async def test_zero_targets_finish_with_tagged_zero(self):
job = await self.run_batch()
self.assertEqual(job.state, "finished")
self.assertEqual(job.status_line, "Tagged 0 images")
self.assertEqual((job.processed, job.total), (0, 0))
async def test_runner_unloads_after_a_cancel_when_not_kept_in_memory(self):
self.image("a.png", (1, 1, 1))
owner = model_gate.new_owner("legacy-batch")
self.assertTrue(model_gate.try_acquire(owner))
run = jobs_module.AutotagRun(
model_type="wd14-vit",
existing="skip",
options={**WD14, "use_gpu": True, "suggestion_limit": 10},
keep_in_memory=False,
backup_on=False,
owner=owner,
)
from py.api.jobs import AUTOTAG_GROUP_NAMES, Job as RealJob
job = RealJob(
"autotag",
"Tag output images",
admission="records",
groups=AUTOTAG_GROUP_NAMES,
)
real_targets = self.api._autotag_targets
async def cancel_after_listing():
targets = await real_targets()
job.cancelled = True
return targets
with patch.object(
self.api, "_autotag_targets", side_effect=cancel_after_listing
):
await self.api._run_autotag_batch(run, job)
self.assertEqual(job.state, "cancelled")
self.assertEqual(self.tagger.unload_calls, 1)
self.assertIsNone(model_gate.holder())
async def test_time_left_uses_the_models_rate_only(self):
for n in range(4):
self.image(f"{n}.png", (n, n, n))
await self.run_batch()
for n in range(4, 8):
self.image(f"{n}.png", (n, n, n))
await self.relist()
calls = []
real = jobs_module._time_left
def spy(*args):
calls.append(args)
return real(*args)
with patch.object(jobs_module, "_time_left", side_effect=spy):
job = await self.run_batch()
self.assertEqual(job.counters["skipped"], 4)
# (done, total, batches, elapsed): over the 4 images the model runs on.
self.assertEqual(
[(c[0], c[1], c[2]) for c in calls], [(n, 4, n) for n in range(4)]
)
# The shared D56 text is unchanged.
self.assertEqual(admin_module._time_left(5, 10, 5, 300), " · about 5 min left")
class TestStartRefusals(BatchTestCase):
async def test_unavailable_model_and_bad_bodies(self):
self.tagger.downloaded["wd14-vit"] = False
status, data = await self.start_batch()
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
status, data = await self.start_batch(existing="replace_after_review")
self.assertEqual((status, data["code"]), (400, "review-required"))
status, _data = await self.start_batch(options={**WD14, "suggestion_limit": 3})
self.assertEqual(status, 400)
self.assertEqual(self.api._jobs.list(), [])
self.assertIsNone(model_gate.holder())
async def test_model_busy_when_the_gate_is_held(self):
owner = model_gate.new_owner("image-annotations")
self.assertTrue(model_gate.try_acquire(owner))
try:
status, data = await self.start_batch()
finally:
model_gate.release(owner)
self.assertEqual((status, data["code"]), (409, "model-busy"))
class TestBatchBeside(BatchTestCase):
async def test_legacy_batch_and_unload_are_refused_while_the_job_runs(self):
self.image("a.png", (1, 1, 1))
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
status, data = await self.start_batch()
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
try:
await self.wait_for(entered)
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
legacy = await self.client.post("/prompt_manager/autotag/start")
self.assertEqual(legacy.status, 200)
frames = [
json.loads(line[len("data: ") :])
for line in (await legacy.text()).splitlines()
if line.startswith("data: ")
]
self.assertEqual(
frames,
[
{
"type": "error",
"status": "error",
"message": "Tag output images already running",
}
],
)
unload = await self.client.post("/prompt_manager/autotag/unload")
self.assertEqual((await unload.json())["operation"], "Tag output images")
finally:
self.tagger.block = None
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "finished")
async def test_language_model_batch_passes_instructions_per_call(self):
self.image("a.png", (1, 1, 1))
job = await self.run_batch(model_type="gguf")
self.assertEqual(job.state, "finished", job.status_line)
self.assertEqual(self.tagger.calls[0]["prompt"], "Tags please.")
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
async def test_inference_errors_are_fixed_strings_and_not_marked(self):
self.image("a.png", (1, 1, 1))
def explode(_path):
raise RuntimeError("/srv/secret/model exploded")
self.tagger.on_generate = explode
job = await self.run_batch()
self.assertEqual(
self.rows(job, "errors"),
[("a.png", "Auto-tagging failed. Check server logs for details.")],
)
self.assertNotIn("/srv/secret", json.dumps(job.report()))
self.assertEqual(self.marked(), set())
class TestPreview(BatchTestCase):
async def test_preview_counts_images_and_estimates_time(self):
prompt_id = self.prompt("p")
a = self.image("a.png", (1, 1, 1))
self.image("b.png", (2, 2, 2))
self.image("c.png", (3, 3, 3))
self.link(prompt_id, a)
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
status, fresh = await self.preview()
self.assertEqual(status, 200, fresh)
self.assertEqual(
{k: fresh[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 3, "already_tagged": 0, "to_tag": 3},
)
self.assertNotIn("prompts", fresh)
self.assertEqual(fresh["suggestion_limit"], 10)
self.assertEqual(fresh["estimate_device"], "cpu")
self.assertAlmostEqual(fresh["estimate_seconds"], 3 * 1.6)
self.tagger.block = None
await self.run_batch()
self.image("d.png", (4, 4, 4))
await self.relist()
skip = (await self.preview("skip"))[1]
self.assertEqual(
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 4, "already_tagged": 3, "to_tag": 1},
)
merge = (await self.preview("merge"))[1]
self.assertEqual(merge["to_tag"], 4)
for model_type in ("gguf", "hf"):
llm = (await self.preview(model_type=model_type))[1]
self.assertIsNone(llm["estimate_seconds"])
self.assertIsNone(llm["estimate_device"])
self.assertEqual([s for s in _strings(skip) if _absolute(s)], [])
async def test_preview_uses_the_graphics_card_rate_when_wd14_runs_on_cuda(self):
self.image("a.png", (1, 1, 1))
self.tagger.wd14_device = lambda: "gpu"
data = (await self.preview())[1]
self.assertEqual(data["estimate_device"], "gpu")
from py.tagging_defaults import WD14_GPU_SECONDS_PER_IMAGE
self.assertAlmostEqual(data["estimate_seconds"], WD14_GPU_SECONDS_PER_IMAGE)
async def test_preview_writes_nothing_and_opens_no_media_file(self):
self.image("a.png", (1, 1, 1))
await self.run_batch()
self.image("b.png", (2, 2, 2))
await self.relist() # the listing, primed
files = [self._temp_db.name + suffix for suffix in ("", "-wal")]
before = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
media = str(self.output_dir)
real_open, real_stat = open, os.stat
def guarded_open(file, *args, **kwargs):
if str(file).startswith(media):
raise AssertionError(f"opened {file}")
return real_open(file, *args, **kwargs)
def guarded_stat(path, *args, **kwargs):
if str(path).startswith(media) and str(path) != media:
raise AssertionError(f"stat {path}")
return real_stat(path, *args, **kwargs)
with patch("builtins.open", guarded_open), patch("os.stat", guarded_stat):
status, data = await self.preview()
self.assertEqual(status, 200, data)
after = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
self.assertEqual(after, before)
self.assertEqual(data["to_tag"], 1)
self.assertEqual(self.tagger.calls[1:], [])
+26
View File
@@ -884,3 +884,29 @@ class TestImageConversion(EngineTestCase):
if __name__ == "__main__":
unittest.main()
class TestWd14Device(EngineTestCase):
"""Where WD14 runs, for the Tag output images estimate (#379)."""
def test_loaded_session_answers_from_its_first_provider(self):
cpu = FakeOnnxSession([0.5] * len(WD14_TAGS))
service = self._service(("wd14", (cpu, WD14_TAGS)))
_touch_model_files(self.models_dir, "wd14-vit")
service.load_model("wd14-vit")
self.assertEqual(service.wd14_device(), "cpu")
cpu.get_providers = lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"]
self.assertEqual(service.wd14_device(), "gpu")
def test_without_a_session_the_available_providers_answer(self):
service = self._service(None)
fake = types.SimpleNamespace(
get_available_providers=lambda: ["CUDAExecutionProvider"]
)
with patch.dict(sys.modules, {"onnxruntime": fake}):
self.assertEqual(service.wd14_device(), "gpu")
fake.get_available_providers = lambda: ["CPUExecutionProvider"]
with patch.dict(sys.modules, {"onnxruntime": fake}):
self.assertEqual(service.wd14_device(), "cpu")
with patch.dict(sys.modules, {"onnxruntime": None}):
self.assertEqual(service.wd14_device(), "cpu")
+318
View File
@@ -0,0 +1,318 @@
"""Auto-tagged marks schema group (#379): install, validation and restore."""
import hashlib
import re
import shutil
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
from database.models import PromptModel
try: # guarded so a missing module fails assertions, not test loading
from database.autotag_schema import (
AUTOTAG_TABLE,
autotag_schema_error,
autotag_without_annotations_error,
migrate_autotag_state,
)
except ImportError:
AUTOTAG_TABLE = None
autotag_schema_error = None
autotag_without_annotations_error = None
migrate_autotag_state = None
TABLE = "media_image_autotags"
DDL = (
f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE) WITHOUT ROWID"
)
ANNOTATION_TABLES = (
"media_annotation_metadata",
"media_annotation_files",
"media_annotation_bytes",
"media_image_tags",
)
REVIEW_TABLES = (
"media_review_metadata",
"media_review_items",
"media_review_decisions",
)
FILE_ID = "0f8e4b2a-1c3d-4e5f-8a9b-0c1d2e3f4a5b"
BYTE_ID = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
OTHER_ID = "7b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"
DIGEST = hashlib.sha256(b"img").hexdigest()
NOW = "2026-10-27T00:00:00.000000+00:00"
NEEDS_ANNOTATIONS = "Image auto-tag marks need the image annotation tables"
INVALID_VALUES = "Image auto-tag marks contain invalid values"
INVALID_SCHEMA = "Image auto-tag marks have an invalid schema"
def _compact(sql):
return re.sub(r"\s+", "", sql or "").lower()
def catalog(conn):
return {
(kind, name, _compact(sql))
for kind, name, sql in conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? OR tbl_name=?",
(TABLE, TABLE),
)
}
def marks(conn):
return [tuple(row) for row in conn.execute(f"SELECT byte_id FROM {TABLE}")]
def insert_byte(conn, file_id=FILE_ID, byte_id=BYTE_ID, locator="/out/img.png"):
conn.execute(
"INSERT INTO media_annotation_files VALUES(?,?,?)", (file_id, locator, NOW)
)
conn.execute(
"INSERT INTO media_annotation_bytes VALUES(?,?,?,?,?,?,?,0)",
(byte_id, file_id, DIGEST, 3, 1_700_000_000_000_000_000, NOW, NOW),
)
class AutotagSchemaTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.addCleanup(assert_closed, self, self.temp.name)
self.addCleanup(PromptModel.reset_schema_cache)
self.models = []
self.addCleanup(self.close_models)
self.path = str(Path(self.temp.name) / "live.db")
def close_models(self):
for model in self.models:
model.close_all()
def model(self, path=None):
model = PromptModel(path or self.path)
self.models.append(model)
return model
def require_module(self):
self.assertIsNotNone(
autotag_schema_error, "database.autotag_schema is not implemented"
)
def raw(self, path, *statements, foreign_keys=False):
with closing(sqlite3.connect(path)) as raw, raw:
raw.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}")
for statement in statements:
if isinstance(statement, tuple):
raw.execute(*statement)
else:
raw.execute(statement)
def closed_current(self, name="current.db", *, with_mark=True):
"""A closed, current database with one byte record (and its mark)."""
path = str(Path(self.temp.name) / name)
model = self.model(path)
with model.get_connection() as conn:
insert_byte(conn)
if with_mark:
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
model.close_all()
return path
def test_fresh_install_has_exactly_the_pinned_table(self):
self.require_module()
self.assertEqual(AUTOTAG_TABLE, TABLE)
model = self.model()
with model.get_connection() as conn:
# WITHOUT ROWID with a primary key: no sqlite_autoindex row.
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
self.assertEqual(
[tuple(row) for row in conn.execute(f"PRAGMA table_info({TABLE})")],
[(0, "byte_id", "TEXT", 1, None, 1)],
)
self.assertIsNone(autotag_schema_error(conn, allow_missing=False))
self.assertEqual(marks(conn), [])
self.assertEqual(
PromptModel.verify_database_file(self.path, require_library_state=True),
(True, "ok"),
)
def test_reopen_and_rerun_are_idempotent(self):
self.require_module()
path = self.closed_current()
for _ in range(2):
PromptModel.reset_schema_cache()
model = self.model(path)
with model.get_connection() as conn:
migrate_autotag_state(conn)
self.assertEqual(marks(conn), [(BYTE_ID,)])
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
model.close_all()
def test_old_database_without_the_table_gains_it_and_keeps_its_rows(self):
self.require_module()
path = self.closed_current(with_mark=False)
self.raw(path, f"DROP TABLE {TABLE}")
with closing(sqlite3.connect(path)) as raw:
before = raw.execute("SELECT * FROM media_annotation_bytes").fetchall()
self.assertIsNone(autotag_schema_error(raw))
self.assertEqual(
autotag_schema_error(raw, allow_missing=False),
"Missing image auto-tag marks schema",
)
self.assertTrue(PromptModel.verify_database_file(path)[0])
self.assertEqual(
PromptModel.verify_database_file(path, require_library_state=True),
(False, "Missing image auto-tag marks schema"),
)
PromptModel.reset_schema_cache()
model = self.model(path)
with model.get_connection() as conn:
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
self.assertEqual(
[
tuple(row)
for row in conn.execute("SELECT * FROM media_annotation_bytes")
],
before,
)
model.close_all()
def test_table_without_annotation_group_is_refused(self):
self.require_module()
path = self.closed_current(with_mark=False)
self.raw(
path,
*(
f"DROP TABLE {t}"
for t in REVIEW_TABLES + tuple(reversed(ANNOTATION_TABLES))
),
)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_without_annotations_error(raw), NEEDS_ANNOTATIONS)
for allow_missing in (True, False):
self.assertEqual(
autotag_schema_error(raw, allow_missing=allow_missing),
NEEDS_ANNOTATIONS,
)
self.assertEqual(
PromptModel.verify_database_file(path), (False, NEEDS_ANNOTATIONS)
)
PromptModel.reset_schema_cache()
with self.assertRaises(sqlite3.DatabaseError) as raised:
self.model(path)
self.assertIn(NEEDS_ANNOTATIONS, str(raised.exception))
def test_invalid_values_are_refused_without_repair(self):
self.require_module()
cases = {
"not-a-uuid": (f"INSERT INTO {TABLE} VALUES('not-a-uuid')",),
"upper-uuid": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID.upper(),)),),
"dangling": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID,)),),
}
for name, statements in cases.items():
with self.subTest(name):
path = self.closed_current(f"{name}.db", with_mark=False)
self.raw(path, *statements)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_VALUES)
before = marks(raw)
self.assertEqual(
PromptModel.verify_database_file(path), (False, INVALID_VALUES)
)
PromptModel.reset_schema_cache()
with self.assertRaises(sqlite3.DatabaseError):
self.model(path)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(marks(raw), before)
def test_altered_definitions_are_refused(self):
self.require_module()
cases = {
"rowid": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE)",
"restrict": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE RESTRICT) WITHOUT ROWID",
"extra column": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE, engine TEXT) "
"WITHOUT ROWID",
}
for name, ddl in cases.items():
with self.subTest(name):
path = self.closed_current(f"{name}.db", with_mark=False)
self.raw(path, f"DROP TABLE {TABLE}", ddl)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
self.assertFalse(PromptModel.verify_database_file(path)[0])
path = self.closed_current("index.db", with_mark=False)
self.raw(path, f"CREATE INDEX idx_extra ON {TABLE}(byte_id)")
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
def test_deleting_the_byte_record_cascades_to_its_mark(self):
self.require_module()
model = self.model()
with model.get_connection() as conn:
insert_byte(conn)
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
conn.execute(f"INSERT OR IGNORE INTO {TABLE} VALUES(?)", (BYTE_ID,))
self.assertEqual(marks(conn), [(BYTE_ID,)])
conn.execute("DELETE FROM media_annotation_files")
self.assertEqual(marks(conn), [])
def test_restore_of_a_pre_change_backup_migrates(self):
self.require_module()
src = self.closed_current("backup.db", with_mark=False)
self.raw(src, f"DROP TABLE {TABLE}")
live = self.model()
live.restore_from_file(src)
with closing(sqlite3.connect(self.path)) as raw:
self.assertEqual(catalog(raw), {("table", TABLE, _compact(DDL))})
self.assertEqual(
raw.execute("SELECT id FROM media_annotation_bytes").fetchall(),
[(BYTE_ID,)],
)
self.assertEqual(
PromptModel.verify_database_file(self.path, require_library_state=True),
(True, "ok"),
)
with closing(sqlite3.connect(src)) as raw: # the source is untouched
self.assertEqual(catalog(raw), set())
def test_install_failure_rolls_back(self):
self.require_module()
src = self.closed_current("copy.db", with_mark=False)
self.raw(src, f"DROP TABLE {TABLE}")
path = str(Path(self.temp.name) / "rollback.db")
shutil.copyfile(src, path)
with closing(sqlite3.connect(path)) as raw:
raw.execute("BEGIN")
raw.execute("CREATE TABLE marker(x)")
# An invalid group already present: nothing created, outer work kept.
raw.execute(f"CREATE TABLE {TABLE}(byte_id TEXT)")
with self.assertRaises(sqlite3.DatabaseError):
migrate_autotag_state(raw)
self.assertEqual(
raw.execute(
f"SELECT sql FROM sqlite_master WHERE name='{TABLE}'"
).fetchone()[0],
f"CREATE TABLE {TABLE}(byte_id TEXT)",
)
self.assertIsNotNone(
raw.execute(
"SELECT 1 FROM sqlite_master WHERE name='marker'"
).fetchone()
)
raw.rollback()
if __name__ == "__main__":
unittest.main()
+335
View File
@@ -0,0 +1,335 @@
"""Browser-test resets seed a scratch copy and publish it in one commit (#384)."""
import hashlib
import os
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import types
import unittest
from pathlib import Path
from unittest import mock
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
from database.operations import PromptDatabase
from tests.browser import fast_reset
from tests.browser.fast_reset import ScratchSeeder, table_digests
def digests(db: PromptDatabase) -> dict:
return table_digests(db.model.get_connection())
def add_prompt(db: PromptDatabase, text: str) -> int:
with db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO prompts (text, hash, created_at, updated_at) "
"VALUES (?, ?, '2026-01-01 00:00:00', '2026-01-01 00:00:00')",
(text, hashlib.sha256(text.encode()).hexdigest()),
).lastrowid
def add_image(db: PromptDatabase, prompt_id: int, name: str) -> int:
with db.model.get_connection() as conn:
return conn.execute(
"INSERT INTO generated_images (prompt_id, image_path, filename, "
"generation_time) VALUES (?, ?, ?, '2026-01-01 00:00:00')",
(prompt_id, f"/out/{name}", name),
).lastrowid
def reseed(api) -> str:
"""A seeder in the old style: clears its tables, then many small commits."""
db = api.db
with db.model.get_connection() as conn:
for table in ("generated_images", "prompt_tags", "tags", "prompts"):
conn.execute(f"DELETE FROM {table}")
for index in range(30):
prompt_id = add_prompt(db, f"seeded {index}")
add_image(db, prompt_id, f"seeded-{index}.png")
with db.model.get_connection() as conn:
tag_id = conn.execute(
"INSERT INTO tags (name) VALUES (?)", (f"tag{index}",)
).lastrowid
conn.execute(
"INSERT INTO prompt_tags (prompt_id, tag_id) VALUES (?, ?)",
(prompt_id, tag_id),
)
return "manifest"
class TestScratchSeeder(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.addCleanup(assert_closed, self, self.tmp.name)
self.live = self.database("live.db")
self.api = types.SimpleNamespace(db=self.live, marker="live api")
self.seeder = ScratchSeeder(self.api, self.root / "scratch", memory_root=None)
self.addCleanup(self.seeder.close)
# Rows from "earlier tests": AUTOINCREMENT positions have moved on.
for index in range(5):
add_image(self.live, add_prompt(self.live, f"old {index}"), f"o{index}")
with self.live.model.get_connection() as conn:
conn.execute("DELETE FROM prompts WHERE text = 'old 4'")
def database(self, name: str) -> PromptDatabase:
db = PromptDatabase(str(self.root / name))
self.addCleanup(db.close_all)
return db
def oracle(self, seed):
"""Run ``seed`` the old way, on a copy of the live database."""
path = self.root / "oracle.db"
target = sqlite3.connect(path)
try:
self.live.model.get_connection().backup(target)
finally:
target.close()
db = self.database("oracle.db")
seed(types.SimpleNamespace(db=db))
return digests(db)
def test_publishes_exactly_what_the_seed_writes_on_live(self):
expected = self.oracle(reseed)
self.assertEqual(self.seeder.run(reseed), "manifest")
self.assertEqual(digests(self.live), expected)
with self.live.model.get_connection() as conn:
first = conn.execute("SELECT MIN(id) FROM prompts").fetchone()[0]
self.assertEqual(first, 6, "AUTOINCREMENT continues as on live")
def test_runs_twice_from_the_state_the_first_run_left(self):
self.seeder.run(reseed)
expected = self.oracle(reseed)
self.seeder.run(reseed)
self.assertEqual(digests(self.live), expected)
def test_tables_the_seed_did_not_change_keep_their_rows(self):
before = digests(self.live)
def rename(api):
with api.db.model.get_connection() as conn:
conn.execute("UPDATE prompts SET text = 'renamed' WHERE id = 1")
self.seeder.run(rename)
after = digests(self.live)
changed = {table for table in after if after[table] != before[table]}
self.assertEqual(changed, {"prompts"})
with self.live.model.get_connection() as conn:
images = conn.execute("SELECT COUNT(*) FROM generated_images").fetchone()
# Replacing prompts' rows never cascades into generated_images.
self.assertEqual(images[0], 4)
def test_the_seed_sees_the_scratch_database_and_the_live_api(self):
seen = {}
def seed(api):
seen["db"] = api.db
seen["marker"] = api.marker
api.note = "set by the seed"
self.seeder.run(seed)
self.assertIsNot(seen["db"], self.live)
self.assertNotEqual(seen["db"].model.db_path, self.live.model.db_path)
self.assertEqual(seen["marker"], "live api")
self.assertEqual(self.api.note, "set by the seed")
self.assertIs(self.api.db, self.live)
def test_cache_drops_wait_until_the_rows_are_published(self):
seen = []
def invalidate_gallery_cache(retire=False):
with self.live.model.get_connection() as conn:
count = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
seen.append((count, retire))
self.api.invalidate_gallery_cache = invalidate_gallery_cache
def seed(api):
reseed(api)
api.invalidate_gallery_cache(retire=True)
self.assertEqual(seen, [], "not dropped while live has the old rows")
self.seeder.run(seed)
self.assertEqual(seen, [(30, True)])
def test_a_failing_seed_drops_no_cache(self):
seen = []
self.api.invalidate_gallery_cache = lambda: seen.append(True)
def broken(api):
api.invalidate_gallery_cache()
raise ValueError("seed failed")
with self.assertRaises(ValueError):
self.seeder.run(broken)
self.assertEqual(seen, [])
def test_a_failing_seed_leaves_live_untouched(self):
before = digests(self.live)
def broken(api):
reseed(api)
raise ValueError("seed failed")
with self.assertRaisesRegex(ValueError, "seed failed"):
self.seeder.run(broken)
self.assertEqual(digests(self.live), before)
self.assertIs(self.api.db, self.live)
def test_a_concurrent_live_commit_fails_the_reset(self):
other = self.database("live.db")
def racing(api):
reseed(api)
add_prompt(other, "written to live meanwhile")
with self.assertRaisesRegex(RuntimeError, "changed while"):
self.seeder.run(racing)
with self.live.model.get_connection() as conn:
texts = [row[0] for row in conn.execute("SELECT text FROM prompts")]
self.assertIn("written to live meanwhile", texts)
self.assertNotIn("seeded 0", texts)
def test_a_write_open_during_the_seed_and_committed_later_fails_it(self):
other = self.database("live.db")
began, finished = threading.Event(), []
def write():
with other.model.get_connection() as conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"INSERT INTO prompts (text, hash) VALUES ('concurrent', 'c')"
)
began.set()
time.sleep(0.5) # the publish waits on this lock meanwhile
other.model.close()
finished.append(True)
writer = threading.Thread(target=write)
def racing(api):
reseed(api)
writer.start()
self.assertTrue(began.wait(10))
with self.assertRaisesRegex(RuntimeError, "changed while"):
self.seeder.run(racing)
writer.join()
self.assertEqual(finished, [True])
with self.live.model.get_connection() as conn:
texts = [row[0] for row in conn.execute("SELECT text FROM prompts")]
self.assertIn("concurrent", texts)
self.assertNotIn("seeded 0", texts)
self.assert_live_connection_restored()
def test_a_failed_publish_leaves_live_and_its_connection_as_they_were(self):
before = digests(self.live)
columns = fast_reset._columns
def broken_columns(conn, table, *schema):
# The publish asks for the live table's columns without a schema.
return columns(conn, table, *schema) if schema else ["no_such_column"]
with mock.patch.object(fast_reset, "_columns", broken_columns):
with self.assertRaises(sqlite3.OperationalError):
self.seeder.run(reseed)
self.assertEqual(digests(self.live), before)
self.assert_live_connection_restored()
def assert_live_connection_restored(self):
conn = self.live.model.get_connection()
self.assertFalse(conn.in_transaction)
self.assertEqual(conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
names = [row[1] for row in conn.execute("PRAGMA database_list")]
self.assertEqual(names, ["main"])
def test_the_live_connection_is_left_as_it_was(self):
self.seeder.run(reseed)
conn = self.live.model.get_connection()
self.assertFalse(conn.in_transaction)
self.assertEqual(conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
names = [row[1] for row in conn.execute("PRAGMA database_list")]
self.assertEqual(names, ["main"])
self.assertEqual(conn.execute("PRAGMA foreign_key_check").fetchall(), [])
def test_on_live_mode_runs_the_seed_on_the_live_api(self):
seeder = ScratchSeeder(
self.api, self.root / "unused", on_live=True, memory_root=self.root
)
self.addCleanup(seeder.close)
expected = self.oracle(reseed)
seen = []
self.assertEqual(
seeder.run(lambda api: seen.append(api) or reseed(api)), "manifest"
)
self.assertIs(seen[0], self.api)
self.assertEqual(digests(self.live), expected)
self.assertFalse((self.root / "unused").exists())
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
expected = self.oracle(reseed)
seeder.run(reseed)
self.assertEqual(digests(self.live), expected)
self.assertEqual(len(list(memory.glob("pm-reset-*/seed.db"))), 1)
self.assertFalse((self.root / "fallback").exists())
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
full = mock.patch.object(
fast_reset.shutil,
"disk_usage",
return_value=types.SimpleNamespace(free=fast_reset.MEMORY_FREE_BYTES - 1),
)
with full:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
@unittest.skipUnless(os.name == "posix", "process probe is POSIX only")
def test_scratch_folders_of_dead_servers_are_removed(self):
memory = self.root / "memory"
memory.mkdir()
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
orphan = memory / f"pm-reset-{dead.pid}-abc"
alive = memory / f"pm-reset-{os.getpid()}-def"
other = memory / "pm-reset-notapid"
for folder in (orphan, alive, other):
folder.mkdir()
(folder / "seed.db").write_bytes(b"x")
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
seeder.run(reseed)
self.assertFalse(orphan.exists())
self.assertTrue(alive.exists())
self.assertTrue(other.exists())
own = [p for p in memory.glob(f"pm-reset-{os.getpid()}-*") if p != alive]
self.assertEqual(len(own), 1)
def test_close_releases_the_scratch_files(self):
self.seeder.run(reseed)
self.seeder.close()
assert_closed(self, self.root / "scratch")
self.seeder.close() # idempotent
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Browser-test media folders are emptied by a rename, deleted later (#384)."""
import tempfile
import threading
import unittest
from pathlib import Path
from tests.browser.folder_trash import TRASH_NAME, close_trash, replace_folder
class TestFolderTrash(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.addCleanup(close_trash, self.root)
def trash_threads(self):
return [t for t in threading.enumerate() if t.name == "pm-reset-trash"]
def test_replaces_the_folder_with_an_empty_one(self):
folder = self.root / "output"
(folder / "nested").mkdir(parents=True)
(folder / "nested" / "image.png").write_bytes(b"png")
replace_folder(folder)
self.assertTrue(folder.is_dir())
self.assertEqual(list(folder.iterdir()), [])
close_trash(self.root)
self.assertFalse((self.root / TRASH_NAME).exists())
self.assertEqual(self.trash_threads(), [])
def test_a_missing_folder_is_created_without_a_trash(self):
replace_folder(self.root / "input")
self.assertTrue((self.root / "input").is_dir())
self.assertFalse((self.root / TRASH_NAME).exists())
self.assertEqual(self.trash_threads(), [])
def test_repeated_replacements_share_one_thread_and_all_are_removed(self):
folder = self.root / "output"
for number in range(5):
folder.mkdir(exist_ok=True)
(folder / f"{number}.png").write_bytes(b"png")
replace_folder(folder)
self.assertEqual(len(self.trash_threads()), 1)
close_trash(self.root)
self.assertEqual(list(folder.iterdir()), [])
self.assertFalse((self.root / TRASH_NAME).exists())
close_trash(self.root) # idempotent
class TestFolderTrashAfterClose(unittest.TestCase):
def test_a_replacement_after_close_deletes_in_place_without_a_thread(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
close_trash(root)
folder = root / "output"
folder.mkdir()
(folder / "late.png").write_bytes(b"png")
replace_folder(folder)
self.assertEqual(list(folder.iterdir()), [])
self.assertFalse((root / TRASH_NAME).exists())
names = [t.name for t in threading.enumerate()]
self.assertNotIn("pm-reset-trash", names)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -221,12 +221,12 @@ class TestSlowResetOnTheServer(unittest.TestCase):
self.assertRegex(
text, r"kernel: asyncio_\d+ \(0x[0-9a-f]{16}\) state \w, waiting in "
)
# The plain reset's seed() waits on its first DELETE.
self.assertRegex(text, r"line \d+ in seed\n")
# The plain reset seeds a scratch copy (#384), then waits to publish it.
self.assertRegex(text, r"line \d+ in _publish\n")
self.assertIn("POST /__test__/reset answered 200 after", text)
self.assertRegex(
text,
r"executor call create_app.<locals>.seed: queued \d+\.\d+s, ran \d+\.\d+s",
r"executor call ScratchSeeder.run: queued \d+\.\d+s, ran \d+\.\d+s",
)
+1
View File
@@ -468,6 +468,7 @@ class TestRestoreFromFile(BackupTestCase):
def test_restore_old_backup_without_annotations(self):
tables = (
"media_image_autotags",
"media_review_decisions",
"media_review_items",
"media_review_metadata",
+26
View File
@@ -690,6 +690,32 @@ class TestFixRound(ImageTagJobTestCase):
],
)
async def test_an_ended_job_shows_no_time_left(self):
"""#379 live check: a finished or cancelled job never says "min left"."""
for index in range(3):
self.png(f"f{index}.png", color=(index, 7, 7))
clock = iter([0.0, 0.0, 120.0, 240.0])
with patch(
"py.api.image_tag_jobs._monotonic", side_effect=lambda: next(clock, 240.0)
):
job = await self.run_job(self.auto_body([f"f{i}.png" for i in range(3)]))
self.assertEqual(job.state, "finished")
self.assertEqual(job.status_line, "Auto-tag finished · 3 files processed")
self.png("g0.png", color=(1, 1, 1))
self.png("g1.png", color=(2, 2, 2))
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
job = await self.start_ok(self.auto_body(["g0.png", "g1.png"]))
await self.wait_for(entered)
await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.tagger.block = None
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Auto-tag cancelled · 1 file processed")
self.assertNotIn("left", job.status_line)
def maximal_target(self, number):
"""A distinct target whose path is 1024 non-ASCII code points long."""
head = f"{number:03d}/" + ("界" * 200 + "/") * 5
+13
View File
@@ -59,6 +59,7 @@ def _minimal():
{"class": "VAEEncode", "slot": 0, "strategy": "continue", "input": "pixels"}
],
"stage_inputs": {"shaped": ["positive"], "with_model": ["samples"]},
"pass_through": {"skip_inputs": ["clip"]},
"facts": {
"configuration_outputs": {},
"model_outputs": {},
@@ -690,6 +691,18 @@ class ValidationTest(_TableCase):
(lambda d: d["stage_inputs"].update(shaped="pipe"), "input name"),
(lambda d: d["stage_inputs"].update(with_model=["positive"]), "both"),
(lambda d: d["stage_inputs"].update(shaped=["model"]), "model"),
(lambda d: d.pop("pass_through"), "category"),
(lambda d: d["pass_through"].update(extra=["x"]), "pass_through"),
(lambda d: d["pass_through"].update(skip_inputs="clip"), "input name"),
(lambda d: d["pass_through"].update(skip_inputs=[]), "input name"),
(
lambda d: d["pass_through"].update(skip_inputs=["clip", "clip"]),
"repeated",
),
(
lambda d: d["pass_through"].update(skip_inputs=["conditioning"]),
"carries text",
),
(
lambda d: d["facts"]["configuration_outputs"].update(
X={"kind": "sigmas", "fields": ["steps"], "type_fields": []}
+187
View File
@@ -0,0 +1,187 @@
"""Legacy parity gate for the inspection resolver (#381, the PR #170 floor).
The legacy parser (``utils.generation_params``, the port of the browser's
``extractGenerationParams``) is the floor: a graph whose positive prompt it
found, and that the inspection resolver resolved to the same text, must keep
resolving to that text. ``legacy-agreement.json`` records those graphs per
corpus: the generation-params vectors, the inspection parity fixtures, the
local resolver golden and selection cases, and the unknown-node shapes of
#381. A graph may join the list, never leave it; regenerate with::
python3 -m tests.test_legacy_parity_gate --write
"""
import argparse
import json
import unittest
from pathlib import Path
from utils import prompt_pair_resolver as resolver
from utils.generation_params import (
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
try:
from tests import inspection_parity as parity
from tests import local_resolver_cases as cases
from tests import local_resolver_selection_cases as selection
from tests import unknown_node_graphs as unknown
except ImportError: # discovered with ``-s tests``
import inspection_parity as parity
import local_resolver_cases as cases
import local_resolver_selection_cases as selection
import unknown_node_graphs as unknown
HERE = Path(__file__).resolve().parent
AGREEMENT_PATH = HERE / "fixtures" / "local-inspect" / "legacy-agreement.json"
VECTORS_PATH = HERE / "fixtures" / "generation_params" / "vectors.json"
NOT_FOUND = "No prompt found"
def _parsed(value):
return parse_js_json(value) if isinstance(value, str) else value
def corpora() -> dict:
"""Corpus name to ``{case name: (prompt graph, workflow or None[, file])}``."""
vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))
named = {**cases.golden_cases(), **selection.selection_cases()}
return {
"generation_params": {
item["name"]: (_parsed(item["prompt"]), _parsed(item.get("workflow")))
for item in vectors
},
"inspection_parity": {
name: (parity.build(name)["prompt"], parity.build(name)["workflow"])
for name in parity.COMMITTED
},
"local_resolver": {
name: (
_parsed(records["prompt"]),
_parsed(records.get("workflow")),
records.get(cases.FILE_NAME),
)
for name, records in named.items()
if "prompt" in records
},
"unknown_nodes": {
name: (graph, None) for name, graph in unknown.shapes().items()
},
}
def legacy_positive(graph, workflow):
"""The positive prompt the legacy parser found, or ``None``."""
try:
found = extract_generation_params({"prompt": graph, "workflow": workflow})
except GenerationParamsError:
return None
text = found["positivePrompt"]
return None if text == NOT_FOUND else text
def inspected(graph, workflow=None, file_name=None) -> dict:
"""The inspection resolver's answer, anchored by ``file_name`` when given."""
metadata = {"prompt": graph}
if workflow is not None:
metadata["workflow"] = workflow
return resolver.resolve_prompt_pair(
metadata, profile=resolver.INSPECTION_PROFILE, file_name=file_name
)
# Graphs where the legacy parser's positive is wrong and the inspector's
# differs on purpose: (corpus, case) -> (the inspector's positive, why).
LEGACY_WRONG = {
("local_resolver", "anchor-branch-3"): (
"a red vintage car in the rain",
"legacy shows the first sampler's prompt for every branch; the file "
"name s45_branch_3_00002_ names branch 3's saver (gpu_unlock_bench)",
),
("local_resolver", "anchor-branch-linked"): (
"an old fishing boat at dawn",
"legacy shows branch 1; the traced prefix names branch 2's saver",
),
("local_resolver", "anchor-branch-unknown-saver"): (
"a cozy library with tall shelves",
"legacy shows branch 1; the file name names branch 4's saver",
),
}
def agreeing(graphs: dict) -> list:
"""Names whose inspection positive equals the legacy parser's, sorted."""
names = []
for name, case in graphs.items():
graph, workflow = case[0], case[1]
if not isinstance(graph, dict):
continue
expected = legacy_positive(graph, workflow)
if expected is None:
continue
result = inspected(*case)
positive = result["positive"]
if (
result["state"] == "resolved"
and isinstance(positive, dict)
and positive["text"] == expected
):
names.append(name)
return sorted(names)
class LegacyParityGateTest(unittest.TestCase):
def test_every_legacy_difference_is_stated(self):
"""A graph may read differently from legacy only with a stated reason."""
all_graphs = corpora()
for (corpus, name), (expected, why) in LEGACY_WRONG.items():
with self.subTest(case=name, why=why):
case = all_graphs[corpus][name]
legacy = legacy_positive(case[0], case[1])
self.assertIsNotNone(legacy)
result = inspected(*case)
positive = result["positive"]
text = positive["text"] if isinstance(positive, dict) else None
if result["state"] != "resolved":
text = None
self.assertEqual(text, expected)
self.assertNotEqual(text, legacy)
def test_no_graph_leaves_the_legacy_agreement(self):
recorded = json.loads(AGREEMENT_PATH.read_text(encoding="utf-8"))
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
self.assertEqual(set(recorded), set(current))
for corpus, names in recorded.items():
with self.subTest(corpus=corpus):
lost = sorted(set(names) - set(current[corpus]))
self.assertEqual(lost, [], "these graphs no longer match legacy")
def test_every_unknown_node_shape_legacy_reads_still_agrees(self):
graphs = corpora()["unknown_nodes"]
legacy = {name for name, pair in graphs.items() if legacy_positive(*pair)}
self.assertLessEqual(legacy, set(agreeing(graphs)))
def test_the_reported_graph_reads_better_than_legacy(self):
# Legacy found no positive here (cf0cef3 showed the positive text as
# the negative); the floor is met, and the inspector now reads it.
graph = unknown.any_switch_graph()
self.assertIsNone(legacy_positive(graph, None))
result = resolver.resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE
)
self.assertEqual(result["positive"]["text"], unknown.POSITIVE)
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--write", action="store_true", help="record the agreement")
if parser.parse_args().write:
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
text = json.dumps(current, indent=1, sort_keys=True, ensure_ascii=False)
AGREEMENT_PATH.write_text(text + "\n", encoding="utf-8", newline="\n")
if __name__ == "__main__":
main()
+21 -6
View File
@@ -1118,8 +1118,17 @@ class LocalProjectionParityTests(unittest.TestCase):
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
def _server_evidence(records):
return inspect_prompt_evidence(records, None, profile=INSPECTION_PROFILE)
def _server_evidence(records, file_name=None):
return inspect_prompt_evidence(
records, None, profile=INSPECTION_PROFILE, file_name=file_name
)
FILE_NAME = resolver_cases.FILE_NAME
def _records(case):
return {key: value for key, value in case.items() if key != FILE_NAME}
class LocalResolverParityTests(unittest.TestCase):
@@ -1143,7 +1152,12 @@ class LocalResolverParityTests(unittest.TestCase):
observed = _run_oracle(
self,
[
{"name": name, "mode": "resolve", "records": records}
{
"name": name,
"mode": "resolve",
"records": _records(records),
"fileName": records.get(FILE_NAME),
}
for name, records in cases.items()
],
)
@@ -1151,7 +1165,7 @@ class LocalResolverParityTests(unittest.TestCase):
for name, records in cases.items():
with self.subTest(case=name):
local = observed[name]
server = _server_evidence(records)
server = _server_evidence(_records(records), records.get(FILE_NAME))
answers[name] = server
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
@@ -1190,7 +1204,7 @@ class LocalResolverParityTests(unittest.TestCase):
# The single-chain subset (Task 8); the rest is Task 9's test below.
if not resolver_cases.single_chain(records):
continue
server = _server_evidence(records)
server = _server_evidence(records, Path(path).name)
self.assertEqual(
_canonical_bytes(local["evidence"]), _canonical_bytes(server)
)
@@ -1237,7 +1251,8 @@ class LocalResolverParityTests(unittest.TestCase):
local, expect = observed[name], vector["expect"]
try:
server = _server_evidence(
read_inspection_file(files[name]).metadata
read_inspection_file(files[name]).metadata,
Path(files[name]).name,
)
except MetadataReadError:
server = unreadable_evidence()
+21 -15
View File
@@ -1019,7 +1019,8 @@ class TestResolverProfiles(unittest.TestCase):
def test_unsupported_node_names_the_blocking_node(self):
source = graph()
source["1"]["class_type"] = "UnknownEncoder"
# No text and no links: nothing passes through it (#381).
source["1"] = {"class_type": "UnknownEncoder", "inputs": {"seed": 1}}
result = resolve_prompt_pair(source, profile=resolver.INSPECTION_PROFILE)
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "1", "class_type": "UnknownEncoder"})
@@ -1161,14 +1162,9 @@ class TestGuiderSamplers(unittest.TestCase):
def test_unsupported_node_names_first_blocking_node(self):
source = _guided()
source["30"] = {
"class_type": "FutureConditioner",
"inputs": {"conditioning": ["6", 0]},
}
source["31"] = {
"class_type": "OtherConditioner",
"inputs": {"conditioning": ["7", 0]},
}
# No links: nothing passes through them (#381).
source["30"] = {"class_type": "FutureConditioner", "inputs": {"seed": 1}}
source["31"] = {"class_type": "OtherConditioner", "inputs": {"seed": 2}}
source["21"]["inputs"]["positive"] = ["30", 0]
source["21"]["inputs"]["negative"] = ["31", 0]
result = _inspect(source)
@@ -1548,7 +1544,8 @@ class TestConditioningSemantics(unittest.TestCase):
def test_diverging_branch_that_cannot_be_traced_is_never_guessed(self):
extra = {
"10": _encoded(parity.POS),
"11": {"class_type": "FutureEncoder", "inputs": {"text": WOLF}},
# No text and no links: nothing passes through it (#381).
"11": {"class_type": "FutureEncoder", "inputs": {"strength": 1.0}},
"12": _image_exists(),
}
source = _with_positive(_if_else(["12", 1], ["10", 0], ["11", 0]), extra)
@@ -2048,12 +2045,17 @@ class TestEncoderAndStringSemantics(unittest.TestCase):
result = _inspect(_with_positive(node))
self.assertEqual(result["reason_code"], "malformed")
def test_unverified_text_nodes_stay_unsupported_and_named(self):
# TextBoxMira was verified in its source in Task 2.7b and is listed.
def test_unknown_text_nodes_pass_their_text_through(self):
# #381: an unlisted node's ``text`` input is its text (the legacy rule);
# without one, and with nothing linked, it is still named.
for name in ("LoRAfromText", "RegexReplace", "XYZPrompt"):
with self.subTest(node=name):
node = {"class_type": name, "inputs": {"text": parity.POS}}
result = _inspect(_string_positive(node))
self.assertEqual(result["state"], "resolved")
self.assertEqual(result["positive"]["text"], parity.POS)
node = {"class_type": name, "inputs": {"pattern": parity.POS}}
result = _inspect(_string_positive(node))
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "10", "class_type": name})
@@ -2313,7 +2315,8 @@ class TestDetailerStages(unittest.TestCase):
def test_unresolved_stage_blocks_the_chain(self):
source = _detailed(same_prompt=True)
source["30"] = {"class_type": "FutureConditioner", "inputs": {"c": ["6", 0]}}
# No links: nothing passes through it (#381).
source["30"] = {"class_type": "FutureConditioner", "inputs": {"c": 1.0}}
source["3"]["inputs"]["positive"] = ["30", 0]
source["50"]["inputs"]["positive"] = ["10", 0]
result = _inspect(source)
@@ -2662,11 +2665,13 @@ class TestLatentAncestry(unittest.TestCase):
},
{
"50": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
# Its own encoder (#381: the same links would be one prompt).
"73": parity._enc(parity.POS),
"70": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["6", 0],
"positive": ["73", 0],
"negative": ["7", 0],
"latent_image": ["72", 0],
},
@@ -2969,7 +2974,8 @@ class TestLatentAncestry(unittest.TestCase):
source["60"]["class_type"] = "UltimateSDUpscale"
result = _inspect(source, output_node_id="9")
self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3"))
# Reached only through an unlisted image node, it stays unattributed.
# Reached only through an unlisted image node, it stays unattributed:
# a detailer is never a plain first stage, even on the same links (#381).
source["61"] = {"class_type": "MysteryFilter", "inputs": {"image": ["60", 0]}}
source["5"]["inputs"]["pixels"] = ["61", 0]
result = _inspect(source, output_node_id="9")
+391
View File
@@ -0,0 +1,391 @@
"""Unknown nodes between the prompt and the sampler pass through (#381).
The inspection resolver no longer needs a node class on an allow-list to read
through it. An output the semantics table does not describe passes through: a
``text`` input is the text, a ``positive``/``negative`` input routes by output
slot, a ``conditioning`` input is followed alone, otherwise every linked input
except core type inputs (``pass_through.skip_inputs``) is followed. One text,
or several equal texts, resolve; PromptManager nodes are authoritative; several
different texts are one candidate each. Ingestion (the default profile) is
unchanged. Every graph here is synthetic, with neutral texts.
"""
import json
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import resolve_prompt_pair
try:
from tests import unknown_node_graphs as graphs
except ImportError: # discovered with ``-s tests``
import unknown_node_graphs as graphs
POSITIVE, OTHER, NEGATIVE = graphs.POSITIVE, graphs.OTHER, graphs.NEGATIVE
any_switch_graph = graphs.any_switch_graph
_encode, _pair, _with = graphs.encode, graphs.pair, graphs.with_nodes
def _inspect(graph, **options):
return resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, **options
)
def _texts(result):
return tuple(
result[role]["text"] if isinstance(result[role], dict) else None
for role in ("positive", "negative")
)
class TestTheReportedGraph(unittest.TestCase):
def test_prompt_manager_pair_through_any_switch_resolves(self):
result = _inspect(any_switch_graph())
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result), (POSITIVE, ""))
self.assertEqual(result["sampler_id"], "17")
self.assertIsNone(result["reason_code"])
path = [step["node_id"] for step in result["positive"]["provenance"]["path"]]
self.assertEqual(path, ["15", "10"])
def test_the_inspector_shows_the_prompt(self):
evidence = inspect_prompt_evidence({"prompt": json.dumps(any_switch_graph())})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["status"], "resolved")
self.assertEqual(evidence["prompts"]["positive"]["text"], POSITIVE)
def test_ingestion_is_unchanged(self):
result = resolve_prompt_pair({"prompt": any_switch_graph()})
self.assertEqual(result["state"], "unresolved")
# Its first failure is the custom latent node, as before this change.
self.assertEqual(result["reason"], "Unsupported latent ancestry")
def test_the_inspection_version_moves(self):
self.assertEqual(resolver.INSPECTION_RESOLVER_VERSION, 2)
self.assertEqual(resolver.RESOLVER_VERSION, 1)
class TestSourcesThroughUnknownNodes(unittest.TestCase):
def test_several_inputs_with_one_text_resolve(self):
graph = any_switch_graph(any_01=["10", 0], any_02=["10", 0])
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_equal_texts_from_different_nodes_resolve(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": _encode(OTHER), "21": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], OTHER)
def test_different_texts_are_candidates_not_a_refusal(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": _encode(POSITIVE), "21": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(result["reason"], resolver.SEVERAL_SOURCES_REASON)
self.assertEqual(
[_texts(item) for item in result["candidates"]],
[(POSITIVE, ""), (OTHER, "")],
)
for item in result["candidates"]:
self.assertEqual(item["state"], "resolved")
self.assertEqual(item["sampler_id"], "17")
def test_prompt_manager_is_authoritative(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
**{"20": _encode(OTHER)},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result), (POSITIVE, ""))
def test_prompt_manager_wins_over_an_unreadable_branch(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
**{
"20": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
},
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_an_unreadable_branch_blocks_without_prompt_manager(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["22", 0]),
**{
"20": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
},
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
"22": _encode(OTHER),
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
def test_chained_unknown_nodes(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "Router Node (vendor)",
"inputs": {"in": ["10", 0]},
}
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_no_text_names_the_unknown_node(self):
graph = any_switch_graph(any_01=["7", 0])
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(
result["node"], {"id": "15", "class_type": "Any Switch (rgthree)"}
)
def test_another_sampler_is_never_entered(self):
graph = any_switch_graph(any_01=["17", 0], any_02=["10", 0])
graph["30"] = {
"class_type": "KSampler",
"inputs": {**graph["17"]["inputs"], "positive": ["31", 0]},
}
graph["31"] = _encode(OTHER)
graph["15"]["inputs"]["any_01"] = ["30", 0]
result = _inspect(graph)
found = {item["sampler_id"]: _texts(item) for item in result["candidates"]}
self.assertEqual(found["17"], (POSITIVE, ""))
class TestLegacyShapedRules(unittest.TestCase):
def test_a_text_input_is_the_text(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorTextEncode",
"inputs": {"text": OTHER, "clip": ["9", 1]},
}
},
)
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
def test_role_inputs_route_by_output_slot(self):
graph = any_switch_graph()
graph["20"] = {
"class_type": "VendorControlApply",
"inputs": {
"positive": ["21", 0],
"negative": ["22", 0],
"control_net": ["8", 0],
"image": ["8", 0],
},
}
graph["21"] = _encode(POSITIVE)
graph["22"] = _encode(NEGATIVE)
graph["17"]["inputs"]["positive"] = ["20", 0]
graph["17"]["inputs"]["negative"] = ["20", 1]
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, NEGATIVE))
def test_a_conditioning_input_is_followed_alone(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorStyleApply",
"inputs": {"conditioning": ["10", 0], "style": ["21", 0]},
},
"21": _encode(OTHER),
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
def test_core_type_inputs_are_skipped(self):
# The vendor encoder's ``clip`` reaches a loader with its own ``text``:
# that text is a lora tag, never the prompt.
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorPromptEncode",
"inputs": {"prompt": ["21", 0], "clip": ["22", 1]},
},
"21": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
"22": {
"class_type": "VendorLoraLoader",
"inputs": {"text": "<lora:x:1>", "clip": ["7", 0]},
},
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_texts(result)[0], OTHER)
class TestBounds(unittest.TestCase):
def test_a_long_unknown_chain_reaches_the_depth_limit(self):
graph = any_switch_graph(any_01=["100", 0])
for index in range(100, 100 + resolver.INSPECTION_PROFILE.max_depth + 2):
graph[str(index)] = {
"class_type": "Router Node (vendor)",
"inputs": {"in": [str(index + 1), 0]},
}
graph[str(index + 1)] = _pair()
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "depth-limit")
def test_many_different_sources_hit_the_candidate_cap(self):
inputs = {}
graph = any_switch_graph()
for index in range(resolver._MAX_BRANCH_CANDIDATES + 1):
graph[str(200 + index)] = _encode(f"{OTHER} {index}")
inputs[f"any_{index:02}"] = [str(200 + index), 0]
graph["15"]["inputs"] = inputs
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "work-limit")
if __name__ == "__main__":
unittest.main()
class TestGeneratedAndTransformedText(unittest.TestCase):
def test_a_seeded_text_node_is_generated_at_run_time(self):
result = _inspect(graphs.generated_text_graph())
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "runtime-value")
self.assertEqual(
result["node"], {"id": "30", "class_type": "VendorTextGenerate"}
)
def test_the_recorded_text_is_authoritative(self):
# The record is what ran: a generated or transformed text is never
# checked against the generator's or transform's inputs.
for name, records in graphs.recorded_shapes().items():
with self.subTest(shape=name):
metadata = {
"prompt": json.loads(records["prompt"]),
"comfyassets_prompt_pairs": records["comfyassets_prompt_pairs"],
}
result = resolve_prompt_pair(
metadata, profile=resolver.INSPECTION_PROFILE
)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result), (graphs.GENERATED, ""))
self.assertEqual(
result["positive"]["provenance"]["text_source"], "execution"
)
def test_a_seeded_conditioning_node_still_passes_through(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorConditioningNoise",
"inputs": {"conditioning": ["10", 0], "seed": 3},
}
},
)
self.assertEqual(_texts(_inspect(graph)), (POSITIVE, ""))
class TestReviewFindings(unittest.TestCase):
"""Shapes from the independent review of the first #381 commit."""
def _recorded(self, text_source: dict) -> dict:
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph.update(text_source)
metadata = {
"prompt": graph,
"comfyassets_prompt_pairs": graphs.execution("10", graphs.GENERATED),
}
return resolve_prompt_pair(metadata, profile=resolver.INSPECTION_PROFILE)
def test_a_failing_walk_never_overrides_the_recorded_text(self):
sources = {
"missing": {"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}},
"composite": {
"30": {"class_type": "XLLM", "inputs": {"src": ["31", 0]}},
"31": {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": ["32", 0],
"conditioning_2": ["33", 0],
},
},
"32": _encode(POSITIVE),
"33": _encode(OTHER),
},
"several": {
"30": {
"class_type": "XLLM",
"inputs": {"a": ["31", 0], "b": ["32", 0]},
},
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
},
"list-text": {"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}},
}
for name, source in sources.items():
with self.subTest(source=name):
result = self._recorded(source)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_texts(result)[0], graphs.GENERATED)
def test_the_seed_rule_reads_only_string_outputs(self):
graph = _with(
any_switch_graph(any_01=["20", 0]),
**{
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
"21": {
"class_type": "XSeedEncode",
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
},
},
)
self.assertEqual(_texts(_inspect(graph))[0], OTHER)
def test_an_unlinked_role_input_adds_nothing(self):
graph = any_switch_graph()
graph["20"] = {
"class_type": "XApply",
"inputs": {"positive": ["21", 0], "negative_opt": ""},
}
graph["21"] = _encode(POSITIVE)
graph["17"]["inputs"]["positive"] = ["20", 0]
graph["17"]["inputs"]["negative"] = ["20", 1]
result = _inspect(graph)
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason_code"], "unsupported-node")
self.assertEqual(result["node"], {"id": "20", "class_type": "XApply"})
def test_one_text_offered_twice_is_one_candidate(self):
graph = _with(
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
**{
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
"21": _encode(POSITIVE),
"22": _encode(OTHER),
"23": _encode(POSITIVE),
},
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(
[_texts(item)[0] for item in result["candidates"]], [POSITIVE, OTHER]
)
+10 -2
View File
@@ -72,16 +72,24 @@ class TestHiresFixChain(unittest.TestCase):
def test_an_unlisted_image_node_breaks_the_lineage(self):
# The walk still finds the earlier sampler, but not through listed
# pass-through nodes: the stages are never compared.
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
graph = shapes.hires_fix(
second_positive=shapes.POSITIVE, filter_class="MysteryImageEffect"
)
result = _inspect(graph)
self.assertEqual(result["state"], "ambiguous")
later = next(
item for item in result["candidates"] if item["sampler_id"] == "20"
)
self.assertEqual(later["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
# Stages on the very same conditioning links are one prompt (#381).
result = _inspect(shapes.hires_fix(filter_class="MysteryImageEffect"))
self.assertEqual(result["state"], "resolved")
def test_a_second_earlier_sampler_in_the_ancestry_stays_unattributed(self):
graph = shapes.hires_fix()
graph["30"] = shapes.ksampler(["2", 0], ["3", 0], ["4", 0])
# Its own encoder: the same links would be one prompt (#381).
graph["33"] = shapes.encode(shapes.POSITIVE)
graph["30"] = shapes.ksampler(["33", 0], ["3", 0], ["4", 0])
graph["31"] = {"class_type": "VAEDecode", "inputs": {"samples": ["30", 0]}}
graph["32"] = {
"class_type": "ImageBlend",
+151
View File
@@ -0,0 +1,151 @@
"""Saver anchoring by file name and shared-conditioning stages (#381).
An inspected file's name anchors the walk on the one saver whose
``filename_prefix`` (literal or traced) makes ``<prefix>_<digits>[_]``; the
samplers in that saver's image ancestry are the candidates. When no saver or
more than one matches, nothing is guessed: the file stays "several possible
prompts". A later stage reading the same positive and negative links as the
earlier stage has the same prompt whatever its latent ancestry. Shapes come
from the maintainer's runs (``tests/saver_anchor_graphs.py``).
"""
import json
import unittest
from utils import prompt_pair_resolver as resolver
from utils.prompt_inspector import inspect_prompt_evidence
try:
from tests import saver_anchor_graphs as shapes
except ImportError: # discovered with ``-s tests``
import saver_anchor_graphs as shapes
def _inspect(graph, file_name=None):
return resolver.resolve_prompt_pair(
{"prompt": graph}, profile=resolver.INSPECTION_PROFILE, file_name=file_name
)
def _positive(result):
side = result["positive"]
return side["text"] if isinstance(side, dict) else None
class TestSaverAnchor(unittest.TestCase):
def test_the_file_name_picks_its_branch(self):
for index, text in enumerate(shapes.BRANCH_TEXTS, start=1):
for name in (
f"s45_branch_{index}_00002_.png",
f"s45_branch_{index}_00002.webp",
):
with self.subTest(file=name):
result = _inspect(shapes.branch_bench(), name)
self.assertEqual(result["state"], "resolved")
self.assertEqual(_positive(result), text)
self.assertEqual(result["sampler_id"], str(index * 10 + 1))
def test_unanchored_branches_stay_several(self):
self.assertEqual(_inspect(shapes.branch_bench())["state"], "ambiguous")
def test_a_traced_prefix_anchors(self):
result = _inspect(shapes.linked_prefix_bench(), "s45_branch_3_00007_.png")
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[2])
def test_an_unknown_saver_class_anchors_too(self):
result = _inspect(
shapes.branch_bench(saver="VendorSaveImage"), "s45_branch_2_00001_.png"
)
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[1])
def test_no_guessing_when_the_name_matches_none_or_several(self):
cases = {
"no saver matches": (shapes.branch_bench(), "photo_00001_.png"),
"not a counter": (shapes.branch_bench(), "s45_branch_3_final.png"),
"prefix of another branch": (
shapes.branch_bench(),
"s45_branch_00001_.png",
),
"shared prefix": (shapes.shared_prefix(), "IMG_GEN_00371_.png"),
}
for label, (graph, name) in cases.items():
with self.subTest(case=label):
result = _inspect(graph, name)
self.assertEqual(result["state"], "ambiguous")
self.assertEqual(len(result["candidates"]), len(shapes.BRANCH_TEXTS))
def test_the_inspector_passes_the_file_name(self):
records = {"prompt": json.dumps(shapes.branch_bench())}
evidence = inspect_prompt_evidence(records, file_name="s45_branch_4_00009_.png")
self.assertEqual(
evidence["prompts"]["positive"]["text"], shapes.BRANCH_TEXTS[3]
)
self.assertEqual(
inspect_prompt_evidence(records)["resolution"]["state"], "ambiguous"
)
def test_ingestion_ignores_the_file_name(self):
result = resolver.resolve_prompt_pair(
{"prompt": shapes.branch_bench()}, file_name="s45_branch_1_00001_.png"
)
self.assertEqual(result["state"], "ambiguous")
class TestSharedConditioningStages(unittest.TestCase):
def test_a_later_stage_on_the_same_links_resolves(self):
for name in (None, "final_00003_.png"):
with self.subTest(file=name):
result = _inspect(shapes.two_stage_same_conditioning(), name)
self.assertEqual(result["state"], "resolved", result["reason"])
self.assertEqual(_positive(result), shapes.BRANCH_TEXTS[0])
def test_different_links_stay_unattributed(self):
graph = shapes.two_stage_same_conditioning(second_positive=["91", 0])
result = _inspect(graph, "final_00003_.png")
self.assertEqual(result["state"], "unresolved")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
if __name__ == "__main__":
unittest.main()
class TestReviewFindings(unittest.TestCase):
"""Shapes from the independent review of the saver-anchor commit."""
def test_a_skipped_stage_never_hides_its_own_earlier_prompt(self):
result = _inspect(shapes.hidden_earlier_prompt(), "out_00001_.png")
self.assertNotEqual(result["state"], "resolved")
self.assertEqual(result["reason"], resolver.MULTISTAGE_UNATTRIBUTED_REASON)
def test_a_skipped_stage_never_hides_its_override(self):
result = _inspect(shapes.hidden_earlier_prompt(override=True), "out_00001_.png")
self.assertNotEqual(result["state"], "resolved")
def test_an_unfinished_prefix_scan_anchors_nothing(self):
graph = shapes.deep_prefixes(9)
for visits in range(10, 30):
with self.subTest(visits=visits):
result = resolver.resolve_prompt_pair(
{"prompt": graph},
profile=resolver.INSPECTION_PROFILE,
max_visits=visits,
file_name="dup_00001_.png",
)
self.assertNotEqual(result["state"], "resolved")
class TestPlainFirstStage(unittest.TestCase):
def test_only_a_plain_first_stage_is_skipped(self):
expected = {
"no-latent": "resolved",
"literal-latent": "unresolved",
"missing-latent": "unresolved",
"cyclic-latent": "unresolved",
"stage-shaped": "unresolved",
"detailer": "unresolved",
}
for kind, state in expected.items():
with self.subTest(kind=kind):
result = _inspect(shapes.first_stage_variant(kind), "final_00003_.png")
self.assertEqual(result["state"], state, result["reason"])
+5 -1
View File
@@ -864,7 +864,11 @@ class TestInspectionResolverVersion(InspectionTestCase):
}
with (
patch.object(review_capture, "INSPECTION_RESOLVER_VERSION", 2),
patch.object(
review_capture,
"INSPECTION_RESOLVER_VERSION",
review_capture.INSPECTION_RESOLVER_VERSION + 1,
),
patch.object(review_capture, "resolve_inspection", side_effect=newer),
):
self.assertNotEqual(_version(), version)
+4 -1
View File
@@ -371,7 +371,10 @@ class ReviewSchemaTests(unittest.TestCase):
self.require_module()
for label, dropped in (
("annotations only", TABLES),
("predates annotations", ANNOTATION_TABLES + TABLES),
(
"predates annotations",
("media_image_autotags",) + ANNOTATION_TABLES + TABLES,
),
):
with self.subTest(label):
path = str(Path(self.temp.name) / f"{label.replace(' ', '-')}.db")
+539 -10
View File
@@ -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):
+321
View File
@@ -0,0 +1,321 @@
"""Synthetic graphs with unknown nodes between the prompt and the sampler (#381).
``any_switch_graph`` reproduces the shape of the maintainer's krea2 run
(PromptManagerPair -> Any Switch (rgthree) -> KSampler, a LoraManager loader
with its own lora ``text``, custom latent, filename and saver nodes) with
neutral texts. ``shapes()`` names one graph per pass-through rule; the resolver
tests and the browser-port parity vectors share them. No user file is involved.
"""
import json
POSITIVE = "a lighthouse on a cliff, golden hour"
OTHER = "a red bicycle in the rain"
NEGATIVE = "blurry, lowres"
def encode(text):
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["7", 0]}}
def pair(text=POSITIVE, negative=""):
return {
"class_type": "PromptManagerPair",
"inputs": {
"text": text,
"negative_text": negative,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": "",
"append_text": "",
"clip": ["9", 1],
},
}
def any_switch_graph(**switch_inputs):
"""The shape of #381: PromptManagerPair -> Any Switch (rgthree) -> KSampler.
Classes and links reproduce the maintainer's krea2 run (a LoraManager
loader with its own lora ``text``, a project-path filename node, a custom
latent and saver); the texts are neutral.
"""
inputs = switch_inputs or {"any_02": ["10", 0]}
return {
"3": {"class_type": "SeedHistory", "inputs": {"seed": 1538595059}},
"5": {
"class_type": "KikoProjectFilePath",
"inputs": {"project_name": "demo", "filename": "final"},
},
"6": {"class_type": "UNETLoader", "inputs": {"unet_name": "model.safetensors"}},
"7": {"class_type": "CLIPLoader", "inputs": {"clip_name": "clip.safetensors"}},
"8": {"class_type": "VAELoader", "inputs": {"vae_name": "vae.safetensors"}},
"9": {
"class_type": "Lora Loader (LoraManager)",
"inputs": {
"text": "<lora:style-demo:1>",
"loras": [{"name": "style-demo", "strength": 1, "active": True}],
"model": ["6", 0],
"clip": ["7", 0],
},
},
"10": pair(),
"15": {"class_type": "Any Switch (rgthree)", "inputs": inputs},
"16": {
"class_type": "EmptyLatentBatch",
"inputs": {"width": 1024, "height": 1536, "batch_size": 1},
},
"17": {
"class_type": "KSampler",
"inputs": {
"seed": ["3", 0],
"steps": 8,
"cfg": 1.0,
"sampler_name": "er_sde",
"scheduler": "beta",
"denoise": 1.0,
"model": ["9", 0],
"positive": ["15", 0],
"negative": ["10", 1],
"latent_image": ["16", 0],
},
},
"18": {
"class_type": "VAEDecode",
"inputs": {"samples": ["17", 0], "vae": ["8", 0]},
},
"19": {
"class_type": "KikoSaveImage",
"inputs": {"filename_prefix": ["5", 0], "images": ["18", 0]},
},
}
def with_nodes(graph, **nodes):
return {**graph, **nodes}
GENERATED = "a generated caption of a lighthouse"
def generated_text_graph():
"""PromptManagerPair whose ``text`` is a language model's output.
The generator (``sampling_mode.seed``, ``prompt``, ``system_prompt``)
writes new text at run time: its inputs are not the prompt.
"""
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph["30"] = {
"class_type": "VendorTextGenerate",
"inputs": {
"prompt": ["31", 0],
"system_prompt": ["32", 0],
"sampling_mode.seed": 0,
"clip": ["7", 0],
},
}
graph["31"] = {"class_type": "PrimitiveStringMultiline", "inputs": {"value": OTHER}}
graph["32"] = {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "You"}}
return graph
def transformed_text_graph():
"""PromptManagerPair whose ``text`` passes an unknown string transform."""
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph["30"] = {
"class_type": "VendorRegexReplace",
"inputs": {"text": OTHER, "pattern": "rain", "replace": "snow"},
}
return graph
def execution(node_id, text):
"""A ``comfyassets_prompt_pairs`` record of what the pair node ran."""
record = {
"text": text,
"negative_text": "",
"positive_text": text,
"negative_effective_text": "",
"prepend_text": "",
"append_text": "",
}
return json.dumps({"version": 1, "nodes": {node_id: {"records": [record]}}})
def recorded_shapes():
"""Record sets whose PromptManager node recorded the text it ran."""
return {
"generated-recorded": {
"prompt": json.dumps(generated_text_graph()),
"comfyassets_prompt_pairs": execution("10", GENERATED),
},
"transformed-recorded": {
"prompt": json.dumps(transformed_text_graph()),
"comfyassets_prompt_pairs": execution("10", GENERATED),
},
}
def _sampler_inputs(graph, positive, negative):
graph["17"]["inputs"]["positive"] = positive
graph["17"]["inputs"]["negative"] = negative
return graph
def shapes():
"""One named graph per rule (resolved, candidates, PromptManager, refusals)."""
vendor_encode = {
"class_type": "VendorPromptEncode",
"inputs": {"prompt": ["21", 0], "clip": ["22", 1]},
}
control = {
"class_type": "VendorControlApply",
"inputs": {
"positive": ["21", 0],
"negative": ["22", 0],
"control_net": ["8", 0],
"image": ["8", 0],
},
}
return {
"any-switch-pair": any_switch_graph(),
"any-switch-same-pair": any_switch_graph(any_01=["10", 0], any_02=["10", 0]),
"any-switch-equal-texts": with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": encode(OTHER), "21": encode(OTHER)},
),
"any-switch-candidates": with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["21", 0]),
**{"20": encode(POSITIVE), "21": encode(OTHER)},
),
"any-switch-prompt-manager-wins": with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["10", 0]),
**{"20": encode(OTHER)},
),
"any-switch-runtime-blocks": with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["22", 0]),
**{
"20": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["21", 0], "clip": ["7", 0]},
},
"21": {"class_type": "PromptSearchList", "inputs": {"search": "x"}},
"22": encode(OTHER),
},
),
"any-switch-no-text": any_switch_graph(any_01=["7", 0]),
"unknown-chain": with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "Router Node (vendor)",
"inputs": {"in": ["10", 0]},
}
},
),
"unknown-text-input": with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorTextEncode",
"inputs": {"text": OTHER, "clip": ["9", 1]},
}
},
),
"unknown-role-inputs": _sampler_inputs(
with_nodes(
any_switch_graph(),
**{"20": control, "21": encode(POSITIVE), "22": encode(NEGATIVE)},
),
["20", 0],
["20", 1],
),
"unknown-conditioning-input": with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": {
"class_type": "VendorStyleApply",
"inputs": {"conditioning": ["10", 0], "style": ["21", 0]},
},
"21": encode(OTHER),
},
),
"generated-text-runtime": generated_text_graph(),
"transformed-text": transformed_text_graph(),
"unknown-skips-clip": with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": vendor_encode,
"21": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
"22": {
"class_type": "VendorLoraLoader",
"inputs": {"text": "<lora:x:1>", "clip": ["7", 0]},
},
},
),
}
def review_shapes():
"""Record sets for the review findings on the first #381 commit."""
def recorded(source):
graph = any_switch_graph()
graph["10"]["inputs"]["text"] = ["30", 0]
graph.update(source)
return {
"prompt": json.dumps(graph),
"comfyassets_prompt_pairs": execution("10", GENERATED),
}
seeded = with_nodes(
any_switch_graph(any_01=["20", 0]),
**{
"20": {"class_type": "XNoise", "inputs": {"conditioning": ["21", 0]}},
"21": {
"class_type": "XSeedEncode",
"inputs": {"clip": ["7", 0], "text": OTHER, "seed": 3},
},
},
)
unlinked = any_switch_graph()
unlinked["20"] = {
"class_type": "XApply",
"inputs": {"positive": ["21", 0], "negative_opt": ""},
}
unlinked["21"] = encode(POSITIVE)
unlinked["17"]["inputs"]["positive"] = ["20", 0]
unlinked["17"]["inputs"]["negative"] = ["20", 1]
twice = with_nodes(
any_switch_graph(any_01=["20", 0], any_02=["23", 0]),
**{
"20": {"class_type": "XB", "inputs": {"p": ["21", 0], "q": ["22", 0]}},
"21": encode(POSITIVE),
"22": encode(OTHER),
"23": encode(POSITIVE),
},
)
return {
"recorded-missing": recorded(
{"30": {"class_type": "XLLM", "inputs": {"src": ["99", 0]}}}
),
"recorded-several": recorded(
{
"30": {
"class_type": "XLLM",
"inputs": {"a": ["31", 0], "b": ["32", 0]},
},
"31": {"class_type": "PrimitiveString", "inputs": {"value": POSITIVE}},
"32": {"class_type": "PrimitiveString", "inputs": {"value": OTHER}},
}
),
"recorded-list-text": recorded(
{"30": {"class_type": "XLLM", "inputs": {"text": [1, 2, 3]}}}
),
"seeded-encoder-behind-noise": {"prompt": json.dumps(seeded)},
"unlinked-role-input": {"prompt": json.dumps(unlinked)},
"one-text-twice": {"prompt": json.dumps(twice)},
}
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { COLUMNS, CONTROL_EXEMPTIONS, STATUSES, declaredTitles, parseControl, parseCoverageTable, readCoverageTable,
rowProblems } from '../../tools/vendor/legacy-coverage.mjs';
// TESTING.md "Legacy System capability coverage" (#249 System S12, #329): every legacy System capability names an
// owner, a known status, a well-formed native control and a test its file really declares.
const ROW = Object.freeze({ Capability: 'Clear/truncate logs', 'Legacy anchor': '`admin.html:915`', Owner: 'S12 Logs',
Status: 'covered', 'Native control': '`diagnostics` · button · "Clear all logs…"', Test: 'clears the logs (clear.spec.mjs)' });
const declaring = source => ({ locate: file => `tests/browser/${file}`, read: () => source });
test('every coverage row has an owner, a known status and a declared test', () => {
const rows = readCoverageTable();
assert.ok(rows.length >= 54, `the table lists ${rows.length} capabilities`);
for (const row of rows) {
assert.deepEqual(rowProblems(row), [], `TESTING.md:${row.line} ${row.Capability}`);
assert.ok(row.Owner.trim());
assert.ok(STATUSES.includes(row.Status));
}
assert.ok(rows.some(row => row.Status === 'kept in legacy') && rows.some(row => row.Status === 'approved removal'));
// A broken row fails: an empty owner, an unknown status, a test its file does not declare.
const [first] = rows;
assert.deepEqual(rowProblems({ ...first, Owner: '' }), ['no owner']);
assert.ok(rowProblems({ ...first, Status: 'done' }).includes('unknown status "done"'));
assert.match(rowProblems({ ...first, Test: first.Test.replace(/^(.+) \(/, '$1 again (') }).join(), /declares no test/);
assert.match(rowProblems({ ...first, Test: 'some title (missing.spec.mjs)' }).join(), /no test file missing\.spec\.mjs/);
});
test('a title that appears only in a comment does not count', () => {
const commented = [
"// test('clears the logs', async () => {});",
"/* test('clears the logs', async () => {}); */",
'/**',
" * it('clears the logs')",
' */',
"const title = 'clears the logs';",
"test.describe('clears the logs', () => {});",
].join('\n');
assert.equal(declaredTitles(commented, 'clear.spec.mjs').has('clears the logs'), false);
assert.deepEqual(rowProblems(ROW, declaring(commented)), ['clear.spec.mjs declares no test "clears the logs"']);
const declared = `${commented}\nfor (const theme of THEMES) {\n test('clears the logs', async ({ page }) => {});\n}\n`;
assert.deepEqual(rowProblems(ROW, declaring(declared)), []);
// Escaped quotes, template literals, modifiers and a title on the next line are declarations too.
const titles = declaredTitles("test('a gate\\'s refusal', () => {});\ntest.only(`b`, () => {});\nit(\n 'c', () => {});",
'x.test.js');
assert.deepEqual([...titles], ["a gate's refusal", 'b', 'c']);
// A block comment that starts after code on a line, and a `//` or `/*` inside a string or a regular expression.
const trailing = [
"const url = 'http://host/*'; /* test('clears the logs', () => {});",
"test('clears the logs', () => {}); */",
"const pattern = /['\"`]\\/\\*/;",
"test('after the pattern', () => {});",
].join('\n');
assert.deepEqual([...declaredTitles(trailing, 'x.spec.mjs')], ['after the pattern']);
// Python: only a real `def test_…` counts, never a commented one.
const python = ['# def test_report_lists_config_only_keys(self):', 'class T:', ' \"\"\"Example:', '',
' def test_in_a_docstring(self):', ' \"\"\"', ' def test_other(self):', ' def test_nested():',
' pass', 'def test_module():', ' pass', ''].join('\n');
assert.deepEqual([...declaredTitles(python, 'test_x.py')], ['test_other', 'test_module']);
});
test('a malformed native control cell fails', () => {
assert.deepEqual(parseControl('`diagnostics` · button · "Clear all logs…"'),
{ section: 'diagnostics', role: 'button', name: 'Clear all logs…', row: null });
assert.deepEqual(parseControl('`maintenance` · button · "Review plan" · in "Duplicate prompt text"'),
{ section: 'maintenance', role: 'button', name: 'Review plan', row: 'Duplicate prompt text' });
assert.equal(parseControl('—'), null);
for (const [cell, message] of [
['diagnostics · button · "Clear all logs…"', /malformed/],
['`diagnostics` · button · Clear all logs…', /malformed/],
['`diagnostics` - button - "Clear all logs…"', /malformed/],
['', /malformed/],
['`logs` · button · "Clear all logs…"', /unknown System section "logs"/],
['`diagnostics` · link · "Clear all logs…"', /unknown role "link"/],
]) {
assert.throws(() => parseControl(cell), message, cell);
assert.match(rowProblems({ ...ROW, 'Native control': cell }, declaring("test('clears the logs', () => {});")).join(), message);
}
// Only a covered row names a control.
assert.deepEqual(rowProblems({ ...ROW, Status: 'kept in legacy' }, declaring("test('clears the logs', () => {});")),
['a kept in legacy row names a native control']);
// The table itself must keep its six columns.
assert.throws(() => parseCoverageTable(`### Legacy System capability coverage\n\n| Capability | Owner |\n| --- | --- |\n`), /columns/);
assert.deepEqual(COLUMNS, ['Capability', 'Legacy anchor', 'Owner', 'Status', 'Native control', 'Test']);
});
test('a skipped test does not prove a capability', () => {
for (const modifier of ['skip', 'fixme', 'fail']) {
assert.deepEqual(rowProblems(ROW, declaring(`test.${modifier}('clears the logs', async () => {});`)),
['clear.spec.mjs only skips "clears the logs"'], modifier);
}
for (const modifier of ['only', 'slow']) {
assert.deepEqual(rowProblems(ROW, declaring(`test.${modifier}('clears the logs', async () => {});`)), [], modifier);
}
// A running declaration beside a skipped one counts.
assert.deepEqual(rowProblems(ROW, declaring("test.skip('clears the logs', () => {});\ntest('clears the logs', () => {});")), []);
});
test('a covered row names its native control unless it is exempt', () => {
const source = declaring("test('clears the logs', () => {});");
assert.deepEqual(rowProblems({ ...ROW, 'Native control': '—' }, source), ['a covered row names no native control']);
for (const status of ['kept in legacy', 'approved removal']) {
assert.deepEqual(rowProblems({ ...ROW, Status: status, 'Native control': '—' }, source), [], status);
}
const [exempt] = Object.keys(CONTROL_EXEMPTIONS);
assert.deepEqual(rowProblems({ ...ROW, Capability: exempt, 'Native control': '—' }, source), []);
// Every exemption has a reason and names a covered row of the table that has no control.
const rows = readCoverageTable();
for (const [capability, reason] of Object.entries(CONTROL_EXEMPTIONS)) {
assert.ok(reason.length > 20, capability);
const row = rows.find(item => item.Capability === capability);
assert.ok(row, `${capability} is in the table`);
assert.deepEqual([row.Status, row['Native control']], ['covered', '—']);
}
});
+29 -23
View File
@@ -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": {
+215
View File
@@ -0,0 +1,215 @@
// The legacy System capability coverage table (TESTING.md "Legacy System capability coverage", #249 System S12 #329):
// every capability of the legacy admin and gallery pages names its owner, a status, the native control that replaces it
// and a declared test. tests/vendor/legacy-coverage.test.mjs checks each row against the test files; the browser spec
// `every covered legacy System capability has its native control` finds each covered row's control on its System page.
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
export const HEADING = '### Legacy System capability coverage';
export const COLUMNS = Object.freeze(['Capability', 'Legacy anchor', 'Owner', 'Status', 'Native control', 'Test']);
export const STATUSES = Object.freeze(['covered', 'kept in legacy', 'approved removal']);
export const SECTIONS = Object.freeze(['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups',
'diagnostics']);
export const ROLES = Object.freeze(['button', 'combobox', 'switch', 'slider', 'spinbutton', 'textbox', 'group', 'status', 'term']);
// Where a Test cell's file is looked up, in order.
export const TEST_FOLDERS = Object.freeze(['tests/browser', 'tests/js', 'tests/vendor', 'tests']);
const NONE = '—';
/**
* Covered rows whose replacement is not on a System section, so they name no native control (`—`), each with why. Every
* other covered row must name one.
*/
export const CONTROL_EXEMPTIONS = Object.freeze({
'Gallery per-image auto-tag / review': 'owned by the Images selection bar (Auto-tag selected), not a System section',
});
/** Split a Markdown table row into its cells (a `|` inside backticks is not a separator). */
function cells(line) {
const out = [];
let cell = '';
let code = false;
for (const char of line.trim().replace(/^\|/, '').replace(/\|$/, '')) {
if (char === '`') code = !code;
if (char === '|' && !code) {
out.push(cell.trim());
cell = '';
} else {
cell += char;
}
}
out.push(cell.trim());
return out;
}
/** The table's rows as objects keyed by COLUMNS, from the section that starts at HEADING. */
export function parseCoverageTable(markdown) {
const lines = markdown.split('\n');
const start = lines.findIndex(line => line.trim() === HEADING);
if (start === -1) throw new Error(`TESTING.md has no "${HEADING}" section`);
const header = lines.findIndex((line, index) => index > start && line.trim().startsWith('|'));
if (header === -1 || lines.slice(start + 1, header).some(line => /^#{1,3} /.test(line))) {
throw new Error('The coverage section has no table');
}
const columns = cells(lines[header]);
if (columns.join('|') !== COLUMNS.join('|')) throw new Error(`The coverage table's columns are ${columns.join(', ')}`);
const rows = [];
for (let index = header + 2; index < lines.length && lines[index].trim().startsWith('|'); index += 1) {
const values = cells(lines[index]);
rows.push({ line: index + 1, ...Object.fromEntries(COLUMNS.map((column, at) => [column, values[at] ?? ''])) });
}
return rows;
}
/**
* A Native control cell: `` `<section>` · <role> · "<name>" ``, optionally ` · in "<row title>"` when the name is shared
* by several rows of the section → {section, role, name, row}; `—` → null; else it throws.
*/
export function parseControl(cell) {
if (cell === NONE) return null;
const match = /^`([a-z]+)` · ([a-z]+) · "([^"]+)"(?: · in "([^"]+)")?$/.exec(cell);
if (!match) throw new Error(`malformed native control "${cell}"`);
const [, section, role, name, row = null] = match;
if (!SECTIONS.includes(section)) throw new Error(`unknown System section "${section}"`);
if (!ROLES.includes(role)) throw new Error(`unknown role "${role}"`);
return { section, role, name, row };
}
/** A Test cell: `<title> (<file>)` → {title, file}. */
export function parseTest(cell) {
const match = /^(.+) \(([\w.-]+\.(?:mjs|js|py))\)$/.exec(cell);
if (!match) throw new Error(`malformed test cell "${cell}"`);
return { title: match[1], file: match[2] };
}
// Modifiers that still run the test; `.skip`, `.fixme` and `.fail` do not prove the capability.
const RUNS = new Set(['', 'only', 'slow']);
// After these characters (or at the start) a `/` opens a regular expression, not a division.
const REGEX_BEFORE = /[(,=:[!&|?{};+\-*%<>~^]$/;
/** Where the string, template literal or regular expression opened by `source[start]` ends (its closing index), or -1. */
function literalEnd(source, start) {
const quote = source[start];
let inClass = false;
for (let end = start + 1; end < source.length; end += 1) {
const at = source[end];
if (at === '\\') end += 1;
else if (quote === '/' && at === '[') inClass = true;
else if (quote === '/' && at === ']') inClass = false;
else if (at === quote && !inClass) return end;
// A quoted string or a regular expression never spans lines.
else if (at === '\n' && quote !== '`') return -1;
}
return -1;
}
/**
* JavaScript `source` with every comment blanked (newlines kept), wherever it starts on a line. Strings, template
* literals and regular expressions are skipped whole, so a `//` or `/*` inside them is not a comment.
*/
export function stripJsComments(source) {
let out = '';
let index = 0;
while (index < source.length) {
const char = source[index];
const next = source[index + 1];
if (char === '/' && next === '/') {
while (index < source.length && source[index] !== '\n') index += 1;
continue;
}
if (char === '/' && next === '*') {
const close = source.indexOf('*/', index + 2);
const stop = close === -1 ? source.length : close + 2;
out += source.slice(index, stop).replace(/[^\n]/g, ' ');
index = stop;
continue;
}
const opens = char === "'" || char === '"' || char === '`' || (char === '/' && REGEX_BEFORE.test(out.trimEnd() || '('));
const end = opens ? literalEnd(source, index) : -1;
if (end === -1) {
out += char;
index += 1;
} else {
out += source.slice(index, end + 1);
index = end + 1;
}
}
return out;
}
/** Python `source` with its comments and triple-quoted strings (docstrings) blanked, newlines kept. */
export function stripPythonComments(source) {
return source.replace(/("""|''')[\s\S]*?\1|#[^\n]*/g, text => text.replace(/[^\n]/g, ' '));
}
/**
* The tests a source declares, title → whether it runs: a `test('…'` or `it('…'` call at a line's start (`.only` and
* `.slow` run; `.skip`, `.fixme` and `.fail` do not) in JavaScript, a `def test_…` at function or method indentation in
* Python. Comments, and Python docstrings, never count.
*/
export function declarations(source, file) {
const found = new Map();
const add = (title, runs) => found.set(title, found.get(title) || runs);
if (file.endsWith('.py')) {
for (const match of stripPythonComments(source).matchAll(/^(?: {4})?(?:async[ \t]+)?def[ \t]+(test_\w+)[ \t]*\(/gm)) {
add(match[1], true);
}
return found;
}
const declaration = /^[ \t]*(?:test|it)(?:\.(only|skip|fixme|fail|slow))?\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/gm;
for (const match of stripJsComments(source).matchAll(declaration)) add(match[3].replace(/\\(.)/g, '$1'), RUNS.has(match[1] ?? ''));
return found;
}
/** The titles of the tests a source declares and runs. */
export function declaredTitles(source, file) {
return new Set([...declarations(source, file)].filter(([, runs]) => runs).map(([title]) => title));
}
/** The repository path of a Test cell's file, or null. */
export function testFile(file, root = ROOT) {
for (const folder of TEST_FOLDERS) {
const path = join(root, folder, file);
if (existsSync(path)) return path;
}
return null;
}
/** Every problem of one row (empty: the row is sound). `locate(file)` finds a test file's path, `read(path)` reads it. */
export function rowProblems(row, { root = ROOT, locate = file => testFile(file, root), read = path => readFileSync(path, 'utf8') } = {}) {
const problems = [];
if (!row.Capability) problems.push('no capability');
if (!row['Legacy anchor']) problems.push('no legacy anchor');
if (!row.Owner) problems.push('no owner');
if (!STATUSES.includes(row.Status)) problems.push(`unknown status "${row.Status}"`);
let control = null;
try {
control = parseControl(row['Native control']);
} catch (error) {
problems.push(error.message);
}
if (control && row.Status !== 'covered') problems.push(`a ${row.Status} row names a native control`);
if (row.Status === 'covered' && row['Native control'] === NONE && !Object.hasOwn(CONTROL_EXEMPTIONS, row.Capability)) {
problems.push('a covered row names no native control');
}
let test;
try {
test = parseTest(row.Test);
} catch (error) {
problems.push(error.message);
return problems;
}
const path = locate(test.file);
if (!path) problems.push(`no test file ${test.file}`);
else {
const runs = declarations(read(path), test.file).get(test.title);
if (runs === undefined) problems.push(`${test.file} declares no test "${test.title}"`);
else if (!runs) problems.push(`${test.file} only skips "${test.title}"`);
}
return problems;
}
/** The table read from TESTING.md. */
export function readCoverageTable(root = ROOT) {
return parseCoverageTable(readFileSync(join(root, 'TESTING.md'), 'utf8'));
}
+29 -6
View File
@@ -13,7 +13,7 @@ profile never imports it, so a missing or bad table can never break ingestion.
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. The file is bounded in size and
nesting before it is validated. Every class appears in exactly one prompt
category; classes that are not listed stay ``unsupported-node``. ``lineage``
category; an unlisted output of a listed class stays ``unsupported-node``. ``lineage``
(image and latent pass-through outputs for multi-stage chains) describes no
prompt, so its classes are not claimed by a category.
@@ -23,9 +23,13 @@ keys, no whitespace): any change of content, never of layout, moves it.
folds into the version of the items it records from the inspection profile
(``utils.review_capture``): the SHA-256 of ``class_projection``, the table
restricted to what it says about ``classes`` (the node classes of one graph)
plus the class-independent ``schema`` and ``stage_inputs``. The resolver looks
the table up only by the class of a graph node, so an edit about other
classes cannot change how that graph resolves and leaves the version alone.
plus the class-independent ``schema``, ``stage_inputs`` and ``pass_through``.
The resolver looks the table up only by the class of a graph node, so an edit
about other classes cannot change how that graph resolves and leaves the
version alone. A class the table does not list at all passes text through
(#381); that rule lives in code, so ``INSPECTION_RESOLVER_VERSION`` moves for it.
``pass_through.skip_inputs`` names the core type inputs (model, clip, image,
...) such a node's text is never read through.
"""
import hashlib
@@ -86,7 +90,7 @@ _CATEGORIES = frozenset(
+ _CLASS_CATEGORIES
+ _SLOT_CATEGORIES
+ _SET_CATEGORIES
+ ("lineage", "stage_inputs", "facts")
+ ("lineage", "stage_inputs", "pass_through", "facts")
)
# Input names that make an unlisted node a possible sampler stage in an ancestry
# walk (``shaped``: pipes, video embeds and custom-sampler parts carry a prompt
@@ -95,6 +99,10 @@ _CATEGORIES = frozenset(
# ``model`` alone (ModelSampling*) or with ``clip`` (LoraLoader) is a model patch.
# The resolver and comfy-metadata.js both read these lists.
_STAGE_INPUT_LISTS = ("shaped", "with_model")
# Inputs of core ComfyUI types (model, clip, image, ...) that an unknown node's
# output is never read through when the resolver passes text through it (#381):
# a loader's lora tag reached through ``clip`` is not the prompt.
_PASS_THROUGH_KEYS = ("skip_inputs",)
_STRING_PARAMS = MappingProxyType(
{
"literal": frozenset(),
@@ -127,6 +135,7 @@ class Semantics:
non_file_outputs: FrozenSet[str]
lineage: Mapping[Tuple[str, int], Mapping[str, Any]]
stage_inputs: Mapping[str, Tuple[str, ...]]
pass_through: Mapping[str, Tuple[str, ...]]
facts: Mapping[str, Any]
all_classes: FrozenSet[str]
# SHA-256 of the canonical table JSON (set by ``load_semantics``).
@@ -447,6 +456,19 @@ def _stage_inputs(doc: dict) -> Mapping[str, Tuple[str, ...]]:
return _freeze(table)
def _pass_through(doc: dict) -> Mapping[str, Tuple[str, ...]]:
table = doc["pass_through"]
if not isinstance(table, dict) or set(table) != set(_PASS_THROUGH_KEYS):
raise SemanticsError(
f"pass_through must have exactly {list(_PASS_THROUGH_KEYS)}"
)
_names(table["skip_inputs"], "pass_through.skip_inputs")
for name in ("text", "positive", "negative", "conditioning"):
if name in table["skip_inputs"]:
raise SemanticsError(f"pass_through.skip_inputs: {name} carries text")
return _freeze(table)
def _facts(doc: dict) -> Mapping[str, Any]:
facts = doc["facts"]
if not isinstance(facts, dict) or set(facts) != set(_FACTS_TABLES):
@@ -506,6 +528,7 @@ def _validate(doc: Any) -> Semantics:
return Semantics(
lineage=_lineage(doc),
stage_inputs=_stage_inputs(doc),
pass_through=_pass_through(doc),
facts=_facts(doc),
all_classes=owners.classes(),
**tables,
@@ -542,7 +565,7 @@ def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
# Table parts that hold no class names: they apply to every graph.
_CLASS_INDEPENDENT = frozenset({"schema", "stage_inputs"})
_CLASS_INDEPENDENT = frozenset({"schema", "stage_inputs", "pass_through"})
def _canonical_json(doc: Any) -> str:
+304 -45
View File
@@ -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 {
+2 -1
View File
@@ -534,6 +534,7 @@ def inspect_prompt_evidence(
saved: dict | None = None,
*,
profile: ResolverProfile = INSPECTION_PROFILE,
file_name: str | None = None,
) -> dict:
"""Keep uncertainty visible; a saved association never replaces file evidence.
@@ -597,7 +598,7 @@ def inspect_prompt_evidence(
if workflow is not None and "prompt" in canonical:
# Only to recognize overwritten PromptManager text (R1); never a role.
canonical["workflow"] = workflow
resolved = resolve_prompt_pair(canonical, profile=profile)
resolved = resolve_prompt_pair(canonical, profile=profile, file_name=file_name)
state = resolved["state"]
result = {}
for role in ("positive", "negative"):
+358 -21
View File
@@ -6,7 +6,9 @@ contains effective ``text``, deduplicated-recipe ``storage_text`` and
``provenance``; ComfyUI provenance records the exact output
slot and complete followed link path. States distinguish a resolved pair (an
explicit empty negative is valid), absent data, unsupported/unresolved data and
ambiguous sampler selection. Unknown conditioning semantics are never flattened.
ambiguous sampler selection. Listed composites are never flattened; in the
inspection profile, nodes the semantics table does not name pass their text
through (#381, ``_GraphResolver.passed``).
Future nodes can register declarative ``OutputAdapter`` entries by
``(class_type, output_slot)``. This does not prescribe a future paired-node schema.
@@ -48,7 +50,9 @@ RESOLVER_VERSION = 1
# review items recorded from that profile (with the profile's budgets and
# the semantics table). Bump it when a change would make the inspection
# profile resolve the same metadata otherwise, even if ingestion is unchanged.
INSPECTION_RESOLVER_VERSION = 1
# 2: unknown nodes pass through generically (#381); unknown classes are not in
# the table's class projection, so only this number can move those items.
INSPECTION_RESOLVER_VERSION = 2
@dataclass(frozen=True)
@@ -113,6 +117,15 @@ _HISTORICAL_ADAPTERS = {
for _name in ("PromptManagerPositive", "PromptManagerDetailer"):
_HISTORICAL_ADAPTERS[(_name, 0)] = _COMMA
_HISTORICAL_ADAPTERS[(_name, 1)] = replace(_COMMA, kind="string")
# This repository's own node classes, current and historical: their text is the
# prompt whatever unknown nodes sit between them and the sampler (#381).
_OWN_CLASSES = frozenset(
name
for name, _ in list(_DEFAULT_ADAPTERS) + list(_HISTORICAL_ADAPTERS)
if name.startswith("PromptManager")
)
# Nodes with these exact names are traced by code, not by the table.
_CODED_CLASSES = frozenset({"Reroute", "StringConcatenate", "Text Concatenate"})
@dataclass(frozen=True)
@@ -197,6 +210,17 @@ _ZEROED_SOURCE = "zeroed-conditioning"
_MAX_BRANCH_CANDIDATES = 32
# Statically undecidable text (a search, a list output): never pick an item.
_RUNTIME_REASON = "Text is decided at run time"
# Different texts reach one sampler through unknown nodes (#381): one candidate
# per text, never a refusal.
SEVERAL_SOURCES_REASON = "Several prompts reach the sampler"
SELECTOR_BRANCHES_REASON = "Selector branches have different prompts"
# Failures that bound the work: never skipped by a pass-through walk.
_LIMIT_CODES = frozenset({"work-limit", "depth-limit", "text-limit"})
_PASSED_UNVERIFIABLE = "Text passed through an unknown node is not verifiable"
# Inputs that make an unknown node a conditioning transform, not a generator.
_CONDITIONING_INPUTS = ("conditioning", "positive", "negative")
# The first item of a pass-through divergence's 3-tuple choice key.
_PASS_THROUGH = "pass-through"
# Uncoded outcomes the inspector explains by their exact reason.
EMPTY_POSITIVE_REASON = "Positive prompt is empty"
# Several sampler branches, every one resolved to the same pair (#249).
@@ -241,17 +265,23 @@ class _Unresolved(Exception):
class _Diverging(Exception):
"""A runtime selector whose branches trace to different texts.
"""A runtime choice whose options trace to different texts.
Raised from inside tracing; ``_GraphResolver.branches`` resolves the sampler
again once per choice, so each candidate is internally consistent.
again once per choice, so each candidate is internally consistent. A
selector's choices are ``(True, False)``; an unknown node passing several
texts through (#381) has one choice per distinct text, ``0..n-1``.
"""
def __init__(self, key: Tuple[str, int]) -> None:
def __init__(self, key: tuple, choices: tuple, sources: bool = False) -> None:
super().__init__(key)
# The condition output (node ID, slot): one runtime value, so every
# selector it drives is forced together.
# A selector's condition output (node ID, slot): one runtime value, so
# every selector it drives is forced together. A pass-through key is
# ``(_PASS_THROUGH, node ID, slot)``.
self.key = key
self.choices = choices
# Raised by an unknown node passing several texts (the outcome's reason).
self.sources = sources
def _node_ref(node_id: str, class_type: Any) -> dict:
@@ -447,6 +477,8 @@ class _GraphResolver:
self.latent_charged: set = set()
# ``semantics`` (inspection) names the samplers and guiders it follows.
self.semantics = semantics
# Nesting depth of execution-record checks (``passed``).
self.verifying = 0
if semantics is None:
self.sampler_classes = self.stage_classes = _SAMPLERS
else:
@@ -457,6 +489,19 @@ class _GraphResolver:
)
# Any listed sampler upstream in latent ancestry is an earlier stage.
self.stage_classes = frozenset(semantics.samplers)
# Classes the resolver knows something about: an unlisted output of
# one stays ``unsupported-node``; any other class passes through.
facts = semantics.facts
self.described = (
semantics.all_classes
| {name for name, _ in semantics.lineage}
| {name for name, _ in self.adapters}
| _CODED_CLASSES
| frozenset(facts["configuration_outputs"])
| frozenset(facts["model_outputs"])
| frozenset(facts["model_passthroughs"])
| frozenset(facts["scalar_outputs"])
)
def node(self, node_id: str) -> Tuple[str, dict]:
if self.legacy:
@@ -557,7 +602,9 @@ class _GraphResolver:
self.inherit_source(node_id, slot, [path])
elif (name, slot) in self.adapters:
adapter = self.adapters[(name, slot)]
if not isinstance(adapter, OutputAdapter) or adapter.kind != kind:
if not isinstance(adapter, OutputAdapter) or (
kind != "any" and adapter.kind != kind
):
raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
evidence = (
self.execution_record(node_id, inputs)
@@ -613,7 +660,7 @@ class _GraphResolver:
elif (
name in ("StringConcatenate", "Text Concatenate")
and slot == 0
and kind == "string"
and kind in ("string", "any")
):
if name == "StringConcatenate":
keys = ["string_a", "string_b"]
@@ -652,6 +699,10 @@ class _GraphResolver:
text, path, storage_text = self.semantic(
node_id, name, slot, kind, inputs, depth
)
elif self.semantics is not None and name not in self.described:
text, path, storage_text = self.passed(
node_id, slot, kind, inputs, depth
)
else:
raise self.unsupported(
"Unsupported node semantics or output slot", node_id
@@ -747,7 +798,7 @@ class _GraphResolver:
return True
if slot == 0 and name in semantics.selectors:
return True
return kind == "conditioning" and (
return kind in ("conditioning", "any") and (
(name, slot) in semantics.conditioning
or (slot == 0 and name in semantics.composites)
)
@@ -769,11 +820,13 @@ class _GraphResolver:
):
listed = table.get((name, slot))
if listed is not None:
if kind != expected:
if kind not in (expected, "any"):
raise _Unresolved(_INCOMPATIBLE_SLOT, "malformed")
return build(node_id, listed, inputs, depth)
entry = (
semantics.conditioning.get((name, slot)) if kind == "conditioning" else None
semantics.conditioning.get((name, slot))
if kind in ("conditioning", "any")
else None
)
if entry is not None and entry["strategy"] == "zeroed":
# The upstream text is discarded, so it is not traced.
@@ -825,9 +878,120 @@ class _GraphResolver:
for label in ("true", "false")
]
if parts[0][0] != parts[1][0]:
raise _Diverging((str(condition[0]), condition[1]))
raise _Diverging((str(condition[0]), condition[1]), (True, False))
return self.merged(node_id, 0, parts)
def passed(
self, node_id: str, slot: int, kind: str, inputs: dict, depth: int
) -> Tuple[str, list, str]:
"""``read_through``, except while checking an execution record.
A pair node's recorded text is what ran. While its inputs are checked
against the record (``execution_record``), any outcome of a walk through
an unknown node but running out of visits only means "cannot be read
statically" (``unsupported-node``, unverifiable), as before #381.
"""
if not self.verifying:
return self.read_through(node_id, slot, kind, inputs, depth)
try:
return self.read_through(node_id, slot, kind, inputs, depth)
except _Diverging:
raise self.unsupported(_PASSED_UNVERIFIABLE, node_id) from None
except _Unresolved as error:
if error.code == "work-limit":
raise
raise self.unsupported(_PASSED_UNVERIFIABLE, node_id) from None
def read_through(
self, node_id: str, slot: int, kind: str, inputs: dict, depth: int
) -> Tuple[str, list, str]:
"""An output no rule describes passes its text through (#381).
A string output (kind ``string``) of a node with a seed input
(``seed``, ``noise_seed``, ``sampling_mode.seed``) and no linked
conditioning input is generated at run time (a language model, a
wildcard or random line): ``runtime-value``, never its inputs. With a
linked conditioning input it is a conditioning transform (noise). A
``text`` input is the text (the legacy encoder rule). Otherwise the
followed inputs (``pass_inputs``) are traced with kind ``any``: an
unknown node may turn a string into conditioning. A followed input that
finds no text (``unsupported-node``) adds nothing; limits and runtime
choices propagate; any other failure blocks, unless a PromptManager
node was reached: this repository's own nodes are authoritative, so
only their texts count then. One distinct text is the result; several
are a runtime choice with one candidate per text, in input order.
"""
if (
kind == "string"
and any(name.endswith("seed") for name in inputs)
and not any(_link(inputs.get(name)) for name in _CONDITIONING_INPUTS)
):
raise self.named(_RUNTIME_REASON, "runtime-value", node_id)
if "text" in inputs:
traced = self.text(inputs["text"], "string", depth + 1)
self.inherit_source(node_id, slot, [traced[1]])
return traced
found: list = []
blocking: Optional[_Unresolved] = None
for value in self.pass_inputs(slot, inputs):
try:
found.append(self.text(value, "any", depth + 1))
except _Unresolved as error:
if error.code in _LIMIT_CODES:
raise
if error.code != "unsupported-node" and blocking is None:
blocking = error
owned = [traced for traced in found if self.owned(traced[1])]
if owned:
found = owned
elif blocking is not None:
raise blocking
if not found:
raise self.unsupported("Unsupported node semantics or output slot", node_id)
texts = list(dict.fromkeys(traced[0] for traced in found))
if len(texts) == 1:
return self.merged(node_id, slot, found)
if len(texts) > _MAX_BRANCH_CANDIDATES:
raise _Unresolved("Too many selector branches", "work-limit")
key = (_PASS_THROUGH, node_id, slot)
if key not in self.choices:
raise _Diverging(key, tuple(range(len(texts))), sources=True)
self.tainted = True
chosen = texts[self.choices[key]]
return self.merged(node_id, slot, [t for t in found if t[0] == chosen])
def pass_inputs(self, slot: int, inputs: dict) -> list:
"""The linked inputs an unknown node's output is read through.
Output slot 0 of a node with a ``positive`` or ``negative`` input reads
only ``positive`` (slot 1, ``negative``), nothing when that one is not
linked: the paired-conditioning convention, which the legacy parser
followed by role. A linked ``conditioning`` is read alone (the
legacy order). Otherwise every linked input but the core type inputs
(``pass_through.skip_inputs``: model, clip, image, ...) is read.
"""
if slot in (0, 1) and ("positive" in inputs or "negative" in inputs):
value = inputs.get(("positive", "negative")[slot])
return [value] if _link(value) else []
if _link(inputs.get("conditioning")):
return [inputs["conditioning"]]
skipped = self.semantics.pass_through["skip_inputs"]
return [
value
for name, value in inputs.items()
if _link(value) and name not in skipped
]
def through_unknown(self, path: list) -> bool:
"""Whether a followed path passes through a node no rule describes."""
self.scan(len(path))
return any(self.node(step["node_id"])[0] not in self.described for step in path)
def owned(self, path: list) -> bool:
"""Whether a followed path passes through one of this repository's nodes."""
self.scan(len(path))
return any(self.node(step["node_id"])[0] in _OWN_CLASSES for step in path)
def merged(self, node_id: str, slot: int, parts: list) -> Tuple[str, list, str]:
"""One text reached through several followed paths (deduplicated)."""
text, merged, storage_text = self.joined_paths(parts, parts)
@@ -1021,8 +1185,9 @@ class _GraphResolver:
if record is None:
return None
for name in ("text", "negative_text", "prepend_text", "append_text"):
self.verifying += 1
try:
expected = self.text(inputs.get(name, ""), "string")[0]
expected, followed, _ = self.text(inputs.get(name, ""), "string")
except _Unresolved as error:
if self.unverifiable(error):
value = inputs.get(name)
@@ -1043,6 +1208,11 @@ class _GraphResolver:
raise
continue
raise
finally:
self.verifying -= 1
if self.semantics is not None and self.through_unknown(followed):
# A text passed through an unknown node may be transformed there.
continue
if name in ("prepend_text", "append_text"):
expected = expected.strip()
if expected != record[name]:
@@ -1362,6 +1532,121 @@ class _GraphResolver:
_link(inputs.get(key)) for key in stage_inputs["with_model"]
)
def same_conditioning(self, first: str, second: str) -> bool:
"""Whether two listed samplers read the very same conditioning links.
Compared by the sampler table's conditioning inputs (positive and
negative, or a custom sampler's guider): each a link, equal by node ID
text and slot. Then their prompt is one, whatever their stage order.
"""
keys = []
for node_id in (first, second):
name, inputs = self.node(node_id)
conditioning = self.semantics.samplers[name]["conditioning"]
values = []
for role in sorted(conditioning):
value = inputs.get(conditioning[role])
if not _link(value):
return False
values.append((role, str(value[0]), str(value[1])))
keys.append(tuple(values))
return keys[0] == keys[1]
def alone(self, node_id: str) -> bool:
"""Whether a sampler's prompt is its own links' text alone.
True when it sets no prompt override and its latent ancestry reaches no
listed sampler (a plain first stage). Any failure but running out of
visits is ``False``; the walk never recurses into another stage.
"""
name, inputs = self.node(node_id)
entry = self.semantics.samplers[name]
if entry["strategy"] == _DETAILER:
return False
if any(
inputs.get(override) not in (None, "")
for override in entry.get("prompt_overrides", ())
):
return False
value = inputs.get(entry.get("latent")) if entry.get("latent") else None
if value is None:
return True
if not _link(value):
return False
queue, queued = self.walk_from(value)
try:
while queue:
current, depth = queue.popleft()
if current == node_id or depth >= self.max_depth:
return False
self.budget.spend()
name, inputs = self.node(current)
if name in self.stage_classes or self.stage_shaped(name, inputs):
return False
self.enqueue(queue, queued, inputs, depth + 1)
except _Unresolved as error:
if error.code == "work-limit":
raise
return False
return True
def saver_stages(self, file_name: Any) -> Optional[list]:
"""The candidate samplers of the one saver that wrote ``file_name``.
A saver is any node with a ``filename_prefix`` (literal, or traced as a
string) whose last path part makes the file's stem
``<prefix>_<digits>`` with an optional trailing ``_`` (ComfyUI's and
VideoHelperSuite's counters). Exactly one saver must match; its
linked inputs are walked like a detailer's image ancestry. No match,
several, an unreadable walk or no candidate sampler is ``None``: the
file is then judged unanchored, never guessed (#381).
"""
stem = _file_stem(file_name)
if stem is None:
return None
self.budget.reset(self.per_candidate_visits)
try:
matches = [
node_id
for node_id, node in self.graph.items()
if self.writes(node, stem)
]
if len(matches) != 1:
return None
_, inputs = self.node(matches[0])
values = [
value
for name, value in inputs.items()
if name != "filename_prefix" and _link(value)
]
found = self.stages_from(matches[0], values)
except (_Unresolved, _Diverging):
return None
candidates = [
node_id
for node_id in found
if self.graph[node_id]["class_type"] in self.sampler_classes
]
return candidates or None
def writes(self, node: Any, stem: str) -> bool:
"""Whether a node's ``filename_prefix`` names the file ``stem``."""
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
return False
prefix = node["inputs"].get("filename_prefix")
if _link(prefix):
try:
prefix = self.text(prefix, "string")[0]
except _Diverging:
return False
except _Unresolved as error:
if error.code == "work-limit":
raise # an unfinished scan anchors nothing (never a guess)
return False
if not isinstance(prefix, str):
return False
return _counter_of(prefix.replace("\\", "/").split("/")[-1], stem)
def upstream_stages(self, origin: str, value: Any) -> list:
"""Samplers reached from a detailer's image input, in breadth-first order.
@@ -1370,8 +1655,18 @@ class _GraphResolver:
"""
if not _link(value):
raise _Unresolved("Expected an image link", "malformed")
return self.stages_from(origin, [value])
def stages_from(self, origin: str, values: list) -> list:
"""``upstream_stages`` from several start links, queued in order once."""
found: list = []
queue, queued = self.walk_from(value)
queue: deque = deque()
queued: set = set()
for value in values:
start = str(value[0])
if start not in queued:
queued.add(start)
queue.append((start, 0))
while queue:
node_id, depth = queue.popleft()
if node_id == origin:
@@ -1563,17 +1858,20 @@ class _GraphResolver:
"""
pending: list = [{}]
results: list = []
several = False
try:
while pending:
self.choices = pending.pop(0)
try:
results.append(self.stage(sampler_id, frozenset()))
except _Diverging as diverging:
if len(results) + len(pending) + 2 > _MAX_BRANCH_CANDIDATES:
count = len(diverging.choices)
if len(results) + len(pending) + count > _MAX_BRANCH_CANDIDATES:
raise _Unresolved("Too many selector branches", "work-limit")
several = several or diverging.sources
pending.extend(
{**self.choices, diverging.key: choice}
for choice in (True, False)
for choice in diverging.choices
)
except _Unresolved as error:
return _result(
@@ -1587,12 +1885,18 @@ class _GraphResolver:
self.choices = {}
if all(item["state"] == "unresolved" for item in results):
return results[0]
if several:
# Chained unknown nodes can offer one text twice: list it once.
unique: dict = {}
for item in results:
unique.setdefault(_outcome_key(item), item)
results = list(unique.values())
if len({_outcome_key(item) for item in results}) == 1:
return results[0]
return _result(
"ambiguous",
sampler_id=sampler_id,
reason="Selector branches have different prompts",
reason=SEVERAL_SOURCES_REASON if several else SELECTOR_BRANCHES_REASON,
candidates=results,
)
@@ -1750,6 +2054,10 @@ class _GraphResolver:
if not traced:
lineage, traced = self.lineage_predecessor(value), True
if node_id != lineage:
if self.same_conditioning(origin, node_id) and self.alone(node_id):
# Same positive and negative links: the same prompt
# whichever stage this is (#381).
continue
# Not the one continued stage: no stage's prompt is chosen.
raise _Unresolved(MULTISTAGE_UNATTRIBUTED_REASON)
earlier = node_id
@@ -1883,6 +2191,26 @@ def _parameters(value: Any, max_text_length: int, inspection: bool = False) -> d
return _result("resolved", positive_side, negative_side)
def _file_stem(file_name: Any) -> Optional[str]:
"""A file name's last path part without its extension, or ``None``."""
if not isinstance(file_name, str) or not file_name:
return None
base = file_name.replace("\\", "/").split("/")[-1]
dot = base.rfind(".")
stem = base[:dot] if dot > 0 else base
return stem or None
def _counter_of(prefix: str, stem: str) -> bool:
"""Whether ``stem`` is ``prefix`` + ``_`` + ASCII digits [+ ``_``]."""
if not prefix or not stem.startswith(prefix + "_"):
return False
rest = stem[len(prefix) + 1 :]
if rest.endswith("_"):
rest = rest[:-1]
return rest != "" and all("0" <= char <= "9" for char in rest)
def _profile_with(
profile: ResolverProfile,
max_visits: Optional[int],
@@ -1927,6 +2255,7 @@ def resolve_prompt_pair(
max_visits: Optional[int] = None,
max_depth: Optional[int] = None,
max_text_length: Optional[int] = None,
file_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Resolve parsed metadata; a present API graph is authoritative over text.
@@ -1937,9 +2266,11 @@ def resolve_prompt_pair(
``sampler_id`` (the inspection profile first drops stages that only reach
previews).
``profile`` selects the bounds; explicit ``max_*`` keywords override it.
``file_name`` (inspection only, unanchored) anchors on the one saver that
wrote that file (``_GraphResolver.saver_stages``) when exactly one matches.
"""
profile = _profile_with(profile, max_visits, max_depth, max_text_length)
result = _resolve(metadata, output_node_id, adapters, profile)
result = _resolve(metadata, output_node_id, adapters, profile, file_name)
return _legacy_result(result) if profile.legacy else result
@@ -1992,6 +2323,7 @@ def _resolve(
output_node_id: Optional[str],
adapters: Optional[Mapping[Tuple[str, int], OutputAdapter]],
profile: ResolverProfile,
file_name: Optional[str] = None,
) -> Dict[str, Any]:
if not isinstance(metadata, dict) or not metadata:
return _result("absent", reason="No parsed prompt metadata")
@@ -2047,14 +2379,19 @@ def _resolve(
"unresolved", reason=str(error), reason_code=error.code, node=error.node
)
else:
sampler_ids = [
anchored = (
resolver.saver_stages(file_name)
if semantics is not None and file_name is not None
else None
)
sampler_ids = anchored or [
node_id
for node_id, node in normalized.items()
if isinstance(node, dict)
and isinstance(node.get("class_type"), str)
and node["class_type"] in resolver.sampler_classes
]
if semantics is not None:
if semantics is not None and anchored is None:
sampler_ids = resolver.final_stages(sampler_ids)
if not sampler_ids:
return _no_sampler(normalized, profile)
+18
View File
@@ -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"
+23
View File
@@ -246,6 +246,29 @@
],
"with_model": ["conditioning", "neg_conditioning", "samples", "latent"]
},
"pass_through": {
"skip_inputs": [
"model",
"clip",
"vae",
"clip_vision",
"clip_vision_output",
"control_net",
"style_model",
"upscale_model",
"image",
"images",
"pixels",
"mask",
"latent",
"latent_image",
"samples",
"noise",
"sampler",
"sigmas",
"guider"
]
},
"facts": {
"configuration_outputs": {
"RandomNoise": { "kind": "noise", "fields": ["noise_seed"] },
+4 -4
View File
@@ -106,9 +106,9 @@ export function toInspectionRead(classification, result) {
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */
export function localEvidence(read, classification, semantics = null) {
export function localEvidence(read, classification, semantics = null, fileName = null) {
const inspection = toInspectionRead(classificationOf(classification), read);
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics, fileName);
}
/**
@@ -117,7 +117,7 @@ export function localEvidence(read, classification, semantics = null) {
* "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does
* for Images.
*/
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics) }) {
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics, file?.name ?? null) }) {
const classified = classificationOf(classification);
const inspection = toInspectionRead(classified, read);
const filename = sanitizeLocalFilename(file?.name);
@@ -211,7 +211,7 @@ export const localFileAdapter = Object.freeze({
}
const semantics = await untilAborted(loadInspectionSemantics(), signal);
await yieldControl(signal);
const evidence = localEvidence(result, secret.classification, semantics);
const evidence = localEvidence(result, secret.classification, semantics, secret.filename);
await yieldControl(signal);
const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified };
const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence });
+58 -2
View File
@@ -129,7 +129,16 @@ function compositeOf(resolver, value) {
/** `upstream_stages`: samplers reached from a detailer's image input, breadth first; each node draws a visit. */
function upstreamStages(resolver, origin, value) {
if (!isLink(value)) throw new Unresolved('Expected an image link', 'malformed');
const found = [], start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
return stagesFrom(resolver, origin, [value]);
}
/** `stages_from`: `upstream_stages` from several start links, queued in order once. */
export function stagesFrom(resolver, origin, values) {
const found = [], queue = [], queued = new Set();
for (const value of values) {
const start = String(value[0]);
if (!queued.has(start)) { queued.add(start); queue.push([start, 0]); }
}
for (let head = 0; head < queue.length; head++) {
const [nodeId, depth] = queue[head];
if (nodeId === origin) throw new Unresolved('Cyclic sampler stage ancestry', 'malformed');
@@ -296,6 +305,49 @@ function lineagePredecessor(resolver, value) {
}
/** `latent_stages`: the one earlier sampler a latent continues, null for a single stage; else unattributed. */
/** `same_conditioning`: whether two listed samplers read the very same conditioning links. */
function sameConditioning(resolver, first, second) {
const keys = [];
for (const nodeId of [first, second]) {
const [name, inputs] = resolver.node(nodeId);
const conditioning = resolver.semantics.samplers.get(name).conditioning;
const values = [];
for (const role of Object.keys(conditioning).sort()) {
const value = pyGet(inputs, conditioning[role]);
if (!isLink(value)) return false;
values.push([role, String(value[0]), String(value[1])]);
}
keys.push(JSON.stringify(values));
}
return keys[0] === keys[1];
}
/** `alone`: no prompt override set and no listed sampler in its latent ancestry (a plain first stage). */
function alone(resolver, nodeId) {
let [name, inputs] = resolver.node(nodeId);
const entry = resolver.semantics.samplers.get(name);
if (entry.strategy === DETAILER) return false;
if ((entry.prompt_overrides ?? []).some(override => { const value = pyGet(inputs, override); return value !== null && value !== undefined && value !== ''; })) return false;
const value = entry.latent ? pyGet(inputs, entry.latent) : null;
if (value === null || value === undefined) return true;
if (!isLink(value)) return false;
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
try {
for (let head = 0; head < queue.length; head++) {
const [current, depth] = queue[head];
if (current === nodeId || depth >= resolver.maxDepth) return false;
resolver.budget.spend();
[name, inputs] = resolver.node(current);
if (resolver.stageClasses.has(name) || resolver.stageShaped(name, inputs)) return false;
resolver.enqueue(queue, queued, inputs, depth + 1);
}
} catch (error) {
if (!(error instanceof Unresolved) || error.code === 'work-limit') throw error;
return false;
}
return true;
}
export function latentStages(resolver, origin, value) {
if (!isLink(value)) throw new Unresolved('Unknown latent ancestry', 'malformed');
const start = String(value[0]), queue = [[start, 0]], queued = new Set([start]);
@@ -308,7 +360,11 @@ export function latentStages(resolver, origin, value) {
const [name, inputs] = resolver.node(nodeId);
if (resolver.stageClasses.has(name)) {
if (!traced) { lineage = lineagePredecessor(resolver, value); traced = true; }
if (nodeId !== lineage) throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
if (nodeId !== lineage) {
// Same positive and negative links: the same prompt whichever stage this is (#381).
if (sameConditioning(resolver, origin, nodeId) && alone(resolver, nodeId)) continue;
throw new Unresolved(MULTISTAGE_UNATTRIBUTED_REASON);
}
earlier = nodeId;
continue;
}
+29 -1
View File
@@ -29,6 +29,15 @@ export const ZEROED_SOURCE = 'zeroed-conditioning';
export const MAX_BRANCH_CANDIDATES = 32;
export const RUNTIME_REASON = 'Text is decided at run time';
export const EMPTY_POSITIVE_REASON = 'Positive prompt is empty';
// #381: different texts reach one sampler through unknown nodes (one candidate each).
export const SEVERAL_SOURCES_REASON = 'Several prompts reach the sampler';
export const SELECTOR_BRANCHES_REASON = 'Selector branches have different prompts';
// Failures that bound the work: never skipped by a pass-through walk (`_LIMIT_CODES`).
export const PASS_LIMIT_CODES = new Set(['work-limit', 'depth-limit', 'text-limit']);
export const PASS_THROUGH = 'pass-through';
export const PASSED_UNVERIFIABLE = 'Text passed through an unknown node is not verifiable';
// Classes traced by code, not by the table (`_CODED_CLASSES`).
export const CODED_CLASSES = ['Reroute', 'StringConcatenate', 'Text Concatenate'];
export const IDENTICAL_BRANCHES_REASON = 'Every sampler branch uses the same prompt pair';
export const MULTISTAGE_UNATTRIBUTED_REASON = 'Multistage sampler ancestry is ambiguous';
export const ADDED_CONDITIONING_REASON = 'A later stage adds conditioning to the base prompt';
@@ -96,6 +105,8 @@ const historical = [
];
for (const name of ['PromptManagerPositive', 'PromptManagerDetailer']) historical.push([name, 0, 'text', 'conditioning', COMMA], [name, 1, 'text', 'string', COMMA]);
for (const [name, slot, input, kind, options] of historical) ADAPTERS.set(slotKey(name, slot), adapter(input, kind, options));
// This repository's own classes (`_OWN_CLASSES`): authoritative through unknown nodes (#381).
export const OWN_CLASSES = new Set([...ADAPTERS.keys()].map(key => key.split('\u0000')[0]).filter(name => name.startsWith('PromptManager')));
// `known_slots` in execution_record: every adapter slot per class.
export const ADAPTER_SLOTS = new Map();
for (const key of ADAPTERS.keys()) {
@@ -112,9 +123,13 @@ export class Unresolved extends Error {
}
}
export class Diverging extends Error {
constructor(key) {
/** A selector's choices are [true, false]; an unknown node's are 0..n-1, one per distinct text (#381). */
constructor(key, choices, sources = false) {
super('diverging');
this.key = key;
this.choices = choices;
// Raised by an unknown node passing several texts (the outcome's reason).
this.sources = sources;
}
}
@@ -247,9 +262,22 @@ export function compileSemantics(doc) {
}
const stageInputs = doc.stage_inputs;
if (!plain(stageInputs) || !Array.isArray(stageInputs.shaped) || !Array.isArray(stageInputs.with_model)) fail('stage_inputs');
// #381: core type inputs an unknown node's text is never read through.
const passThrough = doc.pass_through;
if (!plain(passThrough) || Object.keys(passThrough).length !== 1 || !Array.isArray(passThrough.skip_inputs) ||
!passThrough.skip_inputs.length || passThrough.skip_inputs.some(name => typeof name !== 'string' || !name) ||
new Set(passThrough.skip_inputs).size !== passThrough.skip_inputs.length ||
['text', 'positive', 'negative', 'conditioning'].some(name => passThrough.skip_inputs.includes(name))) fail('pass_through');
// Classes the facts tables describe (loaders, model patches, configuration and scalar outputs).
const facts = doc.facts;
if (!plain(facts) || !plain(facts.configuration_outputs) || !plain(facts.model_outputs) || !Array.isArray(facts.model_passthroughs) ||
!plain(facts.scalar_outputs)) fail('facts');
const factClasses = new Set([...Object.keys(facts.configuration_outputs), ...Object.keys(facts.model_outputs), ...facts.model_passthroughs,
...Object.keys(facts.scalar_outputs)]);
return Object.freeze({
samplers, guiders, selectors, composites, savers, conditioning, encoders, strings, runtime, nonFileOutputs, lineage,
stageInputs: { shaped: [...stageInputs.shaped], withModel: [...stageInputs.with_model] }, allClasses: new Set(owners.keys()),
skipInputs: new Set(passThrough.skip_inputs), factClasses,
});
}
+167 -18
View File
@@ -23,7 +23,14 @@ import {
MAX_JOIN_INPUTS,
MAX_SUBGRAPH_DEPTH,
MAX_WORKFLOW_NODES,
CODED_CLASSES,
NO_SAMPLER_NAMES,
OWN_CLASSES,
PASS_LIMIT_CODES,
PASS_THROUGH,
SELECTOR_BRANCHES_REASON,
SEVERAL_SOURCES_REASON,
PASSED_UNVERIFIABLE,
OVERWRITTEN_PROMPT_REASON,
PROFILE,
ROLE_METADATA_KEYS,
@@ -45,7 +52,7 @@ import {
sideOf,
slotKey,
} from './local-resolver-support.mjs';
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage } from './local-resolver-stages.mjs';
import { finalStages, latentStages, outcomeKey, promptPart, sharedPair, stage, stagesFrom } from './local-resolver-stages.mjs';
import { parameters } from './local-parameters.mjs';
export { SemanticsError, compileSemantics, readPairExecution, outcomeMessage, isLink } from './local-resolver-support.mjs';
@@ -76,8 +83,13 @@ class GraphResolver {
this.latentWalks = new Map();
this.latentCharged = new Set();
this.semantics = semantics;
// Nesting depth of execution-record checks (`passed`).
this.verifying = 0;
this.samplerClasses = new Set([...semantics.samplers].filter(([, entry]) => CANDIDATE_STRATEGIES.has(entry.strategy)).map(([name]) => name));
this.stageClasses = new Set(semantics.samplers.keys());
// `described`: an unlisted output of one of these stays `unsupported-node`; any other class passes through (#381).
this.described = new Set([...semantics.allClasses, ...[...semantics.lineage.keys()].map(key => key.split('\u0000')[0]),
...[...ADAPTERS.keys()].map(key => key.split('\u0000')[0]), ...CODED_CLASSES, ...semantics.factClasses]);
}
node(nodeId) {
@@ -156,7 +168,7 @@ class GraphResolver {
[text, path, storageText] = this.text(inputs.get(keys[0]), kind, depth + 1);
this.inheritSource(nodeId, slot, [path]);
} else if (adapted) {
if (adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
if (kind !== 'any' && adapted.kind !== kind) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
const evidence = name === 'PromptManagerPair' ? this.executionRecord(nodeId, inputs) : null;
if (evidence !== null) {
text = evidence[adapted.inputName === 'text' ? 'positive_text' : 'negative_effective_text'];
@@ -178,11 +190,13 @@ class GraphResolver {
if (omitted.length) this.omitting.add(slotKey(nodeId, slot));
}
}
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && kind === 'string') {
} else if ((name === 'StringConcatenate' || name === 'Text Concatenate') && slot === 0 && (kind === 'string' || kind === 'any')) {
[text, path] = this.concatenate(name, inputs, depth);
storageText = text;
} else if (this.follows(name, slot, kind)) {
[text, path, storageText] = this.semantic(nodeId, name, slot, kind, inputs, depth);
} else if (!this.described.has(name)) {
[text, path, storageText] = this.passed(nodeId, slot, kind, inputs, depth);
} else {
throw this.unsupported('Unsupported node semantics or output slot', nodeId);
}
@@ -258,7 +272,7 @@ class GraphResolver {
if (semantics.runtime.has(name)) return true;
if (semantics.encoders.has(key) || semantics.strings.has(key)) return true;
if (slot === 0 && semantics.selectors.has(name)) return true;
return kind === 'conditioning' && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name)));
return (kind === 'conditioning' || kind === 'any') && (semantics.conditioning.has(key) || (slot === 0 && semantics.composites.has(name)));
}
semantic(nodeId, name, slot, kind, inputs, depth) {
@@ -267,11 +281,11 @@ class GraphResolver {
for (const [table, expected, build] of [[semantics.encoders, 'conditioning', 'encoded'], [semantics.strings, 'string', 'stringed']]) {
const listed = table.get(key);
if (listed !== undefined) {
if (kind !== expected) throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
if (kind !== expected && kind !== 'any') throw new Unresolved(INCOMPATIBLE_SLOT, 'malformed');
return this[build](nodeId, listed, inputs, depth);
}
}
const entry = kind === 'conditioning' ? semantics.conditioning.get(key) : undefined;
const entry = kind === 'conditioning' || kind === 'any' ? semantics.conditioning.get(key) : undefined;
if (entry !== undefined && entry.strategy === 'zeroed') {
this.textSources.set(slotKey(nodeId, slot), ZEROED_SOURCE);
return ['', [], ''];
@@ -302,10 +316,114 @@ class GraphResolver {
return traced;
}
const parts = ['true', 'false'].map(label => this.text(pyGet(inputs, branches[label]), kind, depth + 1));
if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1]));
if (parts[0][0] !== parts[1][0]) throw new Diverging(slotKey(String(condition[0]), condition[1]), [true, false]);
return this.merged(nodeId, 0, parts);
}
/** `passed`: `readThrough`, except that while an execution record is checked any outcome but `work-limit` is unverifiable. */
passed(nodeId, slot, kind, inputs, depth) {
if (!this.verifying) return this.readThrough(nodeId, slot, kind, inputs, depth);
try {
return this.readThrough(nodeId, slot, kind, inputs, depth);
} catch (error) {
if (error instanceof Unresolved && error.code === 'work-limit') throw error;
if (error instanceof Unresolved || error instanceof Diverging) throw this.unsupported(PASSED_UNVERIFIABLE, nodeId);
throw error;
}
}
/** `read_through` (#381): an output no rule describes passes its text through; PromptManager nodes are authoritative. */
readThrough(nodeId, slot, kind, inputs, depth) {
// A string output of a node with a seed input is generated at run time (a language model, a wildcard).
// With a linked conditioning input it is a conditioning transform (noise) instead.
if (kind === 'string' && [...inputs.keys()].some(name => name.endsWith('seed')) &&
!['conditioning', 'positive', 'negative'].some(name => isLink(pyGet(inputs, name)))) throw this.named(RUNTIME_REASON, 'runtime-value', nodeId);
if (inputs.has('text')) {
const traced = this.text(inputs.get('text'), 'string', depth + 1);
this.inheritSource(nodeId, slot, [traced[1]]);
return traced;
}
let found = [], blocking = null;
for (const value of this.passInputs(slot, inputs)) {
try {
found.push(this.text(value, 'any', depth + 1));
} catch (error) {
if (!(error instanceof Unresolved) || PASS_LIMIT_CODES.has(error.code)) throw error;
if (error.code !== 'unsupported-node' && blocking === null) blocking = error;
}
}
const owned = found.filter(traced => this.owned(traced[1]));
if (owned.length) found = owned;
else if (blocking !== null) throw blocking;
if (!found.length) throw this.unsupported('Unsupported node semantics or output slot', nodeId);
const texts = [...new Set(found.map(traced => traced[0]))];
if (texts.length === 1) return this.merged(nodeId, slot, found);
if (texts.length > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
// JSON escapes U+0000, so this never equals a selector's raw `slotKey` (Python: a 3-tuple vs a 2-tuple).
const key = JSON.stringify([PASS_THROUGH, nodeId, slot]);
if (!this.choices.has(key)) throw new Diverging(key, texts.map((_, index) => index), true);
this.tainted = true;
const chosen = texts[this.choices.get(key)];
return this.merged(nodeId, slot, found.filter(traced => traced[0] === chosen));
}
/** `pass_inputs`: the role input by output slot, else `conditioning`, else every linked non-type input. */
passInputs(slot, inputs) {
if ((slot === 0 || slot === 1) && (inputs.has('positive') || inputs.has('negative'))) {
const value = pyGet(inputs, slot === 0 ? 'positive' : 'negative');
return isLink(value) ? [value] : [];
}
if (isLink(pyGet(inputs, 'conditioning'))) return [inputs.get('conditioning')];
return [...inputs].filter(([name, value]) => isLink(value) && !this.semantics.skipInputs.has(name)).map(([, value]) => value);
}
/** `saver_stages` (#381): the candidate samplers of the one saver whose prefix names the file, or null (never guessed). */
saverStages(fileName) {
const stem = fileStem(fileName);
if (stem === null) return null;
this.budget.reset(this.perCandidateVisits);
let found;
try {
const matches = [...this.graph].filter(([, node]) => this.writes(node, stem)).map(([id]) => id);
if (matches.length !== 1) return null;
const [, inputs] = this.node(matches[0]);
const values = [...inputs].filter(([name, value]) => name !== 'filename_prefix' && isLink(value)).map(([, value]) => value);
found = stagesFrom(this, matches[0], values);
} catch (error) {
if (error instanceof Unresolved || error instanceof Diverging) return null;
throw error;
}
const candidates = found.filter(id => this.samplerClasses.has(this.graph.get(id).get('class_type')));
return candidates.length ? candidates : null;
}
/** `writes`: whether a node's `filename_prefix` (literal or traced) names the file `stem`. */
writes(node, stem) {
if (!isDict(node) || !isDict(pyGet(node, 'inputs'))) return false;
let prefix = pyGet(node.get('inputs'), 'filename_prefix');
if (isLink(prefix)) {
try { prefix = this.text(prefix, 'string')[0]; } catch (error) {
// An unfinished scan anchors nothing (never a guess): work-limit ends the match scan.
if (error instanceof Diverging || (error instanceof Unresolved && error.code !== 'work-limit')) return false;
throw error;
}
}
if (typeof prefix !== 'string') return false;
return counterOf(prefix.replaceAll('\\', '/').split('/').at(-1), stem);
}
/** `through_unknown`: whether a followed path passes through a node no rule describes. */
throughUnknown(path) {
this.scan(path.length);
return path.some(step => !this.described.has(this.node(step.node_id)[0]));
}
/** `owned`: whether a followed path passes through one of this repository's nodes. */
owned(path) {
this.scan(path.length);
return path.some(step => OWN_CLASSES.has(this.node(step.node_id)[0]));
}
merged(nodeId, slot, parts) {
const traced = this.joinedPaths(parts, parts);
this.inheritSource(nodeId, slot, parts.map(part => part[1]));
@@ -421,9 +539,10 @@ class GraphResolver {
const record = readPairExecution(this.metadata, nodeId);
if (record === null) return null;
for (const name of ['text', 'negative_text', 'prepend_text', 'append_text']) {
let expected;
let expected, followed;
this.verifying += 1;
try {
expected = this.text(pyGet(inputs, name, ''), 'string')[0];
[expected, followed] = this.text(pyGet(inputs, name, ''), 'string');
} catch (error) {
if (error instanceof Unresolved && this.unverifiable(error)) {
const value = pyGet(inputs, name);
@@ -436,7 +555,11 @@ class GraphResolver {
continue;
}
throw error;
} finally {
this.verifying -= 1;
}
// A text passed through an unknown node may be transformed there.
if (this.throughUnknown(followed)) continue;
if (name === 'prepend_text' || name === 'append_text') expected = pyStrip(expected);
if (expected !== record[name]) throw new Unresolved('Execution evidence does not match recorded node inputs', 'malformed');
}
@@ -491,7 +614,8 @@ class GraphResolver {
}
branches(samplerId) {
const pending = [new Map()], results = [];
const pending = [new Map()];
let results = [], several = false;
try {
while (pending.length) {
this.choices = pending.shift();
@@ -499,8 +623,9 @@ class GraphResolver {
results.push(stage(this, samplerId, new Set()));
} catch (error) {
if (!(error instanceof Diverging)) throw error;
if (results.length + pending.length + 2 > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
for (const choice of [true, false]) pending.push(new Map([...this.choices, [error.key, choice]]));
if (results.length + pending.length + error.choices.length > MAX_BRANCH_CANDIDATES) throw new Unresolved('Too many selector branches', 'work-limit');
several = several || error.sources;
for (const choice of error.choices) pending.push(new Map([...this.choices, [error.key, choice]]));
}
}
} catch (error) {
@@ -510,8 +635,14 @@ class GraphResolver {
this.choices = new Map();
}
if (results.every(item => item.state === 'unresolved')) return results[0];
if (several) {
// Chained unknown nodes can offer one text twice: list it once.
const unique = new Map();
for (const item of results) if (!unique.has(outcomeKey(item))) unique.set(outcomeKey(item), item);
results = [...unique.values()];
}
if (new Set(results.map(outcomeKey)).size === 1) return results[0];
return result('ambiguous', { samplerId, reason: 'Selector branches have different prompts', candidates: results });
return result('ambiguous', { samplerId, reason: several ? SEVERAL_SOURCES_REASON : SELECTOR_BRANCHES_REASON, candidates: results });
}
sides(samplerId) {
@@ -673,8 +804,25 @@ function overwrittenPromptNodes(workflow, graph) {
return found;
}
/** `_resolve` for an unanchored inspection (output_node_id None). */
function resolve(metadata, semantics) {
/** `_file_stem`: a file name's last path part without its extension, or null. */
function fileStem(fileName) {
if (typeof fileName !== 'string' || !fileName) return null;
const base = fileName.replaceAll('\\', '/').split('/').at(-1);
const dot = base.lastIndexOf('.');
const stem = dot > 0 ? base.slice(0, dot) : base;
return stem || null;
}
/** `_counter_of`: whether `stem` is `prefix` + `_` + ASCII digits [+ `_`]. */
function counterOf(prefix, stem) {
if (!prefix || !stem.startsWith(`${prefix}_`)) return false;
let rest = stem.slice(prefix.length + 1);
if (rest.endsWith('_')) rest = rest.slice(0, -1);
return rest !== '' && /^[0-9]+$/.test(rest);
}
/** `_resolve` for an unanchored inspection (output_node_id None); `fileName` anchors on its saver (#381). */
function resolve(metadata, semantics, fileName = null) {
if (!isDict(metadata) || !metadata.size) return result('absent', { reason: 'No parsed prompt metadata' });
let graph;
if (metadata.has('prompt')) graph = metadata.get('prompt');
@@ -688,7 +836,8 @@ function resolve(metadata, semantics) {
overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph);
}
const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten);
const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&
const anchored = fileName === null ? null : resolver.saverStages(fileName);
const samplerIds = anchored ?? finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&
resolver.samplerClasses.has(node.get('class_type'))).map(([id]) => id));
if (!samplerIds.length) return noSampler(graph);
if (samplerIds.length > 1) {
@@ -734,7 +883,7 @@ export function unreadableEvidence() {
* local read found (`{prompt, workflow, parameters, comfyassets_prompt_pairs}` texts). `semantics` is
* `compileSemantics(...)`'s table, or null when it could not be loaded.
*/
export function resolveLocalEvidence(records, semantics) {
export function resolveLocalEvidence(records, semantics, fileName = null) {
const source = records !== null && typeof records === 'object' ? records : {};
const metadata = new Map(Object.entries(source).filter(([key]) => ROLE_METADATA_KEYS.has(key)));
const malformedResolution = { state: 'unresolved', sampler_id: null, reason: MALFORMED_EVIDENCE_REASON, reason_code: 'malformed', node: null };
@@ -754,7 +903,7 @@ export function resolveLocalEvidence(records, semantics) {
}
const workflow = mayHoldPromptManager(metadata) ? parsedWorkflow(source.workflow ?? null) : null;
if (workflow !== null && metadata.has('prompt')) metadata.set('workflow', workflow);
const resolved = resolve(metadata, semantics);
const resolved = resolve(metadata, semantics, fileName);
const state = resolved.state, prompts = {};
for (const role of ['positive', 'negative']) {
const side = resolved[role];
+22 -4
View File
@@ -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 }) });
}
+2 -2
View File
@@ -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' },
];
+600
View File
@@ -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 }),
];
}
+2 -1
View File
@@ -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 });
}
+10 -2
View File
@@ -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 }),
+42 -16
View File
@@ -15,10 +15,11 @@
* value (the toast names it). D132: the concept's two simulated job buttons are "Download" (or
* "Download again" for a model on disk), which starts the `model_download` Activity job and opens it, and "Unload",
* a direct `POST /autotag/unload` (enabled while a model is loaded; a busy model's refusal names the operation).
* - D134: the `.operation` "Tag output images" › "Review" previews the prompt-tag batch
* (`POST /autotag/batch/preview`, read-only) and shows it in the shared review dialog with the D55 backup box; "Start
* tagging" runs the cancellable `autotag` Activity job. A review that cannot start (Replace after review, a short
* disk, nothing to tag) offers only Cancel and says why.
* - D134: the `.operation` "Tag output images" › "Review" previews the per-image batch (#379,
* `POST /autotag/batch/preview`, read-only: image counts and a time estimate) and shows it in the shared review dialog
* with the D55 backup box; "Start tagging" runs the cancellable `autotag` Activity job, which tags each output image
* from its own pixels and never a prompt. A review that cannot start (Replace after review, a short disk, no image,
* nothing to tag) offers only Cancel and says why.
*
* Every value is a host draft saved by the one Save row (system.mjs); every listener sits on the section's scope.
*/
@@ -44,7 +45,7 @@ export const REVIEW_TITLE = 'Review tag output images';
export const BATCH_TITLE = 'Tag output images';
export const START_LABEL = 'Start tagging';
export const INTRO = 'One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.';
export const BATCH_DESCRIPTION = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
export const BATCH_DESCRIPTION = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
export const SUGGESTIONS_DESCRIPTION = 'From 1 to 100: the most suggestions Generate shows for one image.';
/** D132: the engines in the concept's order, with the labels and sizes the server states when it cannot be read. */
@@ -97,8 +98,12 @@ export const SETTING_PROBLEMS = Object.freeze({
export const characters = text => [...String(text ?? '')].length;
export const REPLACE_NOTE = 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.';
export const NOTHING_TO_TAG = 'Every prompt with an output image is already tagged.';
export const NO_LINKED_IMAGES = 'No output image is linked to a prompt yet, so there is nothing to tag.';
export const NOTHING_TO_TAG = 'Every output image is already auto-tagged.';
export const NO_IMAGES = 'The output folders contain no images to tag.';
/** The Time row's tails (#379): where WD14 runs, and the language models, which have no estimate. */
export const TIME_ON_CPU = ' on a processor; a graphics card can be faster.';
export const TIME_ON_GPU = ' on the graphics card.';
export const TIME_UNKNOWN = 'Language-model engines take longer per image. Activity shows the time left once tagging starts.';
export const STATUS_UNREAD = 'The model status is still being read. Try again in a moment.';
export const SETTINGS_UNREAD = 'The settings are still being read. Try again in a moment.';
export const UNLOAD_FAILED = 'The tagging model could not be unloaded. Try again.';
@@ -254,19 +259,40 @@ export async function startJob({ activity, scope, kind, body, what, notify = sho
/** The existing-tags policy's label. */
export const existingLabel = value => EXISTING_OPTIONS.find(([id]) => id === value)?.[1] ?? String(value ?? '');
/** D134: the review's rows from the server's preview and the drafts it was made with. */
/**
* DEC11 (#379): a whole number of minutes as the review's Time text: "about m min" under 90, then "about H h M min",
* or "about H h" on the hour.
*/
export function durationText(minutes) {
const m = Math.max(1, Math.ceil(Number.isFinite(minutes) ? minutes : 1));
if (m < 90) return `about ${m} min`;
const hours = Math.floor(m / 60);
const rest = m % 60;
return rest === 0 ? `about ${hours} h` : `about ${hours} h ${rest} min`;
}
/** The review's Time row: the preview's `estimate_seconds` where WD14 runs (`estimate_device`), else the sentence. */
export function timeText(plan) {
const seconds = plan?.estimate_seconds;
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return TIME_UNKNOWN;
return durationText(Math.ceil(seconds / 60)) + (plan.estimate_device === 'gpu' ? TIME_ON_GPU : TIME_ON_CPU);
}
/** D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). */
export function batchGrid(plan, values) {
const id = plan.model?.type ?? values.autotag_model_type;
const settings = CLASSIFIERS.has(id)
let settings = CLASSIFIERS.has(id)
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}`
: `Template of ${number(characters(values.autotag_template))} characters`;
// The saved count the run uses (the preview's), not an unsaved draft.
if (Number.isSafeInteger(plan.suggestion_limit)) settings += ` · up to ${number(plan.suggestion_limit)} tags per image`;
return [
['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`],
['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)],
['Images linked', number(plan.images)],
['Prompts', number(plan.prompts)],
['Already tagged', number(plan.already_tagged)],
['Prompts to tag', `up to ${number(plan.to_tag)}`],
['Images', number(plan.images)],
['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`],
['Time', timeText(plan)],
['Settings', settings],
['Backup', backupText(plan.backup)],
];
@@ -291,13 +317,13 @@ export function batchCloseNote(plan) {
if (plan.review_required === true) return REPLACE_NOTE;
if (plan.model?.downloaded === false) return notDownloaded(modelLabel(plan.model.type, plan.model));
if (plan.disk?.sufficient === false) return diskShortNote(plan.disk);
if (!Number.isSafeInteger(plan.prompts) || plan.prompts === 0) return NO_LINKED_IMAGES;
if (!Number.isSafeInteger(plan.images) || plan.images === 0) return NO_IMAGES;
if (plan.to_tag === 0) return NOTHING_TO_TAG;
return null;
}
export const BATCH_SENTENCE = 'Nothing is written until you start. Missing files and files outside the output folders are '
+ 'counted, so the prompts to tag are an upper bound; Activity reports what was tagged.';
export const BATCH_SENTENCE = 'Nothing is written until you start. The number to tag is an estimate: images changed since '
+ 'they were tagged are tagged again, and missing files are skipped. Activity reports what was tagged.';
/** The Cancel-only review: the counts and why the batch cannot start (CUJ 5), in the shared close-only shell. */
export function openBatchCloseOnly({ scope, trigger, grid, note }) {
+21 -45
View File
@@ -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();
}
+5 -3
View File
@@ -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 */ }