Compare commits

..
Author SHA1 Message Date
Vito Sansevero 7e9fa0682b Merge remote-tracking branch 'origin/main' into fix/451-masonry-default 2026-10-28 18:59:57 -07:00
Vito Sansevero 8b12e95bc6 fix(images): open Masonry by default, keep a saved Default layout, crop portraits from the top (#451)
- A Default layout or Thumbnail size saved in System now reaches the next Images
  visit in an open tab (this tab or another): the last visit records the
  defaults it started from, and a value its toolbar never changed follows newer
  defaults. A toolbar choice stays the current view's own.
- Images opens in Masonry when the browser has no stored Default layout
  (gallery-settings.js DEFAULT_SETTINGS.workspaceLayout); stored choices are kept.
- Grid/List card art, Library card art, the inspector cover and the Tag image
  preview crop with object-position 50% 20%, so portrait images keep their top.
- D157 in TESTING.md; concept comparisons start from the concept's stored Grid.
2026-10-28 17:56:10 -07:00
68 changed files with 378 additions and 3109 deletions
+21 -201
View File
@@ -731,9 +731,8 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled
sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt
tags · N" or "Recipe tags · N" for the linked tags; image tags and the linked positive's own tags
chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
tags first, D88, then the linked tags; image tags and the linked positive's own tags
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
@@ -1386,8 +1385,8 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
Library title, and tag names), `#category-filter` "All categories" plus the
Library's categories, `#sort` (Recently added, Name A–Z, then Oldest first and Name
Z–A; no Highest rated, D153), and `Filters` (opens "Refine this view").
Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
first and Name Z–A), and `Filters` (opens "Refine this view").
- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
"Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library).
@@ -1413,7 +1412,7 @@ How the earlier workspace Images filters moved (old → new). The legacy `/galle
| Source | "Refine this view" → Source (D14); shown in the scope chip |
| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A (D13, D14; no Highest rated, D153) |
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
| (none) | toolbar category (the effective category, D141) and "Refine this view" → Tags, through the linked positive (D13, D14) |
| Refresh | Import → "Scan an output folder" → Rescan (D15) |
| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
@@ -1524,9 +1523,6 @@ Recorded deviations on this page (spec §8):
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
and are filters too; a linked tag already shown (case-insensitive) is left out.
Amended by D154 (#449): the image tags and the linked tags each sit in their own
labelled section, and a linked tag stays in its section even when an image tag
has the same name.
Amended by D146 (#332): the category chip also names its origin in visible text
after the category (" · this image", " · prompt", " · recipe"), and an image's own
category shows for an unlinked or ambiguous file too.
@@ -1814,30 +1810,6 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
concept image's tags saved as its image tags production still equals the concept.
After a Tag save the selected file's inspection is re-read, so the panel redraws
the row without reselecting and the viewer, which shares that inspection, shows it.
Amended by D154 (#449): the one row is now two labelled sections.
- **D154** (#449, beyond the concept): the inspector's tags no longer follow the
negative prompt as a bare `.tags` row, where they read as part of it. Every surface of
the shared inspector (the Images side panel, the viewer details, History's inline
current file, the Library-embedded gallery and Activity) draws them in
`section.tag-section` regions in the concept's prompt-section frame (the
`.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a
`.role-label`, the row's own 12px top margin dropped; workspace.css). The label is
muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First
`section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88),
then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's
tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category
chip names its origin). Each region is named without its count and with the prompt
blocks' context ("Current file image tags" in History). An empty source draws no
section. Repeats are removed within a source only, so a linked tag that is also an
image tag shows in both sections and each count is true. D21's filter rules are
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
injected) and compares the measured sections (frame, heading, label, row); a
mutation of the label color, the row margin or the top rule fails it. Browser tests:
"D154: image tags sit in their own labelled section after the negative prompt, in
the panel and the viewer" and "the inline current file labels its image tags after
its negative prompt".
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
@@ -1923,7 +1895,6 @@ production-versus-concept difference of PR C and its record:
| Suggestions count field | D91 (ruling T3-bulk) |
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) |
| Auto-tag images `aria-disabled` without a selected image | D115 |
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
@@ -2404,7 +2375,7 @@ The shelf uses the shared concept toolbar and caption (E1) and the concept's
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
`.card-footer` (the last-use line, then `.row-actions` with the positive
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
the measured row heights on Chromium and touch.
@@ -2414,8 +2385,8 @@ Recorded deviations on this page:
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
browser reads as today), and "Not used yet" without one. There are no rating stars
(D153, amending D23): every parent shows only the last-use words.
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
The meta chip reads "− N negative variants" (singular for one). The title and Explore
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
against the old links is that they offer no open in a new tab, middle-click or URL on
@@ -2576,48 +2547,6 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
### No 1–5 prompt rating in the workspace (#447)
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
a 1–5 rating): the workspace shows and edits no prompt rating. The concept's rating
appears in four places, and production draws none of them:
- Library cards: the concept's footer reads "★★★★★ · Used today"; production reads only
the last use (amends D23). `library-concept.spec.mjs` takes the stars and their " · "
out of the concept page itself before measuring (`dropConceptRatings`), so the footer
facts are compared at production's width; it first checks that every concept card had
them.
- Sort: the concept's "Highest rated" is not offered on Images or the Library (amends
D14); the comparisons drop it from the concept's options. A saved view or a Library
visit that still names `sort: "rating"` opens on Recently added; the server keeps such a
view as it was stored.
- Editor (New prompt, Edit recipe metadata, Create text revision): no `#recipe-rating`
"Rating" select; `library-editor-canonical-comparison.mjs` `mapConcept` drops the
concept's Rating field. The editor never sends `rating`. An edit sends only changed
fields, so a stored rating stays as it is; a new prompt and a text revision create an
unrated positive (the revised prompt keeps its own rating; the legacy admin page shows
the revision as unrated). A reuse that leaves metadata unapplied compares title,
category, tags and notes only. The edit token still covers `rating`, so a rating
changed on a legacy page while the editor is open is an ordinary metadata conflict
whose review shows no visible difference.
- System > Statistics: no "Avg. rating" column and no "Rated images" total (amends D96);
`system-concept.spec.mjs` maps the concept's table without that column.
Data and API are unchanged: the `rating` column (`CHECK` 1–5), `PUT
/prompt_manager/prompts/{id}/rating`, `rating` in Library, Images and statistics
answers, and the server's `rating` sorts. The legacy pages keep their ratings until the
cutover (#334). Favorites (the image star) are unchanged.
Tests: units `tests/js/workspace-library-editor-state.test.js` (a prompt rated 4 edited
and saved keeps 4, no `rating` in any body, no rating field),
`workspace-library-state.test.js`, `workspace-images-state.test.js`,
`workspace-saved-views.test.js`, `workspace-system-overview.test.js`,
`workspace-library-format.test.js`; browser `workspace-library-editor.spec.mjs` (the
fixture's positive stored with rating 4 still reads 4 after Edit, a revision, Add
negative and a reuse), `workspace-library.spec.mjs`, `workspace-images.spec.mjs`,
`workspace-saved-views.spec.mjs`, `workspace-system.spec.mjs` and the concept
comparisons above.
### Prompt history page and recorded deviations (#249 PR F-a)
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
@@ -3238,8 +3167,7 @@ until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismisse
the legacy admin page's key); Later dismisses it without scanning, and Review scan
dismisses it as the legacy Scan does. "+ Add source" asks validate-source, then adds
the folder to the host draft of `gallery_root_paths`; Remove takes one out; "Watch for
new outputs" (`monitoring_enabled`), "Save prompts from other workflows"
(`save_foreign_runs`, D158) and Worker threads (`worker_threads`) are host
new outputs" (`monitoring_enabled`) and Worker threads (`worker_threads`) are host
drafts too, and Save writes them with the revision. A save the server refuses because a
scan runs toasts "Another job is running: <title>." with a link to that job (D57) and
keeps the drafts; a save refused for one source says the server's message as the status
@@ -3488,7 +3416,7 @@ 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, D109–D110 and D111–D114; D121–D134 and D158 assigned). D94,
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
@@ -3534,13 +3462,12 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
every check passed, some could not run, or issues need review. Close / Review missing
links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up.
Nothing is changed by a check.
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with library totals." because
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
average rating of eligible parents per category ("Uncategorized", "Not rated"),
then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the
legacy statistics, and Rated images: indexed images whose linked prompt is rated).
The Overview row says "Runs and ratings by category, with library totals." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -3977,26 +3904,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
without a PromptManager node; the maintainer chose a visible switch over a config key.
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
"Save prompts from other workflows" (`#foreign-runs`, host key `save_foreign_runs`,
config.json `gallery.monitoring.save_foreign_runs`, default on), "New outputs of
workflows without a PromptManager node add their prompt to the library when it is
certain.". It is saved with the revision like the other host rows and applied at once
(the monitor reads it for every file), so a save names no restart. While "Watch for
new outputs" is drafted off, a `p.small` under the description says "Applies while
Watch for new outputs is on." (the switch's `aria-describedby`; the value is kept) and
the switch is disabled, except while a source still reads "watching until restart"
(D128): the running observer still delivers files then, so the value can be set. The
server stores no foreign prompt while `monitoring_enabled` is saved off, whatever the
observer does.
Pinned by `sources matches the concept` (the mapping inserts the row after the
monitoring row), `sources comparison detects mutations` (a missing row and a changed
label are caught), `D158: Save prompts from other workflows saves save_foreign_runs
live; off while monitoring is off (#247)` (keyboard on desktop, a tap on touch) and
`D158: saved off while the monitor still watches until a restart, the foreign row can
still be set (D128)` (workspace-system-sources.spec) and the system-sources node test.
- **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
@@ -5924,70 +5831,6 @@ Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
**In the monitor.** An output whose workflow has no positive PromptManager node
(`_find_prompt_from_metadata` says `foreign`) used to be logged and skipped. It now
goes through the same ladder with the `MONITOR` policy (`utils/foreign_runs.py`, the
handler's `_ingest_foreign`, `_recheck_foreign`, `_store_foreign`):
- **At settle.** The ladder runs without history. Certain (`pair` or `positive`): the
prompt is stored and the file linked at once. `history` (an undecided graph inside
ComfyUI's output folder): the file is registered in its own waiting set, then the
ladder runs again at once with the file's recorded output, because the run may have
been sealed before the file settled. When that lookup finds a record, its answer is
final: certain is stored, anything else is logged "No certain prompt for <file>;
left unlinked" and the file stops waiting (no seal is coming). When it finds none,
only the inspector's or the recorded saver's answer is stored; anything else (the
legacy floor included) keeps waiting for the re-check. Anything else at the first
pass is logged "No certain prompt for <file>; left unlinked".
- **After the run ends.** When the run's outputs are sealed, each waiting foreign file
gets one re-check (`ForeignRecheck(reference)`) with the sealed output reference,
so history is asked with the spelling it recorded (a Windows folder whose case
differs from the disk still links). Not certain: "No certain prompt for <file>
after its run ended; left unlinked". An ambiguous inspector answer is never given
the legacy floor in the monitor (D-Q3); an untraceable one still is.
- **Waiting sets.** `_awaiting_foreign` and `_foreign_due` sit beside the keyless sets
with the same bounds (1000 files, one hour) but are separate, so a large foreign run
never evicts a waiting keyless file. An expired entry is logged "No recorded run
for <file>; left unlinked", an evicted one (waiting or due) "Too many files wait for
their run; <file> left unlinked", both after the lock is released. A due re-check
is dropped only when it was scheduled, never a newer reference for the same file. `awaiting_foreign()` lists them;
`shutdown()` and `close()` clear them, and a closed handler re-checks nothing.
- **Store.** One write through `survive_switch` (rerun after a Move library): a pair
through `ingest_resolved_pair(..., marks=RUN_MARKS)`, a positive through
`store_scanned_prompt_or_raise(..., RUN_MARKS)`. A created prompt carries category
`scanned`, tag `auto-scanned` and the note `Saved from a workflow run: <file>`; a
file linked to another prompt is `conflict` and keeps its link. Logged at info
"Foreign run: <status> prompt <id> for <file> (<source>)"; a failure is logged and
the file stays unlinked.
- **Never.** No tracker FIFO entry is read or consumed, nothing is counted (run count
stays 0, no `last_used_at`), and nothing is queued for Needs review.
- **The setting.** `GalleryConfig.SAVE_FOREIGN_RUNS` (`gallery.monitoring.
save_foreign_runs`, default true, only a bool applies) is read for every file, at
settle, at the re-check and again just before the store, together with
`MONITORING_ENABLED`: either saved off stops foreign writes at once (the running
observer watches until a restart, D128); off logs "No PromptManager prompt in
workflow, skipping image" as before. System › Sources & scans "Save prompts from other workflows" (D158)
saves it through `POST /prompt_manager/settings` with no restart field; the
diagnostics report lists it.
Tests: `tests/test_run_attribution.py` `TestMonitorForeignRuns` (dataset branches
linked after the seal, a seal before settle, a name two runs reported, the setting
off at settle and at the re-check, a plain workflow linked at settle with no run
count and the tracker queue untouched, the sealed reference's spelling, the separate
bound, expiry, a seal while the pending set is full, shutdown, a failed store, no
record at settle keeps waiting, a record without an answer ends the wait, monitoring
saved off, the setting read before the store, a seal between registering and the
settle lookup, a newer due reference kept, evictions logged, one link per file),
`tests/test_image_linking.py` `TestForeignWorkflows`, `tests/test_image_monitor_singleton.py`
(close drops a foreign re-check), `tests/test_config.py`,
`tests/test_settings_sources.py` `TestSaveForeignRunsSetting`, and the real-host
`tests/comfyui/image-attribution.spec.mjs` case "an output of a workflow without a
PromptManager node creates and links its prompt; a second run reuses it" (ComfyUI's
own `CLIPTextEncode`, `KSampler` and `SaveImage`), and "each branch of a
shared-prefix workflow without a PromptManager node links to its own prompt after the
run" (two branches, one save prefix: the recorded saver names each file's branch).
The fixture route's GET also lists `foreign`, the waiting foreign files.
## Linked prompt text (#343)
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
@@ -6085,26 +5928,8 @@ decide through the same `own_counts`.
- A cached node counts at job start like the hook: a job failing later still counts it.
A node that executes after its replay in the same job (RAM-pressure freed the entry)
skips its own count but still consumes the pending first-use catch-up.
- No backfill: earlier cached jobs keep their counts.
- History (#403): the receipt also keeps the raw, prepend and append text the node gave
the history capture. For a cached node whose graph inference leaves a field unknown,
the capture takes it from the receipt matching the cache entry
(`CachedRuns.recorded_fields`), as `inferred` / `graph_semantics`: the node did not run
in this job, the value follows from the run key (the whole upstream closure) plus the
cache proof. The job then joins the prompt's history scope, gets the
`positive_prompt_id` (and Pair) hint, and an image of it can be attributed through its
run, so the history page's "tracked runs" equals the Library count from the restart on.
Fail closed (fields stay unknown, as before): no matching receipt, a receipt that
disagrees with a field the graph knows, executions with equal outputs that recorded
different fields (a conflict that stays for that run key), a field over 64 KiB or
invalid Unicode, more than one run's history (1 MiB) in one receipt, or a full 8 MiB
field budget (least recently used receipts give up their fields first; their counting
stays). No new evidence vocabulary, so older builds still open the database.
- Still different: jobs before the restart (no backfill); list batches (per item in the
Library, once per job in history); text with leading or trailing whitespace (the
Library stores it stripped, history matches the exact raw text, #438); a job in which the
node executes after its replay (RAM-pressure freed the entry: two samples, an
ambiguous capture); jobs while the history writer is fenced (restore, library move).
- No backfill: earlier cached jobs keep their counts. The history page's "tracked runs"
can stay below the Library count for these prompts (#403).
- Tests: `tests/test_cached_runs.py` (whole jobs with the real nodes, a real database,
fake host caches); `tests/comfyui/execution-text-link.spec.mjs` "#347" cases on the
managed host: PreviewAny into each node kind, 3 jobs changing only the sampler seed,
@@ -6112,10 +5937,7 @@ decide through the same `own_counts`.
modes, A, B, A from the owned `PMCycledText` under LRU, typed text, a downstream
failure, a "run selected output" job that does not need the node, and a rewire to the
negative input. The RAM-pressure eviction path cannot be forced on a real host and is
covered by the Python tests. Since #403 these cases also check the history page's
tracked runs equal the Library count, and `tests/test_execution_history_hooks.py`
(`CachedRuntimeHistoryTests`) runs the real history hook and writer with a real
`CachedRuns`.
covered by the Python tests.
```sh
python3 -m unittest discover -s tests -p "test_cached_runs.py"
@@ -6214,12 +6036,10 @@ Context cases verify A,A,B as three jobs/two combinations/one positive snapshot
when every positive field matches; all five registered PM variants; exact raw
whitespace and Unicode; known empty negatives; Text negative roles and crossed
pairs; fully cached repeats after trigger configuration changes; successful source
text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); unsupported
text before downstream failure/interruption; unknown cached dynamic raw; unsupported
transforms and duplicate list invocation ambiguity. Fresh values use
`runtime_return`, cached effective STRING outputs use `host_cache_output`, and
submitted literals use graph inference. A cached dynamic raw is unknown unless the
#347 receipt of the execution the cache entry comes from proves it; it is then
`inferred` / `graph_semantics` (graph identity plus a same-process cache receipt, #403). SQL NULL means unknown; empty text is
submitted literals use graph inference. SQL NULL means unknown; empty text is
known. Text is never trimmed or normalized by capture. Cache availability and
inferred sampler roles prove neither sampler execution nor consumption, and list
indices do not identify generated images. External trigger configuration changes
+1 -6
View File
@@ -275,12 +275,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
self._record_cached_item(
unique_id,
(encoding_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (encoding_text,), storage_text)
return result
@classmethod
+3 -13
View File
@@ -243,24 +243,14 @@ class PromptManagerBase:
return False
def _record_cached_item(
self,
unique_id: Any,
outputs: tuple,
text: Any,
negative_text: Any = None,
fields: Optional[tuple] = None,
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
) -> None:
"""Remember this execution's outputs and stored text for cached jobs (#347).
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
``record_runtime_context`` for each output, so a cached job's history
can record them too (#403).
"""
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
if unique_id is None:
return
try:
self._cached_runs().record_item(
unique_id, outputs, stored_facts(text, negative_text), fields
unique_id, outputs, stored_facts(text, negative_text)
)
except Exception as error:
self.logger.debug(f"Could not record the cached-run receipt: {error}")
+1 -5
View File
@@ -198,11 +198,7 @@ class PromptManagerPair(PromptManagerFocused):
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
self._record_cached_item(
unique_id,
(positive, negative_text),
text,
negative_text,
fields=((text, prepend_text, append_text), (negative_text, "", "")),
unique_id, (positive, negative_text), text, negative_text
)
return result
+1 -6
View File
@@ -246,12 +246,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
self._record_cached_item(
unique_id,
(final_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
self._record_cached_item(unique_id, (final_text,), storage_text)
return result
@classmethod
-2
View File
@@ -213,8 +213,6 @@ def public_config():
"max_image_age_days": GalleryConfig.MAX_IMAGE_AGE_DAYS,
"max_concurrent_processing": GalleryConfig.MAX_CONCURRENT_PROCESSING,
"metadata_extraction_timeout": GalleryConfig.METADATA_EXTRACTION_TIMEOUT,
# config.json gallery.monitoring.save_foreign_runs (#247)
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
},
"integrations": {
"lora_manager_enabled": bool(IntegrationConfig.LORA_MANAGER_ENABLED),
+2 -9
View File
@@ -8,8 +8,7 @@ is shared with the LoRA integration's save.
GET also reports the image sources and the monitor (``sources.py``). A save
that changes the sources runs as a ``source_change`` registry hold, so it is
refused while a record-changing job (a scan) runs. ``monitoring_enabled``
takes effect at the next restart; ``save_foreign_runs`` (#247) at once, since
the monitor reads it for every file. ``POST /prompt_manager/settings/validate-source``
takes effect at the next restart. ``POST /prompt_manager/settings/validate-source``
checks one path and writes nothing.
"""
@@ -44,7 +43,6 @@ _SETTINGS_FILE_KEYS = {
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
"WORKER_THREADS": ("performance", "worker_threads"),
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
"SAVE_FOREIGN_RUNS": ("gallery", "monitoring", "save_foreign_runs"),
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
@@ -60,7 +58,7 @@ _SETTINGS_FILE_KEYS = {
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED", "SAVE_FOREIGN_RUNS"})
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
# Attributes that live on AutotagConfig, named without the AUTOTAG_ prefix.
_AUTOTAG_SETTINGS = frozenset(
name for name in _SETTINGS_FILE_KEYS if name.startswith("AUTOTAG_")
@@ -266,7 +264,6 @@ class SettingsRoutesMixin:
"thumbnail_encoders": encoders,
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
@@ -629,10 +626,6 @@ class SettingsRoutesMixin:
if not isinstance(data["monitoring_enabled"], bool):
raise ValueError("monitoring_enabled must be true or false")
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
if "save_foreign_runs" in data:
if not isinstance(data["save_foreign_runs"], bool):
raise ValueError("save_foreign_runs must be true or false")
updates["SAVE_FOREIGN_RUNS"] = data["save_foreign_runs"]
if "safety_backup" in data:
if not isinstance(data["safety_backup"], bool):
raise ValueError("safety_backup must be true or false")
-13
View File
@@ -194,8 +194,6 @@ class GalleryConfig:
Attributes:
MONITORING_ENABLED (bool): Enable/disable automatic image monitoring
SAVE_FOREIGN_RUNS (bool): The monitor stores the certain prompt of an
output whose workflow has no PromptManager node (#247)
MONITORING_DIRECTORIES (List[str]): Directories to monitor for new images
SUPPORTED_EXTENSIONS (List[str]): Image file extensions to process
PROCESSING_DELAY (float): Delay in seconds before processing new files
@@ -212,8 +210,6 @@ class GalleryConfig:
# Image monitoring settings
MONITORING_ENABLED = True
# Read by the monitor for every file, so a save applies without a restart
SAVE_FOREIGN_RUNS = True
MONITORING_DIRECTORIES = [] # Auto-detect if empty
SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
PROCESSING_DELAY = 2.0 # Seconds to wait before processing new files
@@ -255,7 +251,6 @@ class GalleryConfig:
return {
"monitoring": {
"enabled": cls.MONITORING_ENABLED,
"save_foreign_runs": cls.SAVE_FOREIGN_RUNS,
"directories": cls.MONITORING_DIRECTORIES,
"extensions": cls.SUPPORTED_EXTENSIONS,
"processing_delay": cls.PROCESSING_DELAY,
@@ -308,14 +303,6 @@ class GalleryConfig:
config_logger.warning(
"Ignoring gallery monitoring enabled: expected true or false"
)
if "save_foreign_runs" in monitoring:
if isinstance(monitoring["save_foreign_runs"], bool):
cls.SAVE_FOREIGN_RUNS = monitoring["save_foreign_runs"]
else:
config_logger.warning(
"Ignoring gallery monitoring save_foreign_runs: expected true "
"or false"
)
if "directories" in monitoring:
directories = cls._validated_directories(monitoring["directories"])
if directories is not None:
-44
View File
@@ -232,41 +232,6 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -715,13 +680,6 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -755,8 +713,6 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
+12 -20
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,13 +139,9 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section button')).toHaveCount(0);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
@@ -153,7 +149,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -172,20 +168,18 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
const tagRow=panel.locator('.detail-inner > .tags').last();
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -249,7 +243,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -296,14 +290,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
@@ -317,9 +311,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -475,7 +467,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
+1 -2
View File
@@ -92,8 +92,7 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...reference.toolbar};
// D153 (#447): production offers no "Highest rated".
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item);
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
+26 -52
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,8 +51,7 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
@@ -109,8 +108,7 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await settle(page);
}
@@ -131,14 +129,13 @@ async function withCollectionsButton(page,selector) {
}
/**
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
*/
async function plainViewerChips(page,sections) {
async function plainViewerChips(page) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -154,7 +151,7 @@ async function plainViewerChips(page,sections) {
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSections();await restoreSource();await restoreTargets();
await restoreSource();await restoreTargets();
};
}
@@ -172,7 +169,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
async function viewerOf(page,concept,mutation=null) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -181,7 +178,7 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -189,18 +186,15 @@ async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -264,8 +258,7 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -330,9 +323,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -362,17 +352,14 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
@@ -386,7 +373,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -396,9 +383,8 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -406,14 +392,13 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const reference=await inventory(page,true);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -438,17 +423,6 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();await settle(page);
}
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
+8 -19
View File
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
// D14: the Library appends its Oldest first after the concept's three sorts.
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
@@ -39,11 +39,11 @@ export function mapLibraryConcept(reference) {
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
tags:sortedTags(card.tags),
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own
// "4 runs" and "Used today". The two-line clamp is
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
// and "Used today". The two-line clamp is
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
footer:card.footer}));
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
return {...reference,library:{...library,toolbar,
// D35: the caption uses the singular for one.
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
@@ -139,20 +139,9 @@ async function sortConceptTags(page) {
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
}
/**
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
* Returns the concept's original footer texts.
*/
async function dropConceptRatings(page) {
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
}
async function openConceptLibrary(page,theme,motion) {
await openConcept(page,'library',theme,motion);
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
const footers=await dropConceptRatings(page);
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
await page.mouse.move(0,0);await decoded(page);await settle(page);
}
async function openProductionLibrary(page,theme,motion) {
@@ -306,7 +295,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today');
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
@@ -405,7 +394,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
const reference=await conceptInventory(page);
await openProductionLibrary(page,'dark','no-preference');
const actual=await inventoryOf(page,'#workspace-main');
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
});
@@ -25,10 +25,9 @@ export function inventoryOf(dialog) {
demo:Boolean(dialog.querySelector('.demo-note')),
};
}
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
// concept's "Rating" select is not drawn.
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
export function mapConcept(reference) {
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
+13 -38
View File
@@ -162,6 +162,7 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview) {
@@ -177,8 +178,8 @@ function mapOverviewConcept(reference, overview) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -287,10 +288,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
expect(diffInventory(expected, await measure())).toEqual([]);
});
/**
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
function mapStatisticsConcept(reference, statistics) {
const [runs] = reference.tables;
return {
@@ -298,11 +296,10 @@ function mapStatisticsConcept(reference, statistics) {
// No sample note (prototype copy); a second table follows the first.
body: ['table', 'table'],
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
],
};
}
@@ -617,21 +614,10 @@ const WATCHED = new Set(['watching', 'stopping']);
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
label: 'Refresh' };
// D158 (#247): the row production adds directly below "Watch for new outputs", in that row's markup.
const FOREIGN_RUNS = { id: 'foreign-runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.' };
/** D158: the concept's "Watch for new outputs" row as the inserted "Save prompts from other workflows" row. */
function foreignRunsRow(monitoringRow, settings) {
return { ...monitoringRow, text: [['label', FOREIGN_RUNS.label, FOREIGN_RUNS.id], ['p', FOREIGN_RUNS.description, null]],
control: { ...monitoringRow.control, id: FOREIGN_RUNS.id, checked: settings.save_foreign_runs } };
}
/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */
function mapSourcesConcept(reference, { settings, sources }) {
const [sample] = reference.sources;
const scan = sample.actions[0];
const rowAt = reference.surface.tree.indexOf('div.setting-row');
return {
...reference,
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
@@ -639,15 +625,13 @@ function mapSourcesConcept(reference, { settings, sources }) {
sources: sources.effective.map(source => ({ ...sample, title: source.label,
description: `${source.path} · ${WATCH_TEXT[source.watch]}`,
actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })),
rows: reference.rows.flatMap(row => {
if (row.control.id === 'monitoring') return [{ ...row, control: { ...row.control, checked: settings.monitoring_enabled } },
foreignRunsRow(row, settings)];
rows: reference.rows.map(row => {
if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } };
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } };
}),
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
// D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
@@ -701,12 +685,10 @@ test('sources comparison detects mutations', async ({ page, request }) => {
const expected = mapSourcesConcept(concept, server);
const production = await productionSources(page, 'dark', { notice: true });
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs only in the real sources, the D98 operation, the D158 row (which moves the D113
// range to the third row) and the D113 range.
// Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range.
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort())
.toEqual(['operations.2', 'operations.length', 'rows.1', 'rows.2', 'rows.length', 'sources.0', 'sources.1',
'surface.tree'].sort());
.toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort());
const measure = () => page.evaluate(measureSources, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed notice and a removed source row.
@@ -720,13 +702,6 @@ test('sources comparison detects mutations', async ({ page, request }) => {
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; });
expect(await paths()).toEqual(['rows.0.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; });
// D158: the inserted row is compared too: a missing one and a changed label are caught.
const undoForeign = await mutate(page, { remove: ['#workspace-main .setting-row:has(#foreign-runs)'] });
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
await undoForeign();
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save other prompts'; });
expect(await paths()).toEqual(['rows.1.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save prompts from other workflows'; });
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; });
expect(await paths()).toEqual(['heading.action.text']);
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; });
+4 -16
View File
@@ -363,26 +363,14 @@ test('a list read superseded by another read still opens a collection made since
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
// hash change, and the page would then wait on the timer's read alone (#459).
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await renameHeld;
return route.fallback();
});
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBe(1);
releaseRename();
// The rename landed (the sidebar row has the new name), so the count read is now due.
await expect(row(page, 'Landscape grids')).toHaveCount(1);
await expect.poll(() => hold.held.length).toBe(2);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
-19
View File
@@ -495,25 +495,6 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
+3 -4
View File
@@ -897,8 +897,7 @@ test('images toolbar and filter row match the concept controls', async ({ page,
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
const row = main.locator('.filter-row');
await expect(row.locator(':scope > *')).toHaveCount(5);
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
@@ -917,8 +916,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
await search(page).fill('');
await expect(cards(page)).toHaveCount(2);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
await sortBy(page).selectOption('name-desc');
await expect(sortBy(page)).toHaveValue('name-desc');
await sortBy(page).selectOption('rating');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await sortBy(page).selectOption('newest');
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
await expect(cards(page)).toHaveCount(1);
+18 -25
View File
@@ -9,9 +9,8 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
const editorDialog=page=>page.locator('dialog.library-editor');
test.beforeEach(async({page,request,browserName})=>{
@@ -66,8 +65,9 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
await expect(dialog.getByRole('region')).toHaveCount(0);
await expect(field(dialog,'Category')).toHaveValue('Portraits');
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
await expect(field(dialog,'Rating')).toHaveValue('4');
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
@@ -82,20 +82,19 @@ test('editor modes persist through reload with selected saved negative',async({p
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
await field(dialog,'Tags, separated by commas').fill('studio, moody');
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const saved=(await detail(request,f.positive_id)).positive;
// D153: the edit never sends a rating, so the stored 4 stays.
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
await page.reload();await mountEditor(page);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await button(dialog,'Create text revision').click();
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
@@ -107,15 +106,12 @@ test('editor modes persist through reload with selected saved negative',async({p
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
@@ -124,9 +120,9 @@ test('editor modes persist through reload with selected saved negative',async({p
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
expect(page.writes).toHaveLength(before);
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
@@ -138,7 +134,6 @@ test('editor modes persist through reload with selected saved negative',async({p
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
await expect(page.locator('#editor-trigger')).toBeFocused();
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
});
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
@@ -809,20 +804,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
const f=page.fixture;await page.goto(`${workspace}#/library`);
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
@@ -830,8 +825,6 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
expect(page.writes).toHaveLength(1);
// D153: neither the reuse nor the kept draft touches the stored rating.
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
});
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
@@ -839,7 +832,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
@@ -855,13 +848,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
await field(dialog,'Notes').fill(f.metadata.notes);
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
};
await reuse('','blank category negative');
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
await reuse('Landscapes','different category negative');
await expect(toast(page)).toHaveText(UNAPPLIED);
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
});
+9 -22
View File
@@ -152,25 +152,13 @@ test('failed library reads retry while late search responses cannot overwrite ne
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
});
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
expect(sorts).toEqual(['newest']);
});
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
await page.goto(route);await expect(cards(page)).toHaveCount(3);
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
@@ -251,8 +239,7 @@ test('a sort change atomically discards old row geometry and renders changed par
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
await expect(cards(page)).toHaveCount(3);
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
// D153: a stored rating of 5 still draws no stars.
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
});
@@ -458,8 +445,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
for(const item of data.items) {
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
const footer=card.locator('.card-footer > span');
// D153: no stars or "Rated N out of 5", whatever the stored rating.
await expect(footer.locator('> *')).toHaveCount(0);
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
}
@@ -611,7 +598,7 @@ async function patchShelf(page,patch) {
await route.fulfill({response,json:body});
});
}
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
/** The footer line as seen: everything but its visually hidden rating sentence. */
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
@@ -647,13 +634,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
const footer=card.locator('.card-footer');
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
await expect(footer.locator('> span > *')).toHaveCount(0);
expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today');
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
// Never used: the used label alone; no negatives still reads as a count.
// Unrated and never used: the used label alone; no negatives still reads as a count.
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
@@ -230,31 +230,6 @@ test('a view stored under older rules still lists and opens with its usable valu
await expect(promptCards(page)).toHaveCount(1);
});
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
const url = new URL(item.url());
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
});
await page.goto(`${workspace}#/saved/${images.id}`);
await expect(heading(page)).toHaveText('Rated cats');
await expect(sortBy(page)).toHaveValue('newest');
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
await expect(search(page)).toHaveValue('cat');
await expect(imageCards(page)).toHaveCount(1);
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
await expect(heading(page)).toHaveText('Rated dogs');
await expect(sortBy(page)).toHaveValue('newest');
await expect(search(page)).toHaveValue('dog');
await expect(promptCards(page)).toHaveCount(1);
expect(listings.length).toBeGreaterThan(1);
expect(listings).not.toContain('rating');
});
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
@@ -268,71 +268,6 @@ test('Watch for new outputs saves monitoring_enabled; a restart applies it', asy
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
});
test('D158: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => {
try {
const layout = await openSources(page);
const rows = layout.locator('section.surface > .setting-row');
await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']);
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
const foreignRow = rows.filter({ has: page.locator('#foreign-runs') });
await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.');
await expect(foreign).toBeChecked();
await expect(foreign).toBeEnabled();
// Keyboard on desktop, a tap on the touch project.
if (testInfo.project.name === 'touch') await foreign.tap();
else {
await foreign.focus();
await page.keyboard.press('Space');
}
await expect(foreign).not.toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
const response = await saved;
expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false }));
expect((await response.json()).restart_required_fields).toEqual([]);
// Applied live: the monitor reads it for every file, so no restart is named.
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false);
// With Watch for new outputs drafted off nothing is watched: the row is disabled and its note says why.
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
await monitoring.uncheck();
await expect(foreign).toBeDisabled();
const note = foreignRow.locator('p.small');
await expect(note).toHaveText('Applies while Watch for new outputs is on.');
await expect(foreign).toHaveAttribute('aria-describedby', await note.getAttribute('id'));
await monitoring.check();
await expect(foreign).toBeEnabled();
await expect(note).toHaveCount(0);
await expect(foreign).not.toHaveAttribute('aria-describedby', /./);
await expect(statusOf(page)).toHaveText('No unsaved changes.');
} finally {
// The server is shared by the spec's tests: put the default back whatever happened.
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('D158: saved off while the monitor still watches until a restart, the foreign row can still be set (D128)', async ({ page, request }) => {
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
try {
const layout = await openSources(page);
await layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }).uncheck();
await saveButton(page).click();
await expect(sourceList(page).locator('.operation p').first()).toHaveText('output · watching until restart');
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
await expect(foreign).toBeEnabled();
await expect(layout.locator('.setting-row').filter({ has: page.locator('#foreign-runs') }).locator('p.small'))
.toHaveText('Applies while Watch for new outputs is on.');
await foreign.uncheck();
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
await saveButton(page).click();
expect((await saved).postDataJSON()).toEqual({ save_foreign_runs: false, expected_revision: expect.any(String) });
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
} finally {
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
}
});
test('worker range max is cpu_count', async ({ page, request }) => {
const { settings } = await serverJson(request, SETTINGS_URL);
const layout = await openSources(page);
+6 -5
View File
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
@@ -373,12 +373,13 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
['Rated images', number(statistics.rated_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+20 -62
View File
@@ -458,32 +458,27 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -495,10 +490,9 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -508,53 +502,18 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const row = details.locator('.tags').last();
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -611,7 +570,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -630,13 +589,12 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -649,7 +607,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -809,7 +767,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -834,7 +792,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
+9 -13
View File
@@ -263,18 +263,15 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -372,7 +369,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -380,9 +377,8 @@ test('cached dynamic raw comes from the reused execution while literal affixes a
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+2 -20
View File
@@ -287,14 +287,6 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -317,8 +309,6 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -339,7 +329,6 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -363,9 +352,6 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -404,14 +390,10 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect(cachedContext.positive_prompt_id).toBeNull();
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+2 -9
View File
@@ -334,15 +334,8 @@ async def image_monitor(request: web.Request) -> web.Response:
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
async def image_monitor_state(request: web.Request) -> web.Response:
"""File names of images waiting for their run's recorded outputs: keyless
ones (``awaiting``) and foreign ones (``foreign``, #247)."""
"""File names of keyless images waiting for their run's recorded outputs."""
monitor = _plugin_module("utils.image_monitor")._monitor_instance
handler = getattr(monitor, "handler", None)
waiting = handler.awaiting_history() if handler is not None else []
foreign = handler.awaiting_foreign() if handler is not None else []
return web.json_response(
{
"awaiting": [os.path.basename(p) for p in waiting],
"foreign": [os.path.basename(p) for p in foreign],
}
)
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})
+1 -64
View File
@@ -1,7 +1,6 @@
// #342 on the real pinned host: the image monitor links an output only to a prompt
// that provably produced it. An unknown node (PMHistoryCached) stands in for the
// maintainer's LLM; KSampler is the fixture's deterministic stand-in. #247: an output
// of a workflow without a PromptManager node gets its own prompt.
// maintainer's LLM; KSampler is the fixture's deterministic stand-in.
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
@@ -142,68 +141,6 @@ test('an output saved without metadata links once its recorded run names a store
}
});
// #247: no PromptManager node at all, only ComfyUI's own text encoders, sampler and
// saver (the fixture's inputs and decoder stand in for the model files).
function foreignGraph({ text, seed = 1, prefix }) {
return {
'10': { class_type: 'PMHistoryInputs', inputs: {} },
'20': { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } },
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2], positive: ['20', 0],
negative: ['30', 0], seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } },
'70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } },
};
}
test('an output of a workflow without a PromptManager node creates and links its prompt; a second run reuses it', async ({ execution }, testInfo) => {
const text = `foreign lighthouse at dusk ${testInfo.project.name} ${randomUUID()}`;
const first = await saved(execution.request, foreignGraph({ text, prefix: 'foreign/run' }));
await linkedTo(execution, first, text);
// Only the seed changes: a new file, the same text, so the same stored prompt.
const second = await saved(execution.request, foreignGraph({ text, seed: 2, prefix: 'foreign/run' }));
await linkedTo(execution, second, text);
const state = await readLinks(execution.databasePath, [first, second], [text]);
expect(state).toEqual({ links: { [first]: text, [second]: text }, stored: { [text]: true } });
// Decided from the graph at settle: nothing waits for the run's recorded outputs.
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
});
// CUJ 1: two branches saved under one prefix, so neither the graph nor the file name
// names a file's branch; the run's recorded saver does, once its outputs are sealed.
function sharedPrefixGraph(texts) {
const nodes = {
'10': { class_type: 'PMHistoryInputs', inputs: {} },
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
};
texts.forEach((text, index) => {
const base = (index + 1) * 100;
nodes[String(base)] = { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } };
nodes[String(base + 1)] = { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
positive: [String(base), 0], negative: ['30', 0], seed: index + 1, steps: 1, cfg: 1, sampler_name: 'euler',
scheduler: 'normal', denoise: 1 } };
nodes[String(base + 2)] = { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: [String(base + 1), 0] } };
nodes[String(base + 3)] = { class_type: 'SaveImage', inputs: { images: [String(base + 2), 0], filename_prefix: 'foreign/dataset' } };
});
return nodes;
}
test('each branch of a shared-prefix workflow without a PromptManager node links to its own prompt after the run', async ({ execution }, testInfo) => {
const tag = `${testInfo.project.name} ${randomUUID()}`;
const texts = [`dataset branch one harbor ${tag}`, `dataset branch two meadow ${tag}`];
const id = randomUUID();
const response = await execution.request.post('/prompt', { data: { prompt: sharedPrefixGraph(texts), prompt_id: id } });
expect(response.ok()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.messages?.some(([kind]) => kind === 'execution_success') || false;
}, { timeout: 15000 }).toBe(true);
const files = ['103', '203'].map(node => item.outputs[node].images[0].filename);
for (const [index, file] of files.entries()) await linkedTo(execution, file, texts[index]);
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
});
// #332 on the real host: the migration ran at startup and the image category routes
// set, list and remove an own category on a generated output.
test('an output gets its own image category and loses it again', async ({ execution }, testInfo) => {
-1
View File
@@ -192,7 +192,6 @@
"settings.monitoring_enabled",
"settings.result_timeout",
"settings.safety_backup",
"settings.save_foreign_runs",
"settings.thumbnail_edge",
"settings.thumbnail_format",
"settings.thumbnail_quality",
+5 -62
View File
@@ -209,18 +209,16 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags, each without repeats within its source', async () => {
test('renders current image tags before linked tags without duplicates', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -238,61 +236,6 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
+4 -5
View File
@@ -211,12 +211,11 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
});
test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', async () => {
test('stream sends category, tags and the rating sort and validates them', async () => {
const {createImageStream}=await source;const calls=[];
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc');
await assert.rejects(stream.reset({sort:'rating'}),/sort/i);
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
await stream.reset({category:'',tags:[]});
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
@@ -278,7 +277,7 @@ test('the caption date names the newest result day under Newest first only', asy
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
assert.equal(captionDate([],'newest',now),'');
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
});
+20 -64
View File
@@ -117,9 +117,8 @@ test('new prompt from Images waits for a fresh options epoch', async () => {
assert.equal(sentBody.expected_epoch, EPOCH);
assert.deepEqual(sentBody, {
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
});
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
assert.equal(controller.state.phase, 'complete');
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'create');
@@ -231,11 +230,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => {
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'kept');
controller.setField('rating', 5);
await controller.submit();
phases.push(controller.state.phase);
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
assert.equal(controller.state.draft.notes, 'kept', 'draft preserved');
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
assert.equal(controller.state.receipt, null);
controller.dispose();
}
@@ -307,7 +306,7 @@ test('confirmed save refresh retry is read only', async () => {
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let failReads = 1;
const committed = [], reconciled = [];
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
const base = library({ write: () => response(200, receipt) });
const server = harness(call => {
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
@@ -316,6 +315,7 @@ test('confirmed save refresh retry is read only', async () => {
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
await controller.initialize();
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
await controller.submit();
assert.equal(controller.state.phase, 'saved-refresh-error');
@@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => {
test('legacy title fallback is not an edit', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) };
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
const server = harness(library({ write: () => response(200, receipt) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
@@ -346,6 +346,7 @@ test('legacy title fallback is not an edit', async () => {
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
assert.equal(controller.state.draft.tags, 'soft, warm');
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
controller.setField('tags', 'warm, soft,');
controller.setField('category', ' Studio ');
@@ -356,7 +357,7 @@ test('legacy title fallback is not an edit', async () => {
const sentBody = write.body;
assert.equal(sentBody.expected_epoch, EPOCH);
assert.equal(sentBody.if_match, TOKEN);
assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' });
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await unchanged.initialize();
@@ -420,12 +421,12 @@ test('metadata conflict reload keeps changed fields for review and never replays
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } })
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'mine');
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 });
controller.setField('rating', '5');
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
await controller.submit();
assert.equal(controller.state.phase, 'conflict');
await controller.submit();
@@ -433,20 +434,20 @@ test('metadata conflict reload keeps changed fields for review and never replays
await controller.reloadOwner({ keepDraft: true });
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.draft.notes, 'mine', 'changed field kept');
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
assert.equal(controller.state.draft.category, 'Elsewhere');
assert.equal(controller.state.draft.notes, 'other writer');
assert.equal(server.writes().length, 1);
await controller.submit();
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
assert.equal(controller.state.phase, 'complete');
controller.dispose();
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await discard.initialize();
discard.setField('notes', 'dropped');
discard.setField('rating', 1);
await discard.reloadOwner({ keepDraft: false });
assert.equal(discard.state.draft.notes, '');
assert.equal(discard.state.draft.rating, 3);
assert.equal(discard.state.reviewRequired, false);
discard.dispose();
});
@@ -456,12 +457,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: () => {
owner = positive({ notes: 'applied', edit_token: TOKEN2 });
owner = positive({ rating: 5, edit_token: TOKEN2 });
return Promise.reject(new TypeError('Failed to fetch'));
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'applied');
controller.setField('rating', 5);
await controller.submit();
assert.equal(controller.state.phase, 'unknown-outcome');
await controller.retryUnconfirmed();
@@ -569,10 +570,8 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b
await controller.submit();
assert.deepEqual(server.writes()[0].body, {
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' },
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
});
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
controller.dispose();
});
@@ -590,9 +589,7 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn
assert.equal(controller.state.phase, 'ready');
assert.match(controller.state.error.message, /Title/);
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
// D153: the editor has no rating field.
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
assert.throws(() => controller.setField('notes', 4), TypeError);
assert.throws(() => controller.setField('rating', 7), TypeError);
controller.setField('title', 'T');
await controller.submit();
assert.equal(controller.state.phase, 'write-error');
@@ -810,44 +807,3 @@ test('a blank category reused against an uncategorized owner is not unapplied me
controller.dispose();
}
});
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
let owner = positive({ rating: 4, title: 'Portrait' });
const server = harness(call => library({ owner, write: write => {
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
} })(call));
const reconciled = [];
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onReconciled: result => reconciled.push(result) });
await controller.initialize();
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
controller.setField('title', 'Portrait, edited');
await controller.submit();
const [write] = server.writes();
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.receipt.positive.rating, 4);
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
controller.dispose();
});
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
const { createLibraryEditorController } = await source;
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
const server = harness(created(() => response(200, reused)));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
await controller.submit();
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.outcome.metadataNotApplied, false);
controller.dispose();
});
+5 -4
View File
@@ -46,8 +46,9 @@ test('used label boundaries in UTC and local time', async () => {
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
});
// D153 (#447): cards show no rating, so the layout module has no rating label.
test('the layout module offers no rating label', async () => {
const layout = await source;
assert.equal(Object.hasOwn(layout, 'ratingLabel'), false);
test('rating label omits unrated', async () => {
const { ratingLabel } = await source;
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
});
+2 -8
View File
@@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'name');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
});
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
@@ -114,9 +114,3 @@ test('the stream keeps the first page\'s storage epoch and refuses a later page
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
test('the shelf no longer sorts by rating (D153)',async()=>{
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
});
+2 -12
View File
@@ -72,9 +72,9 @@ test('the listing keeps usable views in order and normalizes their state', async
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
// #331 D140: the open collection round-trips; a missing one is ''.
@@ -215,13 +215,3 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest');
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
});
+3 -3
View File
@@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
});
test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => {
test('statistics rows are the real runs and ratings, then the library totals', async () => {
const { categoryRows, totalsRows } = await overviewModule;
const statistics = {
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
@@ -149,8 +149,8 @@ test('statistics rows are the real runs, then the library totals, without rating
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
-17
View File
@@ -157,20 +157,3 @@ test('a valid configured source missing from the effective list is another spell
assert.deepEqual(sourceRows({ sources, saved, current: saved }).rows.at(-1), { key: 'output/.', title: 'output',
description: 'output/. · same folder as another source', scan: null, remove: 'output/.' });
});
test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabled while monitoring is off (#247)', async () => {
const { FOREIGN_RUNS, foreignRunsState } = await sourcesModule;
assert.deepEqual(FOREIGN_RUNS, { id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
off: 'Applies while Watch for new outputs is on.' });
assert.deepEqual(foreignRunsState({ value: true, monitoring: true }), { disabled: false, checked: true, note: null });
assert.deepEqual(foreignRunsState({ value: false, monitoring: true }), { disabled: false, checked: false, note: null });
// Monitoring drafted or saved off: nothing is watched, so the row says why it is disabled and keeps its value.
assert.deepEqual(foreignRunsState({ value: true, monitoring: false }), { disabled: true, checked: true, note: FOREIGN_RUNS.off });
// Not read yet (or the host read failed): disabled without a value, the host rows' own note applies.
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: undefined }), { disabled: true, checked: null, note: null });
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: false }), { disabled: true, checked: null, note: null });
// Saved off while the running monitor still watches until the restart (D128 `stopping`): it can still be set.
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
});
-1
View File
@@ -85,7 +85,6 @@ CONFIG = {
"max_image_age_days",
"max_concurrent_processing",
"metadata_extraction_timeout",
"save_foreign_runs",
},
"integrations": {"lora_manager_enabled", "has_civitai_api_key"},
}
+1 -193
View File
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
return counts
class ReceiptCase(unittest.TestCase):
class TestReceipts(unittest.TestCase):
def setUp(self):
self.context = ("j1", None)
self.runs = CachedRuns(
@@ -686,8 +686,6 @@ class ReceiptCase(unittest.TestCase):
def keys(self):
return list(self.runs._receipts)
class TestReceipts(ReceiptCase):
def test_least_recently_used_run_keys_are_dropped(self):
self.begin(1)
for name in ("a", "b", "c"):
@@ -728,196 +726,6 @@ class TestReceipts(ReceiptCase):
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
class TestRecordedFields(ReceiptCase):
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
F = (("raw a", " pre ", ""),)
def record(self, item_id, name="a", output="x", fields=F, facts=None):
self.begin(item_id)
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
self.runs.end_attempt(item_id)
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
self.begin(item_id, prompt_id)
return self.runs.recorded_fields(prompt_id, name, values)
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
self.record(1)
self.assertEqual(self.lookup(), [self.F])
def test_list_items_return_their_own_fields_in_order(self):
self.begin(1)
for index, text in enumerate(("x", "y")):
self.context = ("j1", index)
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
)
def test_fails_closed(self):
self.record(1)
for case, kwargs in (
("other output", dict(values=(("changed",),))),
("other node", dict(name="b")),
("unknown node", dict(name="zzz")),
("no values", dict(values=())),
("malformed values", dict(values="x")),
):
with self.subTest(case=case):
self.assertIsNone(self.lookup(**kwargs))
with self.subTest(case="another job"):
self.begin(9, "j9")
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
with self.subTest(case="no job"):
self.runs.end_attempt(9)
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
def test_an_execution_without_fields_gives_none(self):
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
with self.subTest(fields=fields):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=fields)
self.assertIsNone(self.lookup())
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
self.record(1)
self.lookup()
self.assertFalse(self.runs.replayed("a"))
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
key = prompt_node_key(self.g, "a")
self.record(1, fields=(("x ", "", ""),))
self.record(2, fields=(("x", "", ""),))
# Counting dedupes by outputs as before; the fields cannot name either
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertIsNone(self.lookup())
self.assertIsNotNone(self.runs._match(key, (("x",),)))
# The conflict stays: a later execution cannot tell which text it was
self.record(3, fields=(("x", "", ""),))
self.assertIsNone(self.lookup())
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
key = prompt_node_key(self.g, "a")
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
items = self.runs._match(key, (("x",),))
self.assertEqual([item.facts for item in items], [Facts("new")])
def test_equal_outputs_and_fields_still_replace(self):
key = prompt_node_key(self.g, "a")
self.record(1)
self.record(2)
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertEqual(self.lookup(), [self.F])
def test_fields_dropped_by_the_budget_do_not_conflict(self):
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
self.record(1)
self.runs._max_field_bytes = 100
self.record(2)
self.assertEqual(self.lookup(), [self.F])
def test_oversized_or_invalid_fields_are_not_kept(self):
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
with self.subTest(size=len(raw)):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=((raw, "", ""),))
self.assertIsNone(self.lookup())
self.assertEqual(self.runs._field_bytes, 0)
def test_old_receipts_give_up_their_fields_to_new_ones(self):
self.runs = CachedRuns(
context=lambda: self.context, max_keys=3, max_field_bytes=10
)
self.record(1, "a", fields=(("123456", "", ""),))
self.record(2, "b", fields=(("123456", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertIsNone(self.lookup("a"))
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
# "a" still counts
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
# Larger than the whole budget: not kept, nothing else given up
self.record(3, "c", fields=(("12345678901", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
def test_one_receipt_holds_at_most_one_runs_history(self):
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
self.begin(1)
for index, raw in enumerate(("12345", "12345")):
self.context = ("j1", index)
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(self.runs._field_bytes, 5)
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
def test_the_byte_count_follows_replacement_and_eviction(self):
self.begin(1)
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
self.assertEqual(self.runs._field_bytes, 3)
self.runs.end_attempt(1)
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
self.record(item_id, output=raw, fields=((raw, "", ""),))
# max_receipts=2: only the receipts of jobs 4 and 3 remain
self.assertEqual(self.runs._field_bytes, 5)
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
self.record(item_id, name, fields=(("1", "", ""),))
self.assertEqual(self.runs._field_bytes, 2)
self.assertEqual(
self.runs._field_bytes,
sum(module._bytes(r) for r in self.runs._receipts.values()),
)
class TestCachedJobsRecordFields(JobCase):
"""The real nodes keep the texts they gave the history capture (#403)."""
def lookup(self, g, node_id="pm", prompt_id="job-x"):
self.item_id += 1
self.runs.begin_attempt(
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
)
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
found = self.runs.recorded_fields(prompt_id, node_id, values)
self.runs.end_attempt(self.item_id)
return found
def test_each_node_kind_keeps_its_runtime_fields(self):
for kind in (
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
):
with self.subTest(kind=kind):
g = graph(kind)
self.first(g, text=f" {LLM} ")
self.assertEqual(
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
)
def test_pair_keeps_positive_and_negative_fields(self):
g = graph("PromptManagerPair", negative_text="typed negative")
self.first(g, negative="typed negative")
self.assertEqual(
self.lookup(
graph("PromptManagerPair", negative_text="typed negative", seed=2)
),
[((LLM, "", ""), ("typed negative", "", ""))],
)
def test_a_disabled_node_keeps_no_fields(self):
g = graph()
g["pm"]["inputs"]["save_to_library"] = False
self.first(g)
g2 = graph(seed=2)
g2["pm"]["inputs"]["save_to_library"] = False
self.assertIsNone(self.lookup(g2))
class TestCacheReader(unittest.TestCase):
def setUp(self):
patcher = mock.patch.dict(sys.modules, host_modules())
-22
View File
@@ -59,28 +59,6 @@ class TestGalleryConfig(unittest.TestCase):
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
self.assertEqual(GalleryConfig.PROCESSING_DELAY, 5.0)
def test_save_foreign_runs_defaults_on_and_only_booleans_apply(self):
"""#247: gallery.monitoring.save_foreign_runs, default true."""
self.assertIs(
GalleryConfig.get_config()["monitoring"]["save_foreign_runs"], True
)
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": False}})
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
for junk in ("true", 1, None):
with self.subTest(value=junk):
with self.assertLogs("prompt_manager.config", "WARNING") as logs:
GalleryConfig.update_config(
{"monitoring": {"save_foreign_runs": junk}}
)
self.assertIn(
"Ignoring gallery monitoring save_foreign_runs: expected true "
"or false",
"\n".join(logs.output),
)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": True}})
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
def test_update_tracking(self):
GalleryConfig.update_config({"tracking": {"prompt_timeout": 300}})
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, 300)
+3 -143
View File
@@ -100,19 +100,14 @@ class HostTests(unittest.TestCase):
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
def begin(
self,
kind="PromptManager",
raw=" raw ",
prepend=" pre ",
append=" post ",
negative="negative",
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
):
g = {
"p": {
"class_type": kind,
"inputs": {
"text": raw,
"negative_text": negative,
"negative_text": "negative",
"prepend_text": prepend,
"append_text": append,
},
@@ -130,7 +125,7 @@ class HostTests(unittest.TestCase):
},
},
}
if any(type(value) is list for value in (raw, prepend, negative)):
if type(raw) is list or type(prepend) is list:
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
g["stateful"] = {
"class_type": "PromptManagerText",
@@ -450,138 +445,3 @@ class HostTests(unittest.TestCase):
ref = install_and_remove()
gc.collect()
self.assertIsNone(ref())
# -- #403: a cached runtime text takes its fields from the reused execution
class Receipts:
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
def __init__(self, fields):
self.fields, self.calls = fields, []
def recorded_fields(self, prompt_id, node_id, values):
self.calls.append((prompt_id, node_id, values))
return self.fields
def observe_with(self, receipts, outputs, **begin):
self.begin(**begin)
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
self.observe("execution_start")
host.observe_cached_context(
self.executor,
"execution_cached",
{"prompt_id": JOB, "nodes": ["p"]},
receipts=receipts,
)
return self.seal()
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
self.assertEqual(result.raw.value, "LLM raw")
self.assertEqual(
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
)
self.assertEqual(result.effective.value, "cached")
self.assertEqual(result.effective.source, "host_cache_output")
# Fields the graph already knows are never replaced
self.assertEqual(result.prepend.value, " pre ")
self.assertEqual(result.prepend.source, "graph_literal")
self.assertEqual(result.append.value, " post ")
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
).positive
self.assertEqual(result.prepend.value, "LLM pre")
self.assertEqual(result.prepend.source, "graph_semantics")
self.assertEqual(result.raw.value, " raw ")
self.assertEqual(result.raw.source, "graph_literal")
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.prepend.value, " pre ")
def test_pair_runtime_negative_takes_the_negative_fields(self):
receipts = self.Receipts(
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["LLM negative"]],
kind="PromptManagerPair",
negative=["dynamic", 0],
)
self.assertEqual(result.negative.raw.value, "LLM negative")
self.assertEqual(result.negative.raw.source, "graph_semantics")
self.assertEqual(result.positive.raw.value, " raw ")
self.assertEqual(result.positive.raw.source, "graph_literal")
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
receipts = self.Receipts([(("other", "other", "other"),)])
result = self.observe_with(receipts, [object(), ["cached"]]).positive
self.assertEqual(
(result.raw.value, result.prepend.value, result.append.value),
(" raw ", " pre ", " post "),
)
self.assertEqual(result.raw.source, "graph_literal")
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
with self.subTest(fields=fields):
result = self.observe_with(
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_a_failing_receipt_lookup_keeps_the_capture(self):
class Broken:
def recorded_fields(self, *args):
raise RuntimeError("boom")
result = self.observe_with(
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_pair_slots_take_their_own_receipt_fields(self):
receipts = self.Receipts(
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["cached negative"]],
kind="PromptManagerPair",
raw=["dynamic", 0],
)
self.assertEqual(
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
)
self.assertEqual(result.positive.raw.value, "LLM positive")
self.assertEqual(result.positive.effective.value, "cached positive")
# The negative editor is typed: the graph's value stays
self.assertEqual(result.negative.raw.value, "negative")
self.assertEqual(result.negative.raw.source, "graph_literal")
def test_default_receipts_are_the_process_cached_runs(self):
from utils import cached_runs
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
with patch.object(cached_runs, "CACHED_RUNS", receipts):
self.begin(raw=["dynamic", 0])
self.executor.caches.outputs.entries["p"] = Entry(
None, [object(), ["cached"]]
)
self.observe("execution_start")
self.observe("execution_cached", nodes=["p"])
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
+1 -150
View File
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
class HooksCase(unittest.TestCase):
class HistoryHooksTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
@@ -122,8 +122,6 @@ class HooksCase(unittest.TestCase):
def drain(self, registration):
drain_writer(registration.writer, clock=time.monotonic)
class HistoryHooksTests(HooksCase):
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
try:
from tests.test_init_package import (
@@ -769,150 +767,3 @@ class HistoryHooksTests(HooksCase):
self.assertEqual(
calls, ["execution_start", "execution_cached", "execution_cached"]
)
class CachedRuntimeHistoryTests(HooksCase):
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
The real history hook and writer, a real ``CachedRuns`` and the real host
adapter over fakes of ComfyUI's executor, cache and executing context.
"""
TEXT = "a lighthouse the LLM wrote"
GRAPH = {
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
}
def setUp(self):
super().setUp()
from utils import cached_runs
from utils.cached_runs import CachedRuns
self.context = None
self.runs = CachedRuns(context=lambda: self.context or (None, None))
self.cache_entries = {}
entries = self.cache_entries
execution = types.ModuleType("execution")
caching = types.ModuleType("comfy_execution.caching")
package = types.ModuleType("comfy_execution")
utils_module = types.ModuleType("comfy_execution.utils")
entry_type = namedtuple("CacheEntry", "ui outputs")
class PromptExecutor:
def add_message(self, event, data, broadcast):
return None
class HierarchicalCache:
def get_local(self, node_id):
return entries.get(node_id)
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
caching.HierarchicalCache = HierarchicalCache
package.caching, package.utils = caching, utils_module
utils_module.get_executing_context = lambda: (
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
if self.context
else None
)
self.entry_type = entry_type
modules = patch.dict(
sys.modules,
{
"execution": execution,
"comfy_execution": package,
"comfy_execution.caching": caching,
"comfy_execution.utils": utils_module,
},
)
modules.start()
self.addCleanup(modules.stop)
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
swap.start()
self.addCleanup(swap.stop)
self.executor = PromptExecutor()
self.executor.server = self.server
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
self.prompt_id = self.db.save_prompt(text=self.TEXT)
self.item_id = 0
def job(self, cached):
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
from uuid import uuid4
from utils.cached_runs import stored_facts
from utils.execution_context_capture import record_runtime_context
self.item_id += 1
job = str(uuid4())
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
self.queue.results.append(result)
self.queue.get()
self.runs.begin_attempt(result)
self.context = (job, 0)
self.executor.add_message("execution_start", {"prompt_id": job}, False)
if cached:
self.executor.add_message(
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
)
else:
# What PromptManager.encode_prompt records for this text
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
self.runs.record_item(
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
)
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
self.context = None
self.queue.task_done(self.item_id, {}, status(job=job))
self.runs.end_attempt(self.item_id)
def tracked_runs(self):
with self.db.recorded_history_snapshot():
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
registration = self.register()
for cached in (False, True, True):
self.job(cached)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 3)
rows = self.rows()
for row in rows:
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
self.assertEqual(
cached["evidence"]["positive"]["raw"],
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
)
self.assertEqual(
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
)
# The stored history passes the whole-database check older builds run too
from database.execution_history_schema import history_schema_error
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
# An image of a cached job can now be attributed through its run (#342)
from database.run_attribution import named_prompt
self.assertEqual(
[named_prompt(self.db, row["run_id"]) for row in rows],
[self.prompt_id] * 3,
)
def test_without_a_receipt_a_cached_job_stays_untracked(self):
registration = self.register()
self.job(False)
self.runs._receipts.clear() # as after a restart with a provider entry
self.job(True)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 1)
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
self.assertIsNone(cached[0]["positive"]["raw"])
self.assertIsNone(cached[0]["positive_prompt_id"])
from database.run_attribution import named_prompt
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
-330
View File
@@ -1,330 +0,0 @@
"""#247 PR 2 x #90: the monitor's foreign path never stores switched-off text.
A file whose run's prompt has Save to library off is the opted-out path's,
not the foreign path's; a foreign graph whose ladder decision is
``not_saved`` (a switched-off node the #90 walk does not reach) stores
nothing at settle, at the re-check after the seal, or from recorded history,
and leaves no wait behind.
"""
import os
import unittest
from tests import saver_anchor_graphs as shapes
from tests import test_run_attribution as attribution
from tests import test_save_to_library as save_tests
from tests.test_pair_scan import sampled_graph
from utils.foreign_runs import ForeignRecheck
from utils.hashing import generate_prompt_hash
from py.config import GalleryConfig # after tests: it stubs the host
LOGGER = "prompt_manager.image_monitor"
OFF_TEXT = "SECRET OFF TEXT"
SKIPPED = "Save to library is off for this run's prompt, skipping image"
# The class dict, not the class: a TestCase bound here would run again
Fixture = vars(attribution.TestMonitorForeignRuns)
def off_behind(middle="MyNode", key="value"):
"""A switched-off PromptManagerText reaching a core CLIPTextEncode through
``middle``'s ``key`` input: the #90 walk does not follow ``key``."""
graph = save_tests.run_graph()
graph["1"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
graph["8"] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
}
graph["3"]["inputs"]["positive"] = ["8", 0]
return graph
def off_text_unwired():
"""Only history can decide; the legacy answer is a dangling switched-off
node's own text (the ladder says ``not_saved`` once it may ask history)."""
graph = sampled_graph("unused", "blurry")
graph["1"]["inputs"]["text"] = ["30", 0]
graph["30"] = {"class_type": "VendorPromptMaker", "inputs": {"prompt": OFF_TEXT}}
graph["40"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
return graph
class TestForeignPathKeepsSaveToLibrary(attribution.RecordedRuns, unittest.TestCase):
setUp = Fixture["setUp"]
close_databases = Fixture["close_databases"]
wait_for = Fixture["wait_for"]
image = Fixture["image"]
linked_text = Fixture["linked_text"]
key = Fixture["key"]
prompt_count = Fixture["prompt_count"]
settled = Fixture["settled"]
def reference(self, path):
return {
"folder_type": "output",
"subfolder": "dataset",
"filename": os.path.basename(path),
}
def seal(self, path, positives=(), nodes=("9",)):
"""Record one run reporting ``path`` (a new run id for every call)."""
self.runs = getattr(self, "runs", -1) + 1
self.record_run(
self.db,
self.output,
[("dataset", os.path.basename(path))],
list(positives),
item=self.runs,
nodes=list(nodes),
)
def recheck(self, path):
"""The re-check the seal schedules, run through the monitor's entry."""
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path, ForeignRecheck(self.reference(path)))
return "\n".join(logs.output)
def assert_nothing_stored(self, path):
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT)))
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertEqual(list(self.handler._foreign_due), [])
def test_q1_the_only_prompt_manager_node_off_is_opted_out_not_foreign(self):
graph = save_tests.run_graph(save=False)
# The switched-off positive is the only PromptManager node
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry"}}
sampler_less = {
"1": {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
}
for name, graph in (("sampled", graph), ("sampler-less", sampler_less)):
with self.subTest(name=name):
self.metadata = {"prompt": graph}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "opted_out")
path = self.image(f"IMG_GEN_{name}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q2_an_off_text_node_behind_a_pass_through_stores_nothing(self):
for middle, key in (("MyNode", "value"), ("easy showAnything", "anything")):
with self.subTest(middle=middle):
self.metadata = {"prompt": off_behind(middle, key)}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "foreign")
path = self.image(f"IMG_GEN_{key}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q2_a_wired_switch_behind_a_pass_through_stores_nothing(self):
graph = off_behind()
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
self.metadata = {"prompt": graph}
path = self.image("IMG_GEN_wired_00001_.png")
wired = "Save to library is wired from a node the image cannot show"
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(wired, "\n".join(logs.output))
self.seal(path)
self.assertTrue(self.settled())
self.assertIn(wired, self.recheck(path))
self.assert_nothing_stored(path)
def test_q3_an_undecided_not_saved_waits_for_the_record_then_skips(self):
"""No record yet: a not_saved of the undecided step is not final (a
recorded saver may resolve another branch), so the file waits for its
run; the decision with the record stores nothing and logs the skip."""
wired = off_text_unwired()
wired["40"]["inputs"]["save_to_library"] = ["20", 0]
wired["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
cases = (
("off", off_text_unwired(), SKIPPED),
("wired", wired, "Save to library is wired from a node"),
)
for name, graph, line in cases:
with self.subTest(name=name):
self.metadata = {"prompt": graph}
path = self.image(f"vendor_{name}_00001_.png")
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
with self.assertLogs(LOGGER, "INFO") as logs:
self.seal(path)
self.assertTrue(
self.wait_for(lambda: not self.handler.awaiting_foreign())
)
self.assertTrue(self.settled())
self.assertIn(line, "\n".join(logs.output))
self.assert_nothing_stored(path)
def test_q3_an_early_not_saved_never_waits_even_with_a_saver_branch(self):
"""Review: a switched-off node feeding one branch's positive is the
ladder's early, final not_saved (PR 1's #90 rule covers every
sampler's positive), so the file never waits and nothing is stored,
not even the plain branch the recorded saver names."""
graph = shapes.shared_prefix()
graph["20"]["inputs"]["text"] = ["50", 0]
graph["50"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
self.metadata = {"prompt": graph}
path = self.image("IMG_GEN_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path, nodes=("13",))
self.assertTrue(self.settled())
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
self.assertIsNone(
self.db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[0]))
)
def test_q3_an_off_node_in_the_recorded_saver_branch_is_never_foreign(self):
"""PR 3's parity case: a switched-off node feeds the very branch the
recorded saver names (saver "23" is branch 2). A literal switch is the
opted-out path's; a wired one is the node's own run's (#90), never the
foreign path's. Nothing is stored at settle or after the run, and no
file waits for a foreign re-check."""
cases = (
("off", False, "opted_out", SKIPPED),
("wired", ["52", 0], "unknown", "No certain prompt for"),
)
for name, save, state, line in cases:
with self.subTest(name=name):
graph = shapes.shared_prefix()
graph["50"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": save},
}
graph["20"]["inputs"]["text"] = ["50", 0]
if isinstance(save, list):
graph["52"] = {"class_type": "SomeBoolNode", "inputs": {}}
self.metadata = {"prompt": graph}
found, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(found, state)
path = self.image(f"IMG_GEN_{name}_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(line, "\n".join(logs.output))
self.assert_nothing_stored(path)
self.seal(path, positives=[OFF_TEXT], nodes=("23",))
self.assertTrue(self.settled())
self.handler.process_new_image(path)
self.assert_nothing_stored(path)
self.assertIsNone(
self.db.get_prompt_by_hash(
generate_prompt_hash(shapes.BRANCH_TEXTS[1])
)
)
def test_q4_an_off_node_on_the_negative_only_still_links_the_positive(self):
positive = "a lighthouse on a cliff at dusk"
plain = sampled_graph(positive, "unused")
plain["2"] = {
"class_type": "PromptManager",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
behind = sampled_graph(positive, "unused")
behind["2"]["inputs"]["text"] = ["7", 0]
behind["7"] = {
"class_type": "PromptManagerText",
"inputs": {"text": OFF_TEXT, "save_to_library": False},
}
for name, graph in (("direct", plain), ("behind an encoder", behind)):
with self.subTest(name=name):
self.metadata = {"prompt": graph}
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
self.assertEqual(state, "foreign")
path = self.image(f"plain_{len(name)}_00001_.png")
self.handler.process_new_image(path)
self.assertEqual(self.linked_text(path), positive)
self.assertIsNone(
self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT))
)
pairs = self.db.list_prompt_pairs()["pairs"]
self.assertEqual(pairs, [])
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_q5_recorded_history_never_brings_back_switched_off_text(self):
"""Even a recorded run whose contexts hold the off text (an older
history row) cannot name it, at settle or at the re-check."""
cases = {"pass-through": off_behind(), "dangling": off_text_unwired()}
for name, graph in cases.items():
with self.subTest(name=name):
self.metadata = {"prompt": graph}
path = self.image(f"IMG_GEN_{len(name)}_00001_.png")
self.seal(path, positives=[OFF_TEXT], nodes=("9",))
self.handler.process_new_image(path)
self.assert_nothing_stored(path)
self.assertIn(SKIPPED, self.recheck(path))
self.assert_nothing_stored(path)
def test_q6_with_foreign_saving_off_save_to_library_is_unchanged(self):
GalleryConfig.SAVE_FOREIGN_RUNS = False
opted_out = save_tests.run_graph(save=False)
self.metadata = {"prompt": opted_out}
skipped = self.image("IMG_GEN_off_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(skipped)
self.assertIn(SKIPPED, "\n".join(logs.output))
self.metadata = {"prompt": off_behind()}
foreign = self.image("IMG_GEN_foreign_00001_.png")
with self.assertLogs(LOGGER, "INFO") as logs:
self.handler.process_new_image(foreign)
self.assertIn("No PromptManager prompt in workflow", "\n".join(logs.output))
for path in (skipped, foreign):
self.assert_nothing_stored(path)
# A switched-on PromptManager positive still links by its metadata
stored = self.db.save_prompt(
text=save_tests.POSITIVE,
prompt_hash=generate_prompt_hash(save_tests.POSITIVE),
)
self.metadata = {"prompt": save_tests.run_graph(save=True)}
on = self.image("IMG_GEN_on_00001_.png")
self.handler.process_new_image(on)
self.assertEqual(self.linked_text(on), save_tests.POSITIVE)
self.assertEqual(
self.db.get_prompt_by_hash(generate_prompt_hash(save_tests.POSITIVE))["id"],
stored,
)
def test_the_shared_prefix_dataset_still_links_after_the_seal(self):
"""Regression guard (passes before the fix): the wait still works."""
self.metadata = {"prompt": shapes.shared_prefix()}
path = self.image("IMG_GEN_00001_.png")
self.handler.process_new_image(path)
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
self.seal(path, nodes=("13",))
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
if __name__ == "__main__":
unittest.main()
+1 -24
View File
@@ -172,40 +172,17 @@ class TestForeignWorkflows(LinkingTestCase):
self.tracker.set_current_prompt(text, {"prompt_id": prompt_id})
return prompt_id
def _linked_text(self):
row = (
self.db.model.get_connection()
.execute(
"SELECT p.text FROM generated_images g "
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
(self.image,),
)
.fetchone()
)
return row[0] if row else None
def test_foreign_image_leaves_the_batch_queue_aligned(self):
"""#247: it links to its own prompt and never takes a queue entry."""
first = self._queue_batch_item("item one")
self.assertIsNone(self._process({"prompt": foreign_graph()}))
self.assertEqual(self.linked, [])
self.assertEqual(self.tracker.pop_next_prompt()["id"], first)
self.assertIsNone(self.tracker.pop_next_prompt())
self.assertEqual(self._linked_text(), "a cat")
prompt = self.db.get_prompt_by_hash(generate_prompt_hash("a cat"))
self.assertEqual(prompt["run_count"] or 0, 0)
def test_a_foreign_image_is_linked_only_by_the_ladder(self):
def test_foreign_image_is_not_linked_by_the_later_fallbacks_either(self):
self._queue_batch_item("item one") # live tracker and recent prompt exist
untraceable = foreign_graph()
untraceable["9"]["inputs"]["text"] = ["30", 0]
untraceable["30"] = {"class_type": "VendorPromptMaker", "inputs": {}}
self.assertIsNone(self._process({"prompt": untraceable}))
self.assertEqual(self.linked, [])
self.assertIsNone(self._linked_text())
self.assertIsNone(self._process({"prompt": foreign_graph()}))
self.assertEqual(self.linked, [])
self.assertEqual(self._linked_text(), "a cat")
def test_negative_only_workflow_is_foreign(self):
self._save("blurry")
-5
View File
@@ -597,11 +597,6 @@ class TestHandlerScheduling(unittest.TestCase):
self.handler._schedule_recheck(path)
self.assertIsNone(self.handler._worker)
self.assertEqual(list(self.handler._history_due), [])
# #247: a foreign file's sealed re-check is dropped the same way
self.handler._await_foreign(path)
self.assertEqual(self.handler.awaiting_foreign(), [])
self.handler._schedule_recheck(path, {"filename": "late.png"})
self.assertEqual(list(self.handler._foreign_due), [])
self.assertEqual(self._live_workers() - before, set())
def test_a_sealed_run_starts_a_worker_on_an_open_handler(self):
+1 -4
View File
@@ -99,10 +99,7 @@ class TestPairUsageTracking(unittest.TestCase):
unique_id="1",
)
node.cached_runs.record_item.assert_called_once_with(
"1",
("cat", ""),
Facts(usage_tracking.generate_prompt_hash("cat"), None),
(("cat", "", ""), ("", "", "")),
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
)
def test_validation_rejection_never_counts_or_saves(self):
+2 -8
View File
@@ -308,11 +308,7 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
)
node.cached_runs.record_item.assert_called_once_with(
"1",
("pre typed text",),
Facts(generate_prompt_hash("typed text")),
# #403: the texts the history capture got, for a cached job's history
(("typed text", "pre", ""),),
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
)
# Hook-visible text: the node counts nothing itself
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
@@ -323,9 +319,7 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
node = self._node()
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
node.cached_runs.record_item.assert_called_once_with(
"1", (" ",), Facts(None), ((" ", "", ""),)
)
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
if __name__ == "__main__":
-465
View File
@@ -33,7 +33,6 @@ from utils.execution_outputs import (
snapshot_output_roots,
)
from utils.hashing import generate_prompt_hash
from py.config import GalleryConfig
from utils.image_monitor import ImageGenerationHandler
STYLE = "windows" if os.name == "nt" else "posix"
@@ -772,470 +771,6 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
"""#247: outputs of workflows without a PromptManager node, in the monitor."""
def setUp(self):
history_tests.HistoryDatabaseTests.setUp(self)
self.db = history_tests.HistoryDatabaseTests.database(self)
self.output = str(Path(self.temp.name) / "output")
os.makedirs(os.path.join(self.output, "dataset"))
self.tracker = make_tracker(self.db)
self.handler = ImageGenerationHandler(self.db, self.tracker)
self.handler.processing_delay = 0
self.handler.settle_interval = 0.01
self.addCleanup(close_handler, self, self.handler)
self.metadata = {"prompt": shapes.shared_prefix()}
saved = GalleryConfig.SAVE_FOREIGN_RUNS
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
GalleryConfig.SAVE_FOREIGN_RUNS = True
patches = (
mock.patch(
"utils.image_monitor.host_output_directory", return_value=self.output
),
mock.patch(
"database.run_attribution.host_output_directory",
return_value=self.output,
),
mock.patch.object(
self.handler.metadata_extractor,
"extract_metadata",
side_effect=lambda path: self.metadata,
),
)
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
close_databases = history_tests.HistoryDatabaseTests.close_databases
wait_for = TestMonitorHistoryRecheck.wait_for
def image(self, name, subfolder="dataset"):
path = os.path.join(self.output, subfolder, name)
Path(path).write_bytes(b"png")
return path
def linked_text(self, path):
row = (
self.db.model.get_connection()
.execute(
"SELECT p.text FROM generated_images g "
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
(str(path),),
)
.fetchone()
)
return row[0] if row else None
def key(self, path):
return self.handler._awaiting_key(path)
def prompt_count(self):
connection = self.db.model.get_connection()
return connection.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
def settled(self):
return self.wait_for(lambda: self.handler.pending_count() == 0)
def test_each_dataset_file_links_to_its_branch_once_the_run_ends(self):
"""CUJ 1: unlinked while the run is open, its own branch after the seal."""
paths = [self.image(f"IMG_GEN_0000{n}_.png") for n in (1, 2)]
for path in paths:
self.handler.process_new_image(path)
self.assertEqual([self.linked_text(path) for path in paths], [None, None])
self.assertEqual(
self.handler.awaiting_foreign(), [self.key(path) for path in paths]
)
self.assertEqual(self.handler.awaiting_history(), [])
self.record_run(
self.db,
self.output,
[("dataset", os.path.basename(path)) for path in paths],
[],
nodes=["13", "23"],
)
self.assertTrue(self.wait_for(lambda: all(map(self.linked_text, paths))))
self.assertTrue(self.settled())
self.assertEqual(
[self.linked_text(path) for path in paths], list(shapes.BRANCH_TEXTS[:2])
)
self.assertEqual(self.handler.awaiting_foreign(), [])
prompt = self.db.get_prompt_by_hash(
generate_prompt_hash(shapes.BRANCH_TEXTS[0])
)
self.assertEqual(prompt["category"], "scanned")
self.assertIn("Saved from a workflow run: IMG_GEN_00001_.png", prompt["notes"])
def test_a_run_sealed_before_the_file_settles_links_it_at_once(self):
path = self.image("IMG_GEN_00003_.png")
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00003_.png")], [], nodes=["33"]
)
self.handler.process_new_image(path)
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[2])
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_a_name_two_runs_reported_stays_unlinked(self):
"""CUJ 3: the file was overwritten; no run proves its saver."""
path = self.image("IMG_GEN_00004_.png")
reported = [("dataset", "IMG_GEN_00004_.png")]
self.record_run(self.db, self.output, reported, [], nodes=["13"])
self.record_run(self.db, self.output, reported, [], item=1, nodes=["23"])
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
reference = {"folder_type": "output", "subfolder": "dataset"}
reference["filename"] = "IMG_GEN_00004_.png"
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler._recheck_foreign(path, reference)
self.assertIn(
"No certain prompt for IMG_GEN_00004_.png after its run ended; "
"left unlinked",
"\n".join(logs.output),
)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
def test_the_setting_off_stores_nothing_at_settle_or_after_the_run(self):
"""CUJ 4: read for every file, so it applies without a restart."""
GalleryConfig.SAVE_FOREIGN_RUNS = False
skipped = self.image("IMG_GEN_00005_.png")
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(skipped)
self.assertIn(
"No PromptManager prompt in workflow, skipping image",
"\n".join(logs.output),
)
self.assertEqual(self.handler.awaiting_foreign(), [])
GalleryConfig.SAVE_FOREIGN_RUNS = True
waiting = self.image("IMG_GEN_00006_.png")
self.handler.process_new_image(waiting)
self.assertEqual(self.handler.awaiting_foreign(), [self.key(waiting)])
GalleryConfig.SAVE_FOREIGN_RUNS = False
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00006_.png")], [], nodes=["13"]
)
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
self.assertTrue(self.settled())
self.assertEqual(
[self.linked_text(p) for p in (skipped, waiting)], [None, None]
)
self.assertEqual(self.prompt_count(), 0)
def test_a_plain_foreign_workflow_links_at_settle_and_counts_no_run(self):
"""CUJ 2: the default profile decides; the tracker queue is untouched."""
text = "a lighthouse on a cliff at dusk"
self.metadata = {"prompt": sampled_graph(text, "blurry")}
queued = self.db.save_prompt(
text="queued item", prompt_hash=generate_prompt_hash("queued item")
)
self.tracker.set_current_prompt("queued item", {"prompt_id": queued})
first, second = self.image("plain_00001_.png"), self.image("plain_00002_.png")
self.handler.process_new_image(first)
self.handler.process_new_image(second)
self.assertEqual(
[self.linked_text(first), self.linked_text(second)], [text, text]
)
prompt = self.db.get_prompt_by_hash(generate_prompt_hash(text))
self.assertEqual(prompt["run_count"] or 0, 0)
self.assertIsNone(prompt.get("last_used_at"))
self.assertEqual(self.tracker.pop_next_prompt()["id"], queued)
self.assertEqual(self.handler.awaiting_foreign(), [])
def test_the_sealed_reference_spells_the_file_as_history_recorded_it(self):
"""Windows: the watcher's spelling differs in case from the run's report."""
Path(self.output, "Dataset").mkdir()
recorded = self.image("IMG_GEN_00007_.png", subfolder="Dataset")
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
Path(seen).write_bytes(b"png")
folded = staticmethod(lambda path: os.path.realpath(path).lower())
with mock.patch.object(ImageGenerationHandler, "_awaiting_key", folded):
self.handler.process_new_image(seen)
self.assertIsNone(self.linked_text(seen))
self.record_run(
self.db,
self.output,
[("Dataset", os.path.basename(recorded))],
[],
nodes=["43"],
)
self.assertTrue(self.wait_for(lambda: self.linked_text(seen)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(seen), shapes.BRANCH_TEXTS[3])
def test_a_full_foreign_set_never_evicts_a_waiting_keyless_file(self):
from utils import image_monitor
keyless = self.image("bare_00001_.png", subfolder="")
with mock.patch.object(
self.handler.metadata_extractor, "extract_metadata", return_value=None
):
self.handler.process_new_image(keyless)
paths = [self.image(f"IMG_GEN_1000{n}_.png") for n in range(3)]
with (
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 2),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
for path in paths:
self.handler.process_new_image(path)
self.assertEqual(
self.handler.awaiting_foreign(), [self.key(p) for p in paths[1:]]
)
self.assertEqual(self.handler.awaiting_history(), [self.key(keyless)])
self.assertIn(
"Too many files wait for their run; IMG_GEN_10000_.png left unlinked",
"\n".join(logs.output),
)
def test_an_expired_foreign_entry_is_logged_and_left_unlinked(self):
from utils import image_monitor
path = self.image("IMG_GEN_00008_.png")
self.handler.process_new_image(path)
later = time.monotonic() + image_monitor.AWAIT_HISTORY_SECONDS + 1
with (
mock.patch.object(image_monitor.time, "monotonic", return_value=later),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertIn(
"No recorded run for IMG_GEN_00008_.png; left unlinked",
"\n".join(logs.output),
)
self.assertIsNone(self.linked_text(path))
def test_a_seal_while_the_pending_set_is_full_is_rechecked_later(self):
from utils import image_monitor
path = self.image("IMG_GEN_00009_.png")
self.handler.process_new_image(path)
blocker = self.image("blocker.png", subfolder="")
release = threading.Event()
original = self.handler.process_new_image
def slow(image_path, prompt_snapshot=None):
if image_path == blocker:
release.wait(5)
return None
return original(image_path, prompt_snapshot=prompt_snapshot)
self.handler.process_new_image = slow
with mock.patch.object(image_monitor, "MAX_PENDING_IMAGES", 1):
self.assertTrue(self.handler.schedule(blocker))
self.assertTrue(self.wait_for(lambda: self.handler.pending_count() == 1))
self.record_run(
self.db,
self.output,
[("dataset", "IMG_GEN_00009_.png")],
[],
nodes=["13"],
)
self.assertEqual(list(self.handler._foreign_due), [path])
release.set()
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
def test_shutdown_forgets_waiting_and_due_foreign_files(self):
path = self.image("IMG_GEN_00010_.png")
self.handler.process_new_image(path)
self.handler._foreign_due[path] = {"filename": "IMG_GEN_00010_.png"}
self.handler.shutdown()
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertEqual(list(self.handler._foreign_due), [])
def link_count(self, path):
connection = self.db.model.get_connection()
return connection.execute(
"SELECT COUNT(*) FROM generated_images WHERE image_path = ?", (str(path),)
).fetchone()[0]
def test_a_file_linked_to_another_prompt_never_gets_a_second_link(self):
"""One prompt per file: the pair path and the positive path both refuse."""
other = self.db.save_prompt(
"an earlier prompt", generate_prompt_hash("an earlier prompt")
)
plain = self.image("plain_00004_.png")
self.db.link_image_to_prompt(other, plain)
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(plain)
self.assertIn("Foreign run: conflict", "\n".join(logs.output))
dataset = self.image("IMG_GEN_00011_.png")
self.db.link_image_to_prompt(other, dataset)
self.metadata = {"prompt": shapes.shared_prefix()}
self.record_run(
self.db, self.output, [("dataset", "IMG_GEN_00011_.png")], [], nodes=["13"]
)
self.handler.process_new_image(dataset)
for path in (plain, dataset):
self.assertEqual(self.linked_text(path), "an earlier prompt")
self.assertEqual(self.link_count(path), 1)
for text in ("a quiet harbor", shapes.BRANCH_TEXTS[0]):
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(text)))
def test_the_file_size_is_read_only_when_the_ladder_needs_it(self):
"""PR 1's lazy size: a foreign file decided without a store reads none."""
# Foreign (a core encoder only) and no API graph: never, at step 3
encoder = {"type": "CLIPTextEncode", "inputs": [], "widgets_values": ["x"]}
self.metadata = {"text_encoder_nodes": [encoder]}
path = self.image("bare_00002_.png", subfolder="")
with mock.patch("utils.foreign_runs.os.path.getsize") as size:
self.handler.process_new_image(path)
size.assert_not_called()
def untraceable(self):
"""Unresolved and only the legacy parser names a text (an unknown node)."""
graph = sampled_graph("unused", "blurry")
graph["1"]["inputs"]["text"] = ["30", 0]
graph["30"] = {
"class_type": "VendorPromptMaker",
"inputs": {"prompt": "a misty valley at dawn"},
}
return {"prompt": graph}
def test_no_record_at_settle_keeps_the_file_waiting_for_the_legacy_floor(self):
"""Review: a lookup that found no record is not the run's answer yet."""
self.metadata = self.untraceable()
path = self.image("vendor_00001_.png")
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
self.record_run(self.db, self.output, [("dataset", "vendor_00001_.png")], [])
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), "a misty valley at dawn")
def test_a_record_found_at_settle_without_a_certain_prompt_ends_the_wait(self):
"""Review: sealed before settle, still ambiguous: no seal is coming."""
path = self.image("IMG_GEN_00012_.png")
# One run reported it from two savers: a record, but no saver
reported = [("dataset", "IMG_GEN_00012_.png")] * 2
self.record_run(self.db, self.output, reported, [], nodes=["13", "23"])
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
self.handler.process_new_image(path)
self.assertIn(
"No certain prompt for IMG_GEN_00012_.png; left unlinked",
"\n".join(logs.output),
)
self.assertEqual(self.handler.awaiting_foreign(), [])
self.assertIsNone(self.linked_text(path))
def test_monitoring_saved_off_stops_foreign_writes_at_settle_and_recheck(self):
"""Review: the observer runs until a restart; nothing is written."""
enabled = GalleryConfig.MONITORING_ENABLED
self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled)
GalleryConfig.MONITORING_ENABLED = False
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
plain = self.image("plain_00005_.png")
self.handler.process_new_image(plain)
self.assertIsNone(self.linked_text(plain))
GalleryConfig.MONITORING_ENABLED = True
self.metadata = {"prompt": shapes.shared_prefix()}
waiting = self.image("IMG_GEN_00013_.png")
self.handler.process_new_image(waiting)
GalleryConfig.MONITORING_ENABLED = False
reference = {"folder_type": "output", "subfolder": "dataset"}
reference["filename"] = "IMG_GEN_00013_.png"
self.record_run(
self.db, self.output, [("dataset", reference["filename"])], [], nodes=["13"]
)
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
self.assertTrue(self.settled())
self.handler._recheck_foreign(waiting, reference)
self.assertIsNone(self.linked_text(waiting))
self.assertEqual(self.prompt_count(), 0)
def test_the_setting_is_read_again_just_before_the_store(self):
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
path = self.image("plain_00006_.png")
real = __import__("utils.foreign_runs", fromlist=["x"]).decide_foreign
def decide_then_switch_off(*args, **kwargs):
decision = real(*args, **kwargs)
GalleryConfig.SAVE_FOREIGN_RUNS = False
return decision
with mock.patch(
"utils.image_monitor.decide_foreign", side_effect=decide_then_switch_off
):
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
self.assertEqual(self.prompt_count(), 0)
def test_a_seal_between_registering_and_the_settle_lookup_links_once(self):
"""Regression guard (passes before the review fixes): one link only."""
path = self.image("IMG_GEN_00014_.png")
real = __import__("utils.foreign_runs", fromlist=["x"]).history_lookup
def sealed_first(db, image_path, reference=None):
lookup = real(db, image_path, reference)
def run_then_look():
if reference is None: # the settle lookup: the run seals now
self.record_run(
self.db,
self.output,
[("dataset", "IMG_GEN_00014_.png")],
[],
nodes=["13"],
)
return lookup()
return run_then_look
with mock.patch("utils.image_monitor.history_lookup", side_effect=sealed_first):
self.assertTrue(self.handler.schedule(path))
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
self.assertTrue(self.settled())
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
self.assertEqual(self.link_count(path), 1)
self.assertEqual(list(self.handler._foreign_due), [])
def test_a_drained_recheck_never_drops_a_newer_reference(self):
path = self.image("IMG_GEN_00015_.png")
older, newer = {"filename": "old"}, {"filename": "new"}
self.handler._foreign_due[path] = older
real = self.handler.schedule
def schedule(image_path, snapshot=None):
# The run is sealed again while the older one is scheduled
self.handler._foreign_due[image_path] = newer
return real(image_path, snapshot)
self.handler.schedule = schedule
with mock.patch.object(self.handler, "process_new_image"):
self.handler._drain_history_due()
self.assertIs(self.handler._foreign_due[path], newer)
def test_an_evicted_due_recheck_is_logged(self):
from utils import image_monitor
self.handler.schedule = lambda *args, **kwargs: False
with (
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 1),
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
):
for name in ("a_00001_.png", "b_00001_.png"):
self.handler._schedule_recheck(self.image(name), {"filename": name})
self.assertIn(
"Too many files wait for their run; a_00001_.png left unlinked",
"\n".join(logs.output),
)
def test_a_failed_store_is_logged_and_leaves_the_file_unlinked(self):
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
path = self.image("plain_00003_.png")
with (
mock.patch(
"utils.foreign_runs.ingest_resolved_pair",
side_effect=sqlite3.OperationalError("locked"),
),
self.assertLogs("prompt_manager.image_monitor", "ERROR"),
):
self.handler.process_new_image(path)
self.assertIsNone(self.linked_text(path))
class TestBoundedLookup(RecordedRuns, unittest.TestCase):
"""M5: history lookups are batched, and the history schema is unchanged."""
-37
View File
@@ -323,43 +323,6 @@ class TestMonitoringSetting(SourcesTestCase):
self.assertIs(GalleryConfig.MONITORING_ENABLED, False)
class TestSaveForeignRunsSetting(SourcesTestCase):
"""#247 D-Q4: "Save prompts from other workflows", applied live."""
async def asyncSetUp(self):
await super().asyncSetUp()
saved = GalleryConfig.SAVE_FOREIGN_RUNS
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
GalleryConfig.SAVE_FOREIGN_RUNS = True
async def test_read_and_write_published_live_without_a_restart(self):
self.assertIs((await self._get())["settings"]["save_foreign_runs"], True)
self._install_monitor(FakeMonitor([self.output_dir]))
status, data = await self._post(
"/prompt_manager/settings", {"save_foreign_runs": False}
)
self.assertEqual(status, 200, data)
self.assertTrue(data["persisted"])
self.assertEqual(data["restart_required_fields"], [])
saved = self._read_config()
self.assertIs(saved["gallery"]["monitoring"]["save_foreign_runs"], False)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
self.assertIs((await self._get())["settings"]["save_foreign_runs"], False)
async def test_only_true_or_false_is_accepted(self):
self._write_config({"custom": {"keep": True}})
before = self._config_bytes()
for value in ("false", 0, None, []):
with self.subTest(value=value):
status, data = await self._post(
"/prompt_manager/settings", {"save_foreign_runs": value}
)
self.assertEqual(status, 400, data)
self.assertIn("save_foreign_runs must be true or false", data["error"])
self.assertEqual(self._config_bytes(), before)
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
class TestSourceChangeHold(SourcesTestCase):
async def test_root_save_refused_while_scan_job_runs(self):
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
+3 -3
View File
@@ -36,14 +36,14 @@ test('execution script discovers only the new matrix and preserves all 78 origin
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects),
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects),
// + #90 Save to library off (five node types), + #247 foreign workflows (image-attribution.spec.mjs, 2 tests x 2 projects)
assert.match(executions, /Total: 132 tests in 5 files/);
// + #90 Save to library off (five node types)
assert.match(executions, /Total: 128 tests in 5 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 5);
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
+28 -201
View File
@@ -15,14 +15,9 @@ role (positive, negative, which Pair outputs are used) and what the hook
already saw are recomputed from that job's own graph, and both paths count
through the same ``own_counts`` so they cannot drift.
The receipt also keeps the raw, prepend and append text the node gave the
execution history (#403), so the history capture of a cached job can record
the raw text the graph cannot show (``recorded_fields``) and the job becomes a
tracked run of its prompt, as it is a counted one.
Fail closed: when the cached text cannot be matched to a receipt recorded in
this process, nothing is counted (the behavior before #347) and the history
fields stay unknown. In memory only and bounded; starts no threads.
this process, nothing is counted (the behavior before #347). In memory only and
bounded; starts no threads.
"""
import hashlib
@@ -44,7 +39,6 @@ try:
from .queue_observers import subscribe_dequeue, subscribe_task_done
from .run_keys import prompt_node_key, upstream_nodes
from .usage_tracking import find_pair_id_by_hashes, survive_switch
from .execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.logging_config import get_logger
@@ -59,7 +53,6 @@ except ImportError:
from utils.queue_observers import subscribe_dequeue, subscribe_task_done
from utils.run_keys import prompt_node_key, upstream_nodes
from utils.usage_tracking import find_pair_id_by_hashes, survive_switch
from utils.execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
logger = get_logger("prompt_manager.cached_runs")
@@ -68,15 +61,9 @@ MAX_KEYS = 256 # run keys remembered (the same bound as MAX_KEYED_RUNS)
MAX_RECEIPTS = 4 # receipts per run key: a recurring upstream value (A, B, A)
MAX_ITEMS = 64 # list items per receipt (the cache reader's bound)
MAX_CACHED_NODES = 500 # the execution_cached guard the history capture uses
MAX_FIELD_TEXT_BYTES = 8 * 1024 * 1024 # history fields all receipts keep
MAX_RECEIPT_FIELD_BYTES = MAX_ATTEMPT_BYTES # one receipt: what one run's history holds
_EMPTY_HASH = generate_prompt_hash("")
Count = Tuple[Any, ...] # ("prompt", hash) or ("pair", positive, negative or None)
# (raw, prepend, append) per STRING slot, as given to record_runtime_context
Fields = Tuple[Tuple[str, str, str], ...]
# Executions with equal outputs recorded different fields: none can be named
_CONFLICT: Fields = ()
class Facts(NamedTuple):
@@ -109,8 +96,6 @@ class Seen(NamedTuple):
class _Item(NamedTuple):
outputs: Tuple[str, ...]
facts: Facts
fields: Optional[Fields] = None
field_bytes: int = 0
class _Receipt:
@@ -123,18 +108,6 @@ class _Receipt:
def outputs(self) -> Dict[int, Tuple[str, ...]]:
return {index: item.outputs for index, item in self.items.items()}
def field_bytes(self) -> int:
return sum(item.field_bytes for item in self.items.values())
def strip_fields(self) -> int:
"""Drop the history fields (counting is unchanged); the bytes freed."""
freed = 0
for index, item in list(self.items.items()):
if item.field_bytes:
freed += item.field_bytes
self.items[index] = item._replace(fields=None, field_bytes=0)
return freed
class _Attempt(NamedTuple):
item_id: int
@@ -144,22 +117,6 @@ class _Attempt(NamedTuple):
replayed: set
def _field_size(fields: Any, slots: int) -> Optional[int]:
"""UTF-8 size of well-formed fields for ``slots`` outputs, else None."""
if type(fields) is not tuple or len(fields) != slots:
return None
size = 0
for slot in fields:
if type(slot) is not tuple or len(slot) != 3:
return None
for value in slot:
try:
size += text_size(value, MAX_FIELD_BYTES) # the capture's own check
except ValueError:
return None
return size
def text_digest(value: str) -> str:
"""Digest of one returned STRING value, exactly as returned."""
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
@@ -288,14 +245,11 @@ class CachedRuns:
max_keys: int = MAX_KEYS,
max_receipts: int = MAX_RECEIPTS,
max_items: int = MAX_ITEMS,
max_field_bytes: int = MAX_FIELD_TEXT_BYTES,
context: Callable[[], Tuple[Optional[str], Optional[int]]] = _host_context,
) -> None:
self._max_keys = max_keys
self._max_receipts = max_receipts
self._max_items = max_items
self._max_field_bytes = max_field_bytes
self._field_bytes = 0 # UTF-8 bytes of the fields all receipts keep
self._context = context
self._receipts: "OrderedDict[str, List[_Receipt]]" = OrderedDict()
self._attempt: Optional[_Attempt] = None
@@ -330,10 +284,7 @@ class CachedRuns:
self._attempt = None
for run_key, receipts in self._receipts.items():
if receipts and receipts[0].item_id == item_id:
before = _bytes(receipts)
deduplicated = _deduplicated(receipts)
self._field_bytes += _bytes(deduplicated) - before
self._receipts[run_key] = deduplicated
self._receipts[run_key] = _deduplicated(receipts)
def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]:
"""The attempt the executing node belongs to, and its list index."""
@@ -354,19 +305,8 @@ class CachedRuns:
with self._lock:
return str(node_id) in attempt.replayed
def record_item(
self,
node_id: Any,
outputs: Tuple[Any, ...],
facts: Facts,
fields: Optional[Fields] = None,
) -> None:
"""Remember what this execution returned and was entitled to count.
``fields`` are the texts the node gave the execution history, one
``(raw, prepend, append)`` per output; kept only when well formed,
each within the history's field limit and the store within its budget.
"""
def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None:
"""Remember what this execution returned and was entitled to count."""
try:
attempt, index = self._current()
node = attempt.nodes.get(str(node_id)) if attempt else None
@@ -377,20 +317,14 @@ class CachedRuns:
return
if any(not isinstance(value, str) for value in outputs):
return
size = _field_size(fields, len(outputs)) if fields is not None else None
item = _Item(
tuple(text_digest(value) for value in outputs),
facts,
fields if size is not None else None,
size or 0,
)
item = _Item(tuple(text_digest(value) for value in outputs), facts)
self._store(node[1], attempt.item_id, index, item)
except Exception as error:
logger.debug(f"Could not record a cached-run receipt: {error}")
def _store(self, run_key: str, item_id: int, index: int, item: _Item) -> None:
with self._lock:
receipts = self._take(run_key)
receipts = self._receipts.pop(run_key, [])
if receipts and receipts[0].item_id == item_id:
receipt = receipts[0]
else:
@@ -400,44 +334,9 @@ class CachedRuns:
receipt = _Receipt(item_id)
receipts.insert(0, receipt)
receipt.items[index] = item
receipts = receipts[: self._max_receipts]
if item.field_bytes and (
receipt.field_bytes() > MAX_RECEIPT_FIELD_BYTES
or not self._make_room(_bytes(receipts))
):
# Counting is as before; this job's history fields stay unknown
receipt.items[index] = item._replace(fields=None, field_bytes=0)
self._put(run_key, receipts)
self._receipts[run_key] = receipts[: self._max_receipts]
while len(self._receipts) > self._max_keys:
_, dropped = self._receipts.popitem(last=False)
self._field_bytes -= _bytes(dropped)
def _make_room(self, needed: int) -> bool:
"""Free the fields of the least recently used receipts (under the lock).
Old receipts of workflows never run again must not hold the budget:
their counting stays, only their history fields go. False when
``needed`` does not fit even then.
"""
if needed > self._max_field_bytes:
return False
for receipts in self._receipts.values(): # least recently used first
if self._field_bytes + needed <= self._max_field_bytes:
break
for receipt in receipts:
self._field_bytes -= receipt.strip_fields()
return self._field_bytes + needed <= self._max_field_bytes
def _take(self, run_key: str) -> List[_Receipt]:
"""Remove a key's receipts and their bytes from the store (under the lock)."""
receipts = self._receipts.pop(run_key, [])
self._field_bytes -= _bytes(receipts)
return list(receipts)
def _put(self, run_key: str, receipts: List[_Receipt]) -> None:
"""Store a key's receipts as the most recently used (under the lock)."""
self._receipts[run_key] = receipts
self._field_bytes += _bytes(receipts)
self._receipts.popitem(last=False)
# -- cached nodes -------------------------------------------------------
@@ -494,11 +393,25 @@ class CachedRuns:
def _match(self, run_key: str, values: Any) -> Optional[List[_Item]]:
"""The items of the one receipt whose outputs equal ``values``, in order."""
if not values:
return None
length = len(values[0])
if length == 0 or length > self._max_items:
return None
if any(len(slot) != length for slot in values):
return None
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
with self._lock:
matches = self._matching(run_key, values)
receipts = self._receipts.get(run_key)
if not receipts:
return None
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
if not matches:
return None
length = len(values[0])
self._receipts.move_to_end(run_key)
facts = {
tuple(receipt.items[index].facts for index in range(length))
for receipt in matches
@@ -507,99 +420,13 @@ class CachedRuns:
return None
return [matches[0].items[index] for index in range(length)]
def _matching(self, run_key: str, values: Any) -> List[_Receipt]:
"""Receipts of ``run_key`` whose outputs equal ``values`` (under the lock)."""
if type(values) not in (list, tuple) or not values:
return []
if any(type(slot) not in (list, tuple) for slot in values):
return []
length = len(values[0])
if length == 0 or length > self._max_items:
return []
if any(len(slot) != length for slot in values):
return []
if any(type(value) is not str for slot in values for value in slot):
return []
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
receipts = self._receipts.get(run_key)
if not receipts:
return []
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
if matches:
self._receipts.move_to_end(run_key)
return matches
# -- the history capture (#403) -------------------------------------------
def recorded_fields(
self, prompt_id: Any, node_id: Any, values: Any
) -> Optional[List[Fields]]:
"""The history fields of the execution a cached node reuses, per list item.
``values`` are the node's cached STRING outputs (``cached_string_outputs``).
Only for a node of the job currently executing, and only when every
receipt matching ``values`` kept the same fields; None otherwise. Never
counts and never marks the node replayed.
"""
try:
with self._lock:
attempt = self._attempt
if (
attempt is None
or type(prompt_id) is not str
or prompt_id != attempt.prompt_id
or type(node_id) is not str
or node_id not in attempt.nodes
):
return None
matches = self._matching(attempt.nodes[node_id][1], values)
if not matches:
return None
found = []
for index in range(len(values[0])):
fields = {receipt.items[index].fields for receipt in matches}
if len(fields) != 1 or None in fields or _CONFLICT in fields:
return None
found.append(fields.pop())
return found
except Exception as error:
logger.debug(f"Could not read a cached run's history fields: {error}")
return None
def _deduplicated(receipts: List[_Receipt]) -> List[_Receipt]:
"""The newest receipt replaces older ones with the same outputs.
Counting needs only the outputs. When a replaced receipt recorded other
history fields for equal outputs (a raw text the outputs do not show),
the newest item's fields become a conflict, so no cached job of it is
given either text (#403). A conflict stays until the run key is dropped.
"""
"""The newest receipt replaces older ones with the same outputs."""
if not receipts:
return receipts
newest, outputs = receipts[0], receipts[0].outputs()
kept = [newest]
for receipt in receipts[1:]:
if receipt.outputs() != outputs:
kept.append(receipt)
continue
for index, item in list(newest.items.items()):
older = receipt.items[index].fields
if older == _CONFLICT or (
older is not None and item.fields is not None and older != item.fields
):
newest.items[index] = item._replace(fields=_CONFLICT, field_bytes=0)
return kept
def _bytes(receipts: List[_Receipt]) -> int:
"""UTF-8 bytes of the history fields ``receipts`` keep."""
return sum(
item.field_bytes for receipt in receipts for item in receipt.items.values()
)
outputs = receipts[0].outputs()
return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs]
def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]:
+5 -83
View File
@@ -151,77 +151,10 @@ def cached_string_outputs(
return None
def _receipt_fields(
receipts: object, executor: object, job_id: str, source: object
) -> list | None:
"""Per list index, the ``(raw, prepend, append)`` of the execution reused (#403).
From the #347 receipt whose digests equal every STRING slot of the cache
entry; None when there is none or the answer is malformed.
"""
try:
if receipts is None:
try:
from .cached_runs import CACHED_RUNS as receipts
except ImportError:
from utils.cached_runs import CACHED_RUNS as receipts
values = cached_string_outputs(executor, source.node_id, source.node_type)
if values is None:
return None
found = receipts.recorded_fields(job_id, source.node_id, values)
if type(found) is not list or len(found) != len(values[0]):
return None
position = (
(1 if source.output_slot in (1, 3) else 0)
if source.node_type == "PromptManagerPair"
else 0
)
fields = []
for slots in found:
slot = slots[position] if type(slots) is tuple else None
if not (
type(slot) is tuple
and len(slot) == 3
and all(type(value) is str for value in slot)
):
return None
fields.append(slot)
return fields
except Exception:
return None
def _agrees(inferred: PromptSnapshot, fields: tuple) -> bool:
"""Every field the graph knows equals the receipt's; else fill nothing."""
known = (inferred.raw, inferred.prepend, inferred.append)
return all(
field.value is None or field.value == value
for field, value in zip(known, fields)
)
def _filled(inferred: TextEvidence, value: str) -> TextEvidence:
"""A field the graph cannot show, taken from the reused execution (#403)."""
if inferred.value is not None:
return inferred
return TextEvidence(value, "inferred", "graph_semantics", None)
def observe_cached_context(
executor: object,
message_type: object,
message_data: object,
*,
receipts: object = None,
executor: object, message_type: object, message_data: object
) -> None:
"""Copy bounded local strings without cache/provider execution or retention.
Raw, prepend and append come from graph inference; a field it leaves
unknown (runtime-only text) is taken from the receipt of the execution the
cache entry is proven to come from (``CachedRuns.recorded_fields``), so the
cached job is a tracked run of its prompt (#403). ``receipts`` defaults to
the process ``CACHED_RUNS``.
"""
"""Copy bounded local strings without cache/provider execution or retention."""
try:
binding = active_capture()
if binding is None or type(message_type) is not str:
@@ -265,29 +198,18 @@ def observe_cached_context(
inferred = manager.source_snapshot(cap, source)
if inferred is None:
continue
fields = None
if any(
field.value is None
for field in (inferred.raw, inferred.prepend, inferred.append)
):
fields = _receipt_fields(receipts, executor, cap.job_id, source)
for index, value in enumerate(values):
if type(value) is not str:
continue
raw, prepend, append = inferred.raw, inferred.prepend, inferred.append
if fields is not None and _agrees(inferred, fields[index]):
raw = _filled(raw, fields[index][0])
prepend = _filled(prepend, fields[index][1])
append = _filled(append, fields[index][2])
manager.observe(
cap,
source,
index,
PromptSnapshot(
raw,
inferred.raw,
TextEvidence(value, "recorded", "host_cache_output", None),
prepend,
append,
inferred.prepend,
inferred.append,
),
)
except Exception:
-142
View File
@@ -1,142 +0,0 @@
"""The monitor's path for outputs of workflows without a PromptManager node (#247).
A foreign output (no positive PromptManager node in its graph) is decided by
the ladder the output scan uses (``utils.file_prompt.decide_file_prompt``)
with the ``MONITOR`` policy: certain files are stored at once, files only
recorded history can decide wait for their run's outputs to be sealed, and
everything else stays unlinked. This module holds the parts that do not
need the monitor's waiting sets: the setting, the decision call and the
store. The waiting and the re-check stay on ``ImageGenerationHandler``.
"""
import os
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional, Tuple
from .file_prompt import (
NOT_SAVED,
NOT_SAVED_WIRED,
PAIR,
POSITIVE,
Decision,
Policy,
decide_file_prompt,
)
from .hashing import generate_prompt_hash
from .usage_tracking import survive_switch
try:
from ..database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
from ..database.run_attribution import recorded_output_lookup
from ..database.scanned_prompts import store_scanned_prompt_or_raise
except ImportError:
from database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
from database.run_attribution import recorded_output_lookup
from database.scanned_prompts import store_scanned_prompt_or_raise
# Decisions that name a prompt to store
CERTAIN = (PAIR, POSITIVE)
# Decisions of a run whose prompt has Save to library off, or wired from a
# node the file cannot show (#90): final, nothing is stored, nothing waits
NOT_SAVED_KINDS = (NOT_SAVED, NOT_SAVED_WIRED)
# Sources certain without a recorded run: the graph alone, or its recorded saver
ANCHORED_SOURCES = ("inspector", "recorded-run")
@dataclass(frozen=True)
class ForeignRecheck:
"""``schedule`` snapshot: the one re-check of a foreign output after its
run's outputs were sealed. ``reference`` is the sealed output reference,
the spelling history recorded (``recorded_output_lookup(reference=)``)."""
reference: Mapping[str, Any]
def save_foreign_runs() -> bool:
"""Whether a foreign output may be stored now, read for every file.
``GalleryConfig.SAVE_FOREIGN_RUNS`` and ``MONITORING_ENABLED``: a
monitoring switch saved off stops foreign writes at once, although the
running observer watches until a restart. True when the configuration
cannot be imported (the defaults).
"""
try:
from ..py.config import GalleryConfig
except ImportError:
try:
from py.config import GalleryConfig
except ImportError:
return True
return (
GalleryConfig.SAVE_FOREIGN_RUNS is not False
and GalleryConfig.MONITORING_ENABLED is not False
)
def history_lookup(
db: Any, image_path: str, reference: Optional[Mapping[str, Any]] = None
) -> Callable[[], Any]:
"""The ladder's ``lookup`` for one file: its ``RecordedOutput`` or None.
With ``reference`` the file is spelled as history recorded it. Failures
are logged by ``recorded_output_lookup`` and name nothing.
"""
lookup = recorded_output_lookup(
db, reference=dict(reference) if reference is not None else None
)
return lambda: lookup(image_path)
def decide_foreign(
image_path: str,
metadata: Any,
*,
in_output: bool,
lookup: Optional[Callable[[], Any]],
) -> Decision:
"""The ``MONITOR`` ladder for one foreign output (the size is read only
when the ladder needs it)."""
def file_size() -> Optional[int]:
try:
return os.path.getsize(image_path)
except OSError:
return None
return decide_file_prompt(
metadata,
file_name=os.path.basename(image_path),
file_size=file_size,
in_output=in_output,
lookup=lookup,
policy=Policy.MONITOR,
)
def store_foreign(
db: Any, image_path: str, decision: Decision
) -> Tuple[str, Optional[int]]:
"""Store a certain decision: ``(status, prompt id)``.
One write through ``survive_switch``, so it runs again on the moved
library when a Move library switched while it waited (#249 S6b). Created
prompts carry ``RUN_MARKS``. A file linked to another prompt is a
``conflict`` and nothing is written. Database errors are raised.
"""
def store():
if decision.kind == PAIR:
result = ingest_resolved_pair(
db, image_path, decision.canonical, decision.resolution, marks=RUN_MARKS
)
return result["status"], result.get("image_id")
return store_scanned_prompt_or_raise(
db, image_path, decision.canonical, decision.text, RUN_MARKS
)
status, linked = survive_switch(db, store)
if decision.kind != PAIR or linked is None:
return status, linked
# The pair ingester reports the image row; its prompt is the positive's.
prompt = db.get_prompt_by_hash(generate_prompt_hash(decision.text))
return status, prompt["id"] if prompt else None
+41 -201
View File
@@ -33,17 +33,6 @@ from typing import Callable, Optional, Dict, Any, List, Tuple
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from .file_prompt import HISTORY, NOT_SAVED, NOT_SAVED_WIRED
from .foreign_runs import (
ANCHORED_SOURCES,
CERTAIN,
NOT_SAVED_KINDS,
ForeignRecheck,
decide_foreign,
history_lookup,
save_foreign_runs,
store_foreign,
)
from .hashing import generate_prompt_hash
from .metadata_extractor import ComfyUIMetadataExtractor
from .parallel import note_refusal, refused_at_shutdown
@@ -164,11 +153,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
# Sealed re-checks ``schedule`` refused (the path still pending, or the
# pending set full): retried each time a pending image finishes.
self._history_due: "OrderedDict[str, None]" = OrderedDict()
# The same two sets for foreign outputs (#247), apart so a large
# foreign run never evicts a waiting keyless file; the due set keeps
# each file's sealed output reference.
self._awaiting_foreign: "OrderedDict[str, Tuple[str, float]]" = OrderedDict()
self._foreign_due: "OrderedDict[str, dict]" = OrderedDict()
self._awaiting_lock = threading.Lock()
self._sealed_subscribed = False
# Set by ``close``: no new work, no new worker, ever (#302).
@@ -269,8 +253,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
with self._awaiting_lock:
self._awaiting.clear()
self._history_due.clear()
self._awaiting_foreign.clear()
self._foreign_due.clear()
with self._pending_lock:
self._pending.clear()
worker, self._worker = self._worker, None
@@ -456,9 +438,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
if prompt_snapshot is HISTORY_ONLY:
self._recheck_history(image_path)
return
if isinstance(prompt_snapshot, ForeignRecheck):
self._recheck_foreign(image_path, prompt_snapshot.reference)
return
self.logger.info(f"Processing image: {image_path}")
if not os.path.exists(image_path):
@@ -478,12 +457,17 @@ class ImageGenerationHandler(FileSystemEventHandler):
# negative-prompt PromptManager nodes in the workflow.
state, current_prompt = self._find_prompt_from_metadata(metadata)
if state == "opted_out":
self._log_not_saved(image_path)
self.logger.info(
"Save to library is off for this run's prompt, skipping image: "
f"{image_path}"
)
return
if state == "foreign":
# Another workflow saved this image: never a batch queue entry
# of ours; its own graph may name its prompt (#247).
self._ingest_foreign(image_path, metadata)
# Another workflow saved this image: it belongs to no prompt of
# ours, and must not consume a batch queue entry.
self.logger.info(
f"No PromptManager prompt in workflow, skipping image: {image_path}"
)
return
if current_prompt:
self.logger.info(
@@ -605,55 +589,36 @@ class ImageGenerationHandler(FileSystemEventHandler):
resolved = os.path.abspath(image_path)
return os.path.normcase(resolved)
def _prune_awaiting(self, store=None) -> List[Tuple[str, bool]]:
"""Drop expired and surplus entries of ``store`` (the keyless set by
default; ``_awaiting_lock`` held). Returns the dropped foreign files
as ``(path, evicted)`` for ``_log_dropped`` after the lock."""
store = self._awaiting if store is None else store
def _prune_awaiting(self) -> None:
"""Drop expired and surplus entries (``_awaiting_lock`` held)."""
now = time.monotonic()
expired = [k for k, (_p, due) in store.items() if due <= now]
dropped = [(store.pop(key)[0], False) for key in expired]
while len(store) > MAX_AWAITING_HISTORY:
dropped.append((store.popitem(last=False)[1][0], True))
return dropped if store is self._awaiting_foreign else []
for key in [k for k, (_p, due) in self._awaiting.items() if due <= now]:
del self._awaiting[key]
while len(self._awaiting) > MAX_AWAITING_HISTORY:
self._awaiting.popitem(last=False)
def _log_dropped(self, dropped: List[Tuple[str, bool]]) -> None:
"""Log foreign files that stopped waiting (outside ``_awaiting_lock``)."""
for image_path, evicted in dropped:
name = os.path.basename(image_path)
if evicted:
self.logger.info(
f"Too many files wait for their run; {name} left unlinked"
)
else:
self.logger.info(f"No recorded run for {name}; left unlinked")
def _await_history(self, image_path: str, store=None) -> None:
def _await_history(self, image_path: str) -> None:
"""Re-check ``image_path`` once, history only, when its run is sealed.
``store`` is the waiting set (``_awaiting_foreign`` for a foreign
output). A closed handler re-checks nothing.
A closed handler re-checks nothing.
"""
if self._closed:
return
if not self._sealed_subscribed:
subscribe_outputs_sealed(self._on_outputs_sealed)
self._sealed_subscribed = True
store = self._awaiting if store is None else store
key = self._awaiting_key(image_path)
with self._awaiting_lock:
store.pop(key, None)
store[key] = (image_path, time.monotonic() + AWAIT_HISTORY_SECONDS)
dropped = self._prune_awaiting(store)
self._log_dropped(dropped)
self._awaiting.pop(key, None)
self._awaiting[key] = (
image_path,
time.monotonic() + AWAIT_HISTORY_SECONDS,
)
self._prune_awaiting()
def _await_foreign(self, image_path: str) -> None:
self._await_history(image_path, self._awaiting_foreign)
def _stop_awaiting(self, image_path: str, store=None) -> None:
store = self._awaiting if store is None else store
def _stop_awaiting(self, image_path: str) -> None:
with self._awaiting_lock:
store.pop(self._awaiting_key(image_path), None)
self._awaiting.pop(self._awaiting_key(image_path), None)
def awaiting_history(self) -> List[str]:
"""Normalized paths still waiting for their run's recorded outputs."""
@@ -661,14 +626,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
self._prune_awaiting()
return list(self._awaiting)
def awaiting_foreign(self) -> List[str]:
"""Normalized paths of foreign outputs waiting for their run (#247)."""
with self._awaiting_lock:
dropped = self._prune_awaiting(self._awaiting_foreign)
waiting = list(self._awaiting_foreign)
self._log_dropped(dropped)
return waiting
def _on_outputs_sealed(self, run_id: str, references: List[dict]) -> None:
"""A run's outputs were recorded: re-check its waiting images once.
@@ -680,7 +637,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
due = []
with self._awaiting_lock:
self._prune_awaiting()
dropped = self._prune_awaiting(self._awaiting_foreign)
for reference in references:
if reference.get("folder_type") != "output":
continue
@@ -691,53 +647,39 @@ class ImageGenerationHandler(FileSystemEventHandler):
)
waiting = self._awaiting.pop(key, None)
if waiting is not None:
due.append((waiting[0], None))
waiting = self._awaiting_foreign.pop(key, None)
if waiting is not None:
due.append((waiting[0], dict(reference)))
self._log_dropped(dropped)
for image_path, reference in due:
self._schedule_recheck(image_path, reference)
due.append(waiting[0])
for image_path in due:
self._schedule_recheck(image_path)
@staticmethod
def _recheck_snapshot(reference):
return HISTORY_ONLY if reference is None else ForeignRecheck(reference)
def _schedule_recheck(self, image_path: str, reference=None) -> None:
"""Schedule the history-only re-check (with the sealed ``reference``:
a foreign output's), or keep it due for later.
def _schedule_recheck(self, image_path: str) -> None:
"""Schedule the history-only re-check, or keep it due for later.
A closed handler drops it.
"""
if self._closed:
return
if self.schedule(image_path, self._recheck_snapshot(reference)):
if self.schedule(image_path, HISTORY_ONLY):
return
with self._awaiting_lock:
# ``close`` sets the flag before ``shutdown`` clears this set, so
# a close that raced the refusal above is seen here.
if self._closed:
return
due = self._history_due if reference is None else self._foreign_due
due.pop(image_path, None)
due[image_path] = reference
evicted = []
while len(due) > MAX_AWAITING_HISTORY:
evicted.append(due.popitem(last=False)[0])
if reference is not None:
self._log_dropped([(path, True) for path in evicted])
self._history_due.pop(image_path, None)
self._history_due[image_path] = None
while len(self._history_due) > MAX_AWAITING_HISTORY:
self._history_due.popitem(last=False)
def _drain_history_due(self) -> None:
"""Schedule due re-checks now that a pending slot or path is free."""
with self._awaiting_lock:
due = [(self._history_due, p, None) for p in self._history_due]
due += [(self._foreign_due, *item) for item in self._foreign_due.items()]
for store, image_path, reference in due:
if self.schedule(image_path, self._recheck_snapshot(reference)):
if not self._history_due:
return
due = list(self._history_due)
for image_path in due:
if self.schedule(image_path, HISTORY_ONLY):
with self._awaiting_lock:
# A newer seal may have replaced it meanwhile: keep that one
if image_path in store and store[image_path] is reference:
del store[image_path]
self._history_due.pop(image_path, None)
def _recheck_history(self, image_path: str) -> None:
"""The one history-only re-check of a keyless image (#342)."""
@@ -761,108 +703,6 @@ class ImageGenerationHandler(FileSystemEventHandler):
metadata or {"file_info": self.get_basic_file_info(image_path)},
)
def _ingest_foreign(self, image_path: str, metadata) -> None:
"""A foreign output at settle: store its certain prompt, or wait for
its run's recorded outputs when only history can decide (#247)."""
name = os.path.basename(image_path)
if not save_foreign_runs():
self.logger.info(
f"No PromptManager prompt in workflow, skipping image: {image_path}"
)
return
in_output = in_output_folder(image_path, host_output_directory())
decision = decide_foreign(
image_path, metadata, in_output=in_output, lookup=None
)
if decision.kind == HISTORY:
decision = self._settle_lookup(image_path, metadata)
if decision is None:
self.logger.debug(f"{name} waits for its run to end")
return
self._stop_awaiting(image_path, self._awaiting_foreign)
if decision.kind in NOT_SAVED_KINDS:
self._log_not_saved(image_path, decision.kind)
elif decision.kind in CERTAIN:
self._store_foreign(image_path, decision)
else:
self.logger.info(f"No certain prompt for {name}; left unlinked")
def _log_not_saved(self, image_path: str, kind: str = NOT_SAVED) -> None:
"""The log line of a file whose run's prompt does not save (#90)."""
if kind == NOT_SAVED_WIRED:
reason = "is wired from a node the image cannot show"
else:
reason = "is off"
self.logger.info(
f"Save to library {reason} for this run's prompt, skipping image: "
f"{image_path}"
)
def _settle_lookup(self, image_path: str, metadata):
"""The settle-time history lookup of a file only history can decide.
Registered first, so a run sealed before this lookup is not missed.
Returns None to keep waiting: no record yet, and the answer is not
the inspector's or the recorded saver's (the legacy floor waits for
the re-check). With a record, any decision ends the wait. A
``not_saved`` here is the undecided step's (an early one never asks
history, so it never reaches this lookup): a recorded saver may still
resolve another branch, so without a record it waits too (#90).
"""
self._await_foreign(image_path)
found = []
lookup = history_lookup(self.db_manager, image_path)
def recorded():
found.append(lookup())
return found[-1]
decision = decide_foreign(image_path, metadata, in_output=True, lookup=recorded)
if any(record is not None for record in found):
return decision
if decision.kind in CERTAIN and decision.source in ANCHORED_SOURCES:
return decision
return None
def _recheck_foreign(self, image_path: str, reference) -> None:
"""The one re-check of a foreign output after its run ended (#247)."""
name = os.path.basename(image_path)
if not os.path.exists(image_path):
return
if not save_foreign_runs():
self.logger.info(f"Saving prompts from other workflows is off: {name}")
return
try:
metadata = self.metadata_extractor.extract_metadata(image_path)
except Exception as error:
self.logger.warning(f"Metadata of {name} could not be read: {error}")
return
lookup = history_lookup(self.db_manager, image_path, reference)
decision = decide_foreign(image_path, metadata, in_output=True, lookup=lookup)
if decision.kind in NOT_SAVED_KINDS:
self._log_not_saved(image_path, decision.kind)
return
if decision.kind in CERTAIN:
self._store_foreign(image_path, decision)
return
self.logger.info(
f"No certain prompt for {name} after its run ended; left unlinked"
)
def _store_foreign(self, image_path: str, decision) -> None:
name = os.path.basename(image_path)
if not save_foreign_runs(): # switched off while this file was decided
self.logger.info(f"Saving prompts from other workflows is off: {name}")
return
try:
status, prompt_id = store_foreign(self.db_manager, image_path, decision)
except Exception as error:
self.logger.error(f"Failed to store the prompt of {name}: {error}")
return
self.logger.info(
f"Foreign run: {status} prompt {prompt_id} for {name} ({decision.source})"
)
def _history_prompt(self, image_path: str) -> Optional[int]:
"""The stored prompt recorded history attributes to this file, if one."""
try:
+15 -36
View File
@@ -176,32 +176,24 @@ function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
* The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
* tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
* an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
* image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
* is plain.
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
*/
export function inspectorTagChips(record, {filterable}) {
const chips = [];
const add = (names, filter, source) => {
const shown = new Set();
const shown = new Set(), chips = [];
const add = (names, filter) => {
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
shown.add(key); chips.push({name, filter, source});
shown.add(key); chips.push({name, filter});
}
};
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
const {library} = record;
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
return chips;
}
/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
export function tagSectionLabel(source, library) {
if (source === 'image') return 'Image tags';
return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
@@ -303,11 +295,14 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
// D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
for (const source of ['image', 'linked']) {
const own = chips.filter(chip => chip.source === source);
if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
if (chips.length) {
const tags = el('div', undefined, 'tags');
for (const {name, filter} of chips) {
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
@@ -341,22 +336,6 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
* D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
* The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
*/
function tagSection(source, label, chips) {
const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
const tags = el('div', undefined, 'tags');
for (const {name: text, filter} of chips) {
if (!filter) {tags.append(el('span', text, 'chip')); continue;}
const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
}
section.append(heading, tags);
return section;
}
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
+3 -3
View File
@@ -32,8 +32,8 @@ export function imageKey(image) {
return `${image.root_index}:${image.path}`;
}
/** The workspace listing's sorts: the concept's Recently added and Name A–Z, plus Oldest and Z–A (D14; no rating, D153). */
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc']);
/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
const isCount = value => Number.isSafeInteger(value) && value >= 0;
// The largest page a refresh asks for (the listing clamps at 500).
@@ -315,7 +315,7 @@ const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.ge
/**
* D17: the caption's right-hand date, "<Today|Yesterday|weekday> · <Month D>" of the newest result under Newest first,
* with ", YYYY" outside the current year; '' under Oldest first and the name sorts.
* with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts.
*/
export function captionDate(items, sort, now = new Date()) {
if (sort !== 'newest') return '';
+2 -3
View File
@@ -61,9 +61,8 @@ function selectOf(id, options, value) {
return select;
}
// The concept's sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). D153 (#447)
// drops the concept's "Highest rated".
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'],
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
+17 -10
View File
@@ -9,9 +9,7 @@
*/
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
// D153 (#447): the workspace edits no 1–5 rating; a stored one is left exactly as it is.
export const EDITOR_FIELDS = Object.freeze(['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
const FIELDS = EDITOR_FIELDS;
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
@@ -108,20 +106,27 @@ export function fallbackTitle(text) {return text.split('\n')[0].trim();}
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
const stored = value => (value ?? '').trim();
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: ''});
function normalizeRating(value) {
if (value === null || value === '') return null;
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
return number;
}
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
function seedDraft(mode, {owner, pair}) {
if (!owner) return blankDraft();
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
notes: owner.notes ?? ''};
notes: owner.notes ?? '', rating: owner.rating};
}
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
function overlay(next, draft, previous) {
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
}
function baselineOf(owner) {
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], notes: owner.notes};
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
}
/** Only changed fields; an untouched fallback title is not a title edit. */
@@ -131,18 +136,19 @@ function metadataChanges(draft, seed, baseline) {
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
if (category !== stored(baseline.category)) changes.category = category;
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
return changes;
}
function createMetadata(draft) {
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
notes: draft.notes};
rating: draft.rating, notes: draft.notes};
}
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
function sameMetadata(sent, owner) {
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
sent.notes === (owner.notes ?? '');
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
}
function draftProblem(mode, draft) {
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
@@ -322,8 +328,9 @@ export function createLibraryEditorController({api, mode, positiveId = null, pai
},
setField(name, value) {
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
emit({draft: {...state.draft, [name]: value}});
const next = name === 'rating' ? normalizeRating(value) : value;
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
emit({draft: {...state.draft, [name]: next}});
},
async submit() {
if (disposed || !SUBMITTABLE.has(state.phase)) return;
+11 -8
View File
@@ -7,11 +7,12 @@
* module only renders `controller.state` and forwards user actions.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createLibraryEditorController, EDITOR_FIELDS } from './library-editor-state.mjs';
import { createLibraryEditorController } from './library-editor-state.mjs';
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
tags: 'recipe-tags', notes: 'recipe-notes'};
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
const SUBMITTABLE = new Set(['ready', 'write-error']);
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
const NO_PAIR = 'Archive needs a selected saved negative variant.';
@@ -217,8 +218,9 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
category.setAttribute('list', 'categories');
tagInput.setAttribute('list', 'tag-options');
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
// D153 (#447): no Rating field; the concept's `#recipe-rating` select is not drawn.
form.append(columns, categories, tags, field('Notes', textarea('notes'), FIELD_IDS.notes));
const rating = controls.rating = el('select');
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
@@ -228,7 +230,9 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
form.append(line.note, actions);
for (const [name, node] of Object.entries(controls)) {
node.addEventListener('input', () => controller.setField(name, node.value), {signal: views.signal});
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
}, {signal: views.signal});
}
form.addEventListener('submit', event => {
event.preventDefault();
@@ -242,7 +246,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
let categoryKey = null, refocus = null;
function render(state) {
for (const [name, node] of Object.entries(controls)) {
const value = state.draft[name];
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
if (node.value !== value) node.value = value;
}
const key = state.categories.join('\n');
@@ -270,8 +274,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
body.replaceChildren(form);
return {controller, views, render, start() {
// A kept draft carries only the editor's own fields (D153: never a rating).
if (draft) for (const [name, value] of Object.entries(draft)) if (EDITOR_FIELDS.includes(name)) controller.setField(name, value);
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
render(controller.state);
controller.initialize();
}};
+6
View File
@@ -60,3 +60,9 @@ export function usedLabel(lastUsedAt, now = new Date()) {
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
}
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
export function ratingLabel(rating) {
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
}
+1 -1
View File
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
query.set('sort',sort);return query;
}
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
+10 -10
View File
@@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
import {promptRoles} from './prompt-blocks.mjs';
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
import {createLibraryEditorController} from './library-editor-state.mjs';
import {layoutLibraryRows,visibleLibraryRows,usedLabel} from './library-layout.mjs';
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
import {mountRecordedHistory} from './history.mjs';
import {countOf} from './plural.mjs';
@@ -29,8 +29,8 @@ const selectedPairs=new Map();
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
@@ -111,7 +111,7 @@ function outcomeMessage(result,outcome) {
if(kind!=='create' && kind!=='add-negative') return null;
if(receipt.positive_created) return 'Saved.';
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'
:'Saved as a new negative variant of an existing prompt.';
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
return 'This prompt pair already exists. Showing the saved version.';
@@ -122,7 +122,7 @@ function takeSavedSelection(positiveId) {
}
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
const {title:name,category,tags,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,notes});
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
}
/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */
function createNewPromptEditor(api,scope,main) {
@@ -159,8 +159,6 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
@@ -222,7 +220,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
/** The concept's prompt card (app.js renderLibrary a586) with real counts and last use (#249 D23; no rating, D153). */
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
function cardFor(item,index) {
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
@@ -248,8 +246,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
content.append(heading,prompts,meta,vocabulary);
const footer=el('div',undefined,'card-footer');
// D153 (#447): the footer reads only the last use; the concept's rating stars are not drawn.
const facts=el('span',usedLabel(item.last_used_at));
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
const facts=el('span');
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
else facts.textContent=used;
const actions=el('div',undefined,'row-actions');
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
+3 -4
View File
@@ -6,8 +6,7 @@
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
* accepts is dropped to that key's default, so an opened view applies what it can (a view saved under the retired
* "Highest rated" sort opens on Recently added, D153).
* accepts is dropped to that key's default, so an opened view applies what it can.
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
*/
@@ -37,7 +36,7 @@ const tags = { fallback: [], accepts: value => Array.isArray(value) && value.len
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
const FIELDS = Object.freeze({
images: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc'], 'newest'),
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
@@ -46,7 +45,7 @@ const FIELDS = Object.freeze({
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
}),
library: Object.freeze({
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
}),
});
+13 -9
View File
@@ -11,8 +11,7 @@
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
* rated-images total).
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
@@ -169,16 +168,21 @@ export function healthSummary(report) {
return `${time}Nothing was changed; review the checks with issues.`;
}
/** D96: the concept's "Runs by category" rows `[category, runs]`; D153 drops its average rating column. */
/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */
export function categoryRows(statistics) {
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
return statistics.by_category.map(row => [
row.category ?? 'Uncategorized',
number(row.runs),
row.average_rating === null || row.average_rating === undefined ? 'Not rated'
: `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`,
]);
}
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
.map(([label, value]) => [label, number(value)]);
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images],
['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]);
}
function table(caption, head, rows, empty) {
@@ -211,7 +215,7 @@ function table(caption, head, rows, empty) {
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
@@ -374,7 +378,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
+2 -46
View File
@@ -14,9 +14,6 @@
* it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row.
* - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's
* `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source.
* - D158 (#247): "Save prompts from other workflows" (`save_foreign_runs`), a host draft below "Watch for new outputs",
* applied by the server at once (the monitor reads it for every file); disabled, saying why, while monitoring is off,
* unless the running monitor still watches until a restart (D128 `stopping`): then it can still be set.
* - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which
* starts the thumbnail Activity job.
*
@@ -66,21 +63,6 @@ export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data ha
export const DUPLICATE_TEXT = 'This folder is already a source.';
export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.';
export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.';
/** D158 (#247): the row the concept does not draw, below "Watch for new outputs". */
export const FOREIGN_RUNS = Object.freeze({ id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
off: 'Applies while Watch for new outputs is on.' });
/**
* D158: the row for the drafted `save_foreign_runs` (`value`) and `monitoring_enabled` (`monitoring`): disabled until
* read, and while monitoring is off, when `note` says why (the value is kept and saved as it is). `stopping`: a source
* is still watched until the restart (D128), so the value can still be set; the note stays.
*/
export function foreignRunsState({ value, monitoring, stopping = false }) {
if (value === undefined) return { disabled: true, checked: null, note: null };
const off = monitoring === false;
return { disabled: off && !stopping, checked: value === true, note: off ? FOREIGN_RUNS.off : null };
}
// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet.
const addedLabels = new Map();
@@ -285,16 +267,13 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs',
description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' },
scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) });
const foreign = settingRow({ id: FOREIGN_RUNS.id, label: FOREIGN_RUNS.label, description: FOREIGN_RUNS.description,
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', FOREIGN_RUNS.key, value) });
const workers = settingRow({ id: 'workers', label: 'Worker threads',
description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 },
scope, onChange: value => drafts.set('host', 'worker_threads', value) });
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [foreign, FOREIGN_RUNS.id, FOREIGN_RUNS.key],
[workers, 'workers', 'worker_threads']]
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']]
.map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null }));
for (const row of hostRows) row.input.dataset.key = row.key;
surface.append(monitoring, foreign, workers,
surface.append(monitoring, workers,
operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.',
label: 'Review scan', onClick: scanAll }),
operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.',
@@ -369,10 +348,6 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
for (const row of hostRows) {
const value = drafts.get('host', row.key);
row.input.disabled = value === undefined;
if (row.key === FOREIGN_RUNS.key) {
paintForeign(row, value, unavailable);
continue;
}
if (row.key === 'worker_threads') {
// D113: the range ends at the server's core count (the concept's fixed 16).
const cpus = drafts.get('host', 'cpu_count');
@@ -385,24 +360,6 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
}
}
// D158: disabled while monitoring is drafted off, with the reason under the description.
function paintForeign(row, value, unavailable) {
const stopping = sources?.effective?.some(source => source.watch === 'stopping') === true;
const state = foreignRunsState({ value, monitoring: drafts.get('host', 'monitoring_enabled'), stopping });
row.input.disabled = state.disabled;
if (state.checked !== null) row.input.checked = state.checked;
const note = unavailable && value === undefined ? HOST_UNAVAILABLE : state.note;
if (note && row.note?.textContent !== note) {
row.note?.remove();
row.note = el('p', note, 'small');
row.note.id = `${FOREIGN_RUNS.id}-note`;
row.text.append(row.note);
} else if (!note && row.note) { row.note.remove(); row.note = null; }
// The note is the switch's description, so a screen reader says why it is disabled.
if (row.note) row.input.setAttribute('aria-describedby', row.note.id);
else row.input.removeAttribute('aria-describedby');
}
// The sources changed since this page read them (a refused per-source scan): read them and the saved values again.
function reread() {
load();
@@ -421,7 +378,6 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
if (answer === STALE || scope.signal.aborted) return;
sources = answer?.sources ?? null;
paintList();
paintControls();
}
// A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first
-1
View File
@@ -13,7 +13,6 @@ export const SEARCH_DELAY = 250;
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
// other label is one of the page's appended options.
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
const isText = value => typeof value === 'string';
-5
View File
@@ -237,11 +237,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
.prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.file-inspector .tag-section .role-label { color: var(--ca-muted); }
.file-inspector .tag-section > .tags { margin-top: 0; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }