Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e41d21730 | ||
|
|
f5856076ce | ||
|
|
4040007264 | ||
|
|
e752e9c218 | ||
|
|
a6505a07af | ||
|
|
56d0a4904c | ||
|
|
e86c0dcf05 | ||
|
|
39877202b2 |
@@ -214,3 +214,7 @@ coverage/
|
||||
|
||||
# Old library files a Move library renamed (never deleted; #249 S6b)
|
||||
*.moved-to-*
|
||||
|
||||
# Per-install key behind the workspace scopes and cursors (#430)
|
||||
workspace-scope.key
|
||||
.workspace-scope.key.*.tmp
|
||||
|
||||
+168
-12
@@ -551,6 +551,49 @@ hold a walk open with a gated scan instead of sleeping.
|
||||
time to network idle and the 10 slowest requests, from a fresh browser
|
||||
context each run.
|
||||
|
||||
## Open pages across restarts and source changes (#430)
|
||||
|
||||
A page addresses a listed file as `{root_index, root_scope, path}`. The root
|
||||
scope is an HMAC over the root's index and canonical path, so the server refuses
|
||||
(409 `source-changed`) an action aimed at a root that was renumbered or replaced
|
||||
since the page listed it (a same-named `ComfyUI_00001_.png` sits in every output
|
||||
folder). Its key, and the listing cursors' key, derive from one random secret in
|
||||
`workspace-scope.key` beside the database (`utils/workspace_keys.py`; owner-only,
|
||||
64 hex characters; never in config.json or the database, so no backup or export
|
||||
carries it; Move library copies it, best effort). A ComfyUI restart therefore keeps
|
||||
every scope and cursor an open page holds: `tests/test_api_workspace_restart.py`
|
||||
proves it with a second API on its own server and with two fresh interpreters on
|
||||
the same database. The key file is created with a temp file and `os.link` (of two
|
||||
first starts one file wins; without hard links `O_EXCL`), re-read after any write,
|
||||
replaced with a WARNING when corrupt, and a folder that cannot hold it gives that
|
||||
process a per-process key (one WARNING; open pages then refresh after a restart).
|
||||
Tests never write it outside their own temp folders.
|
||||
|
||||
The cursor is `{"v": 2, "key"}` with the listing scope (it hashes every absolute
|
||||
root) bound into its MAC only, never sent. Bump `v` whenever the key tuple's shape
|
||||
changes: a cursor now outlives a restart and an upgrade. A cursor the server
|
||||
cannot verify (another key, root list or filter set) answers 400
|
||||
`cursor-stale`; Images then shows "The image collection changed. Refresh image
|
||||
collection to continue." and its Retry becomes **Refresh image collection**.
|
||||
|
||||
When a source really changed, `POST /prompt_manager/images/rescope` (`{"target":
|
||||
{...}}`, validated by `media_target`, index ≤ 4095) maps the held target to the
|
||||
first current root whose scope at the old index equals the held scope, and answers
|
||||
the file's current workspace descriptor; a removed or replaced root, another key
|
||||
or a foreign scope is 409 `source-changed`, a missing file 404 `file-missing`, a
|
||||
path leaving the root or a preview 403 (always `no-store`). Only the Images roots
|
||||
are mapped. Images (`images-source-recovery.mjs`) asks once per held
|
||||
`(key, scope)` pair, shared by the inspector (an inspection that turns
|
||||
`refreshRequired` with `code: "source-changed"`), the viewer (a media load error)
|
||||
and the Tag image dialog. Found under another index, nothing is kept by an old key
|
||||
(`root_index:path` could name another source's file): the viewer closes, the checks
|
||||
clear, a Source filter on the old index follows the file (another one falls back to
|
||||
All sources), only that file stays selected and the collection lists again; a
|
||||
manual Refresh forgets every pair. Not found, the existing visible Refresh stays.
|
||||
Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` and
|
||||
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
|
||||
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -1697,10 +1740,11 @@ wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." eve
|
||||
for one tag, plus " Prompt association still awaits review." from Needs review.
|
||||
Tagging one file never changes another file (an equal-byte copy included), a
|
||||
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
|
||||
changed storage or file, expired capture, unconfirmed save, unavailable model,
|
||||
video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
|
||||
actions while the controller allows it, where the other workspace dialogs place
|
||||
their Retry. The Tags
|
||||
unavailable model, video) are text in `#tag-generation-status` only; a state that
|
||||
blocks Save (a failed or refused capture, a changed source, file or storage, an
|
||||
expired capture, an unconfirmed save) shows its reason beside the actions instead
|
||||
(D147); **Retry** (D116) leads the action buttons while the controller allows it,
|
||||
where the other workspace dialogs place their Retry. The Tags
|
||||
page re-reads its lists after every confirmed save, so the Images counts follow.
|
||||
|
||||
The image-tagging plan's deviation numbers were renumbered by +23 because the
|
||||
@@ -1744,8 +1788,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
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
|
||||
pressing it toasts "Select an image in Images first." (no new chrome).
|
||||
- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
|
||||
primary) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
- **D116** (ruling T5): **Retry** leads the buttons of `.dialog-actions` (before
|
||||
Cancel and the primary; D147's notice row sits above them) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
answer that needs a fresh observation (changed storage or file, expired capture)
|
||||
can be retried; the concept has no failure states, so it has no Retry. After a
|
||||
failed Generate it takes Generate's own path (an invalid field reports its
|
||||
@@ -1756,6 +1800,25 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
|
||||
Retry after an expired capture observes again and keeps the typed draft (M5)" and
|
||||
the paired `production-unknown-outcome` capture.
|
||||
- **D147** (#430, beyond the Global Constraints' "status text only"): a state
|
||||
that blocks Save shows its reason in `div.tag-action-notice` (its sentence in a
|
||||
`role="status"` span, written only when it changes), the first, full-width row of
|
||||
the sticky `.dialog-actions`, above the buttons; `#tag-generation-status` is then
|
||||
empty, so the sentence shows once. Generate and Save are `aria-disabled` and look
|
||||
it (`.tagging-modal [aria-disabled="true"]`: opacity .55, `not-allowed`). A failed
|
||||
or refused capture replaces the empty preview with a same-size
|
||||
`div.tag-image-preview.tag-preview-empty` "Preview unavailable" (`role="img"`).
|
||||
A changed source (409 `source-changed` from observe, Generate or Save, phase
|
||||
`source-changed`, never retried as is) first asks the page where the file is
|
||||
(`onSourceChanged`, see "Open pages across restarts and source changes"): found,
|
||||
the dialog stays open and observes it there (`controller.retarget`, the typed
|
||||
draft kept, the old capture released; refused while a save is in flight); not
|
||||
found, the notice shows the server's sentence and **Refresh image collection**
|
||||
(Images and Library-embedded galleries; **Reload page** elsewhere), which closes
|
||||
the dialog first. Opened, generated and empty states are unchanged, so the concept
|
||||
comparisons are not. Browser tests: `tests/browser/workspace-stale-source.spec.mjs`;
|
||||
the file-changed and unconfirmed-save checks in `workspace-tagging.spec.mjs` and
|
||||
`tagging-concept.spec.mjs` read the notice.
|
||||
- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
|
||||
simulated…" note, the status reads "Suggestions from <model>. Approve tags to save."
|
||||
(not "Sample suggestions from …"), and the toast has no " in this tab".
|
||||
@@ -1808,7 +1871,8 @@ production-versus-concept difference of PR C and its record:
|
||||
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
|
||||
| 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, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
| A state that blocks Save (failed capture, changed source, file or storage, unconfirmed save): its reason and Refresh beside the actions, a "Preview unavailable" box | D147 (#430) |
|
||||
|
||||
### Bulk tagging from the selection bar (#249 PR D)
|
||||
|
||||
@@ -5342,6 +5406,75 @@ same as a file the scan could not attribute, so the file stays on disk and in th
|
||||
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
|
||||
run prints this. Queue-linked batch images can't be proven after the fact and read
|
||||
`unlink`, so review before applying. Stop ComfyUI first.
|
||||
|
||||
### Foreign workflows (#247): the shared per-file decision
|
||||
|
||||
The output scan decides each file through `utils/file_prompt.py`
|
||||
(`decide_file_prompt`, the ladder), which the monitor will share for outputs of
|
||||
workflows without a PromptManager node. It does no file or database I/O.
|
||||
|
||||
- **Save to library (#90).** Before anything else, a file whose PromptManager
|
||||
node had Save to library off is `not_saved`, and one whose switch is wired
|
||||
from a node the file cannot show is `not_saved_wired`: nothing is written, no
|
||||
recorded-run fallback, no Needs review item. A resolved pair whose negative's
|
||||
node is off stores the positive alone (source `opted-out-negative`). The
|
||||
#90 graph walk follows only prompt-like inputs, so the ladder also walks
|
||||
every input upstream of each sampler or guider positive (not negative, model,
|
||||
clip, latent or image inputs; every PromptManager node of a graph without a
|
||||
sampler) and checks the text later steps name: a resolved or inspected
|
||||
positive whose path passes such a node (`side_opted_out`), or a legacy answer
|
||||
that is such a node's text (resolved, else literal). A switch that is off
|
||||
makes the file `not_saved`; one wired from a node the graph cannot read makes
|
||||
it `not_saved_wired`, as #90's direct check does.
|
||||
- **Order.** The default profile decides first, exactly as before (resolved
|
||||
pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved
|
||||
reaches the inspector's resolve (`inspection_resolution` in
|
||||
`utils/prompt_inspector.py`, the inspection profile, then the recorded saver
|
||||
anchor of #386; `name_anchor=False`: the file-name anchor never decides a
|
||||
link; the scan's legacy floor may still link the file, as before #247, and a
|
||||
file nothing links is queued with the anchor's choice per #390), and only that positive's `storage_text` is stored (never
|
||||
prepend or append text, never `recorded_texts`). Then the #170 browser parser
|
||||
(`graph_prompt_text`), the legacy floor, then the recorded run's stored
|
||||
prompt. The legacy answer is refused when a PromptManager-family node has a
|
||||
prepend or append part and the answer is not that node's own `text` (the
|
||||
parser joins the parts, so the stored text cannot be told from it). History is asked once per file and only at that step; the scan's
|
||||
batch lookup stays lazy and makes one `recorded_runs` pass for the stored
|
||||
prompt and the saver (`_BatchHistory`).
|
||||
- **Policies.** `SCAN` keeps the legacy floor for every undecided file.
|
||||
`MONITOR` waits for history inside the output folder (`history`) and stores
|
||||
nothing for a file the inspector calls ambiguous; an untraceable or
|
||||
malformed one still gets the legacy floor.
|
||||
- **Junk.** `junk_text` refuses `blank`, `number` (the #252 `_numeric` rule),
|
||||
`lora-only` (only `<lora:...>` tags and separators) and `model-file` (one
|
||||
token ending in a model extension) at whichever step found the text and
|
||||
nothing later is tried; the file reports `found`, except at the undecided
|
||||
graph step, which keeps its own status (an ambiguous file is still queued).
|
||||
- **One prompt per file.** `database/scanned_prompts.py` stores a positive in
|
||||
one `BEGIN IMMEDIATE` transaction: a file already linked to another prompt
|
||||
(either spelling, `file_link_rows`/`link_conflict`, the pair ingester's rule)
|
||||
is `conflict` and nothing is written; a failed link rolls the new prompt back
|
||||
(`found`); two writers of one text store one prompt. A recorded run's stored
|
||||
prompt links the same way (`link_stored_prompt_or_raise`). A rescan therefore
|
||||
never adds a second link to a file an older scan linked to the legacy answer;
|
||||
the row reads "Kept: already linked to a different prompt". Link rows are
|
||||
matched under the path and its `realpath` spelling (`file_link_rows`), so a
|
||||
file the scan stored through the resolved folder is found when it arrives
|
||||
through a symlinked or junction folder. The reverse order (a row stored under
|
||||
the watcher's raw spelling, then a scan whose root resolves differently) is
|
||||
matched only when that raw spelling equals the realpath. Every scan write
|
||||
runs through `survive_library_switch`, so a library move reruns it.
|
||||
- **Marks.** Every prompt ingestion creates, pair components included, carries
|
||||
category `scanned`, tag `auto-scanned` and the note `Auto-scanned from
|
||||
<file>` (`SCAN_MARKS`); a reused prompt keeps its fields. Run counts are
|
||||
never touched.
|
||||
|
||||
Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
|
||||
`tests/test_scanned_prompts.py` (store, conflict, rollback, concurrency with
|
||||
`FirstWriterHold`, pair marks), `tests/test_pair_scan.py` `TestScanDecision`,
|
||||
`tests/test_run_attribution.py` (dataset branches linked by the recorded
|
||||
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
|
||||
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
|
||||
|
||||
## Linked prompt text (#343)
|
||||
|
||||
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
|
||||
@@ -5439,8 +5572,26 @@ 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. The history page's "tracked runs"
|
||||
can stay below the Library count for these prompts (#403).
|
||||
- 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).
|
||||
- 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,
|
||||
@@ -5448,7 +5599,10 @@ 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.
|
||||
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`.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p "test_cached_runs.py"
|
||||
@@ -5547,10 +5701,12 @@ 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; unknown cached dynamic raw; unsupported
|
||||
text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); 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. SQL NULL means unknown; empty text is
|
||||
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
|
||||
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
|
||||
|
||||
@@ -56,9 +56,11 @@ from .models import BUSY_TIMEOUT_SECONDS, PromptModel
|
||||
try:
|
||||
from ..utils import library_location as ll
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.workspace_keys import KEY_FILE_NAME
|
||||
except ImportError:
|
||||
from utils import library_location as ll
|
||||
from utils.logging_config import get_logger
|
||||
from utils.workspace_keys import KEY_FILE_NAME
|
||||
|
||||
logger = get_logger("prompt_manager.database.library_move")
|
||||
|
||||
@@ -491,7 +493,8 @@ class _Move:
|
||||
sweep_move_temps(target)
|
||||
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
|
||||
# An earlier move stopped before it switched: its files are ours.
|
||||
for name in (ll.DATABASE_NAME, ll.CONFIG_NAME) + self.plan.backups:
|
||||
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
|
||||
for name in names + self.plan.backups:
|
||||
_remove(os.path.join(target, name))
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
|
||||
@@ -536,6 +539,7 @@ class _Move:
|
||||
raise MoveFailed("copy-failed") from error
|
||||
_check_copies([(self.plan.config_path, temp, expected)])
|
||||
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
|
||||
copies = copies + self._key_copy()
|
||||
self.marker = write_marker(
|
||||
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
|
||||
)
|
||||
@@ -556,6 +560,28 @@ class _Move:
|
||||
self.checkpoint("marked")
|
||||
return files
|
||||
|
||||
def _key_copy(self) -> list:
|
||||
"""The workspace key's verified copy (#430), or none: it is re-creatable.
|
||||
|
||||
Without it the moved library starts with a new key, so pages left open
|
||||
across the restart refresh once; that never fails the move.
|
||||
"""
|
||||
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
|
||||
if not os.path.isfile(source):
|
||||
return []
|
||||
temp = self.temp(".key")
|
||||
try:
|
||||
expected = _hash_copy(source, temp)
|
||||
_check_copies([(source, temp, expected)])
|
||||
except (OSError, MoveFailed) as error:
|
||||
_remove(temp)
|
||||
logger.warning(
|
||||
f"Library move: the workspace key was not copied ({error}); "
|
||||
"open workspace pages refresh once after the restart"
|
||||
)
|
||||
return []
|
||||
return [(source, temp, expected, KEY_FILE_NAME)]
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self.moved:
|
||||
return
|
||||
|
||||
+111
-15
@@ -1,7 +1,14 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
"""Atomic metadata enrichment for a single known generation context.
|
||||
|
||||
Also the helpers both ingestion store paths share (#247): the file's link
|
||||
rows and the one-prompt-per-file rule (``file_link_rows``,
|
||||
``link_conflict``) and the marks a prompt created by ingestion carries
|
||||
(``Marks``, ``mark_created``).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
@@ -37,16 +44,85 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
|
||||
return not (isinstance(text, str) and text.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Marks:
|
||||
"""What a prompt ingestion creates carries, so it can be found and removed.
|
||||
|
||||
``note`` is formatted with ``name``, the file's name.
|
||||
"""
|
||||
|
||||
category: str
|
||||
tags: Tuple[str, ...]
|
||||
note: str
|
||||
|
||||
|
||||
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
|
||||
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
|
||||
|
||||
|
||||
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
|
||||
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
|
||||
conn.execute(
|
||||
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
|
||||
(marks.category, marks.note.format(name=name), prompt_id),
|
||||
)
|
||||
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
|
||||
|
||||
|
||||
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
|
||||
"""The file's link rows, each with its prompt's hash.
|
||||
|
||||
Matched under the path and its resolved spelling (an output folder
|
||||
reached through a symlink or junction), each normalized and raw;
|
||||
deduplicated by row id.
|
||||
"""
|
||||
rows: Dict[Any, Any] = {}
|
||||
for spelling in dict.fromkeys((image_path, _resolved(image_path))):
|
||||
for row in conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalize_image_path(spelling), spelling),
|
||||
).fetchall():
|
||||
rows.setdefault(row["id"], row)
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def _resolved(path: str) -> str:
|
||||
try:
|
||||
return os.path.realpath(path)
|
||||
except (OSError, ValueError):
|
||||
return path
|
||||
|
||||
|
||||
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
|
||||
"""One prompt per file: more than one row, or a row of another prompt."""
|
||||
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
db: Any,
|
||||
image_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
marks: Optional[Marks] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Store a resolved pair and link the file to it in one transaction.
|
||||
|
||||
With ``marks`` (ingestion from files), each pair component this call
|
||||
creates carries them; without (the PromptManager node path) components
|
||||
are created bare, as always.
|
||||
"""
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state == "resolved" and blank_positive(resolution):
|
||||
state = "unresolved"
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
with db._pair_transaction() as conn:
|
||||
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
|
||||
return _ingest_resolved_pair_in(
|
||||
conn, db, image_path, metadata, resolution, marks=marks
|
||||
)
|
||||
|
||||
|
||||
def _conflict() -> Dict[str, Any]:
|
||||
@@ -61,6 +137,7 @@ def _ingest_resolved_pair_in(
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
provenance: Dict[str, Any] | None = None,
|
||||
marks: Optional[Marks] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
|
||||
|
||||
@@ -69,7 +146,8 @@ def _ingest_resolved_pair_in(
|
||||
negative is a ``conflict`` and nothing is written. The caller owns the
|
||||
transaction, so its own later failure rolls the link back too.
|
||||
``provenance`` replaces the link's default resolver provenance (Needs
|
||||
review records the user's selection there).
|
||||
review records the user's selection there). ``marks`` as in
|
||||
``ingest_resolved_pair``.
|
||||
"""
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
@@ -82,15 +160,8 @@ def _ingest_resolved_pair_in(
|
||||
"negative": negative,
|
||||
}
|
||||
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalized, image_path),
|
||||
).fetchall()
|
||||
if len(images) > 1 or (
|
||||
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
|
||||
):
|
||||
images = file_link_rows(conn, image_path)
|
||||
if link_conflict(images, generate_prompt_hash(positive_text)):
|
||||
return _conflict()
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
@@ -115,7 +186,12 @@ def _ingest_resolved_pair_in(
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
if marks is None:
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
else:
|
||||
pair_id = _save_marked_pair(
|
||||
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
|
||||
)
|
||||
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
@@ -197,6 +273,26 @@ def _ingest_resolved_pair_in(
|
||||
}
|
||||
|
||||
|
||||
def _save_marked_pair(
|
||||
conn: Any,
|
||||
db: Any,
|
||||
positive_text: str,
|
||||
negative_text: str,
|
||||
marks: Marks,
|
||||
name: str,
|
||||
) -> int:
|
||||
"""``_save_pair`` that marks each component it creates."""
|
||||
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
|
||||
ids = []
|
||||
for text in texts:
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, name)
|
||||
ids.append(prompt_id)
|
||||
negative_id = ids[1] if len(ids) > 1 else None
|
||||
return db._save_pair_components(conn, ids[0], negative_id)[0]
|
||||
|
||||
|
||||
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``values`` with the workflow and prompt graph texts stored once.
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
|
||||
return found
|
||||
|
||||
|
||||
def _recorded_runs(
|
||||
def recorded_runs(
|
||||
db: Any,
|
||||
image_paths: Iterable[str],
|
||||
output_root: Optional[str],
|
||||
@@ -201,7 +201,8 @@ def _recorded_runs(
|
||||
return attributed
|
||||
|
||||
|
||||
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
def named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
"""The one stored prompt every positive context of ``run_id`` names, else None."""
|
||||
contexts = db.get_execution_contexts(run_id).get("contexts") or []
|
||||
named = {
|
||||
context.get("positive_prompt_id")
|
||||
@@ -228,11 +229,11 @@ def stored_run_prompts(
|
||||
paths = list(dict.fromkeys(path for path in image_paths if path))
|
||||
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
|
||||
prompts: Dict[str, Optional[int]] = {}
|
||||
for path, (run_id, _nodes) in _recorded_runs(
|
||||
for path, (run_id, _nodes) in recorded_runs(
|
||||
db, paths, output_root, path_style
|
||||
).items():
|
||||
if run_id not in prompts:
|
||||
prompts[run_id] = _named_prompt(db, run_id)
|
||||
prompts[run_id] = named_prompt(db, run_id)
|
||||
result[path] = prompts[run_id]
|
||||
return result
|
||||
|
||||
@@ -275,7 +276,7 @@ def recorded_output(
|
||||
) -> Optional[RecordedOutput]:
|
||||
"""The recorded run facts for ``image_path``, else None (#386).
|
||||
|
||||
The file must be attributed to one run (``_recorded_runs``: one run
|
||||
The file must be attributed to one run (``recorded_runs``: one run
|
||||
reported its location and it was written during that run). Read-only; it
|
||||
never links anything.
|
||||
"""
|
||||
@@ -286,7 +287,7 @@ def recorded_output(
|
||||
if own_transaction: # outputs and texts from one state of history
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
found = _recorded_runs(db, [image_path], output_root, path_style)
|
||||
found = recorded_runs(db, [image_path], output_root, path_style)
|
||||
if image_path not in found:
|
||||
return None
|
||||
run_id, nodes = found[image_path]
|
||||
|
||||
@@ -35,6 +35,9 @@ _LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
# A collection id as the listing's ``collection`` query takes it (#331): none
|
||||
# ("") or 1 to 9 digits without a leading zero.
|
||||
_COLLECTION = re.compile(r"|[1-9][0-9]{0,8}")
|
||||
FALLBACK_NAME = "Saved view"
|
||||
|
||||
|
||||
@@ -55,6 +58,9 @@ _STATE_FIELDS = {
|
||||
"tags": ("tags", None, ()),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None, False),
|
||||
# A view saved on a collection page (#331 D140) reopens that
|
||||
# collection; never together with favorites.
|
||||
"collection": ("collection", None, ""),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500, ""),
|
||||
@@ -125,6 +131,8 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
raise SavedViewInvalid(f"State '{key}' is not a supported value")
|
||||
if kind == "source" and not _SOURCE.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
|
||||
if kind == "collection" and not _COLLECTION.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be empty or a collection id")
|
||||
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
|
||||
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
|
||||
|
||||
@@ -142,6 +150,8 @@ def encode_state(page: object, state: object) -> str:
|
||||
)
|
||||
for key, (kind, limit, _default) in fields.items():
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
@@ -181,6 +191,10 @@ def normalize_state(page: str, raw: object) -> dict:
|
||||
except SavedViewInvalid:
|
||||
value = default
|
||||
state[key] = list(value) if kind == "tags" else value
|
||||
# Never written by this build; a stored row with both opens the collection,
|
||||
# as the listing does for a URL with both (#331 D140).
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
state["favorites"] = False
|
||||
return state
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Link an output file to the prompt of one text, creating it when new (#247).
|
||||
|
||||
The store step of ingestion from file metadata when the decision is a
|
||||
positive text (``utils.file_prompt``). The prompt insert and the link run in
|
||||
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
|
||||
a failed link leaves no new prompt behind, and a file stays linked to one
|
||||
prompt (a file already linked to another prompt is a ``conflict``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
|
||||
|
||||
|
||||
def store_scanned_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
|
||||
|
||||
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
|
||||
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
|
||||
another prompt; nothing written, id None). Database errors are raised
|
||||
and roll the whole store back, so a caller can run it again.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if link_conflict(rows, generate_prompt_hash(text)):
|
||||
return "conflict", None
|
||||
if rows:
|
||||
return "unchanged", rows[0]["prompt_id"]
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return ("added" if created else "linked"), prompt_id
|
||||
|
||||
|
||||
def store_scanned_prompt(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""The scan's form: an error is logged and reported as ``("found", None)``.
|
||||
|
||||
A write that failed because Move library switched runs again on the new
|
||||
library (``survive_library_switch``); the rerun re-checks the link rows.
|
||||
"""
|
||||
try:
|
||||
return db.survive_library_switch(
|
||||
lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks)
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.error(
|
||||
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
|
||||
)
|
||||
return "found", None
|
||||
|
||||
|
||||
def link_stored_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], prompt_id: int
|
||||
) -> Optional[str]:
|
||||
"""Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342).
|
||||
|
||||
``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to
|
||||
another prompt; nothing written) or None when the prompt is gone. One
|
||||
``BEGIN IMMEDIATE`` transaction; database errors are raised.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id):
|
||||
return "conflict"
|
||||
if rows:
|
||||
return "unchanged"
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM prompts WHERE id = ?", (prompt_id,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
return None
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return "linked"
|
||||
+6
-1
@@ -275,7 +275,12 @@ 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)
|
||||
self._record_cached_item(
|
||||
unique_id,
|
||||
(encoding_text,),
|
||||
storage_text,
|
||||
fields=((text, prepend_text, append_text),),
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
|
||||
+13
-3
@@ -243,14 +243,24 @@ class PromptManagerBase:
|
||||
return False
|
||||
|
||||
def _record_cached_item(
|
||||
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
|
||||
self,
|
||||
unique_id: Any,
|
||||
outputs: tuple,
|
||||
text: Any,
|
||||
negative_text: Any = None,
|
||||
fields: Optional[tuple] = None,
|
||||
) -> None:
|
||||
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
|
||||
"""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).
|
||||
"""
|
||||
if unique_id is None:
|
||||
return
|
||||
try:
|
||||
self._cached_runs().record_item(
|
||||
unique_id, outputs, stored_facts(text, negative_text)
|
||||
unique_id, outputs, stored_facts(text, negative_text), fields
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.debug(f"Could not record the cached-run receipt: {error}")
|
||||
|
||||
@@ -198,7 +198,11 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
type(self).__name__, negative_text, "", "", negative_text, role="negative"
|
||||
)
|
||||
self._record_cached_item(
|
||||
unique_id, (positive, negative_text), text, negative_text
|
||||
unique_id,
|
||||
(positive, negative_text),
|
||||
text,
|
||||
negative_text,
|
||||
fields=((text, prepend_text, append_text), (negative_text, "", "")),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -246,7 +246,12 @@ 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)
|
||||
self._record_cached_item(
|
||||
unique_id,
|
||||
(final_text,),
|
||||
storage_text,
|
||||
fields=((text, prepend_text, append_text),),
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
|
||||
+6
-82
@@ -74,9 +74,11 @@ from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
from .lora_import_routes import LoraImportRoutesMixin
|
||||
from .image_categories import ImageCategoryRoutesMixin
|
||||
from .source_rescope import SourceRescopeMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
@@ -86,6 +88,7 @@ except ImportError:
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
@@ -340,6 +343,7 @@ class PromptManagerAPI(
|
||||
LoraIntegrationMixin,
|
||||
LoraImportRoutesMixin,
|
||||
ImageCategoryRoutesMixin,
|
||||
SourceRescopeMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
|
||||
@@ -706,6 +710,7 @@ class PromptManagerAPI(
|
||||
self._register_lora_routes(routes)
|
||||
self._register_lora_import_routes(routes)
|
||||
self._register_image_category_routes(routes)
|
||||
self._register_rescope_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
global _gzip_registered
|
||||
@@ -1031,85 +1036,4 @@ class PromptManagerAPI(
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
# Check for direct prompt field
|
||||
if "prompt" in metadata:
|
||||
try:
|
||||
prompt_data = json.loads(metadata["prompt"])
|
||||
result["prompt"] = prompt_data
|
||||
except json.JSONDecodeError:
|
||||
result["prompt"] = metadata["prompt"]
|
||||
|
||||
# Check for workflow
|
||||
if "workflow" in metadata:
|
||||
try:
|
||||
workflow_data = json.loads(metadata["workflow"])
|
||||
result["workflow"] = workflow_data
|
||||
except json.JSONDecodeError:
|
||||
result["workflow"] = metadata["workflow"]
|
||||
|
||||
# Check for other common ComfyUI fields
|
||||
common_fields = [
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
]
|
||||
for field in common_fields:
|
||||
if field in metadata:
|
||||
try:
|
||||
result["parameters"][field] = json.loads(metadata[field])
|
||||
except json.JSONDecodeError:
|
||||
result["parameters"][field] = metadata[field]
|
||||
|
||||
return result
|
||||
return parse_comfyui_prompt(metadata)
|
||||
|
||||
+153
-248
@@ -40,45 +40,51 @@ except ImportError:
|
||||
from utils import exit_signal
|
||||
|
||||
try:
|
||||
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from ...database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
)
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
from ...database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from ...utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
)
|
||||
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from ...utils.review_capture import reviewable
|
||||
except ImportError:
|
||||
from database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
)
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
from database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
)
|
||||
from utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from utils.review_capture import reviewable
|
||||
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
from ...utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from database.library import display_title
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
|
||||
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
|
||||
@@ -258,7 +264,7 @@ SCAN_DETAILS = {
|
||||
"skipped": "Skipped · already linked",
|
||||
"ambiguous": "Needs review: more than one prompt could match",
|
||||
"unresolved": "Needs review: the prompt could not be traced in the workflow",
|
||||
"conflict": "Needs review: already linked to a different prompt",
|
||||
"conflict": "Kept: already linked to a different prompt",
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
@@ -325,22 +331,6 @@ def _file_size(path):
|
||||
return None
|
||||
|
||||
|
||||
def _with_file_size(canonical, media_file):
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
|
||||
file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = _file_size(media_file)
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(absolute root, public label, listing index), the most specific first.
|
||||
|
||||
@@ -596,18 +586,56 @@ class _BatchHistory:
|
||||
"""A scan batch's recorded-history lookup, made on the first file needing it.
|
||||
|
||||
Most files link through their metadata and never ask; the first one that
|
||||
does triggers one lookup for the whole batch (``_recorded_runs_for``).
|
||||
does triggers one ``recorded_runs`` pass for the whole batch, from which
|
||||
both the stored prompt (``get``) and the recorded saver (``recorded``)
|
||||
of every file are read. A failure is logged and names nothing.
|
||||
"""
|
||||
|
||||
def __init__(self, api, media_files):
|
||||
self._api = api
|
||||
self._files = media_files
|
||||
self._found = None
|
||||
self._files = [str(path) for path in media_files]
|
||||
self._runs = None
|
||||
self._prompts = {}
|
||||
|
||||
def _found(self):
|
||||
if self._runs is None:
|
||||
try:
|
||||
self._runs = recorded_runs(
|
||||
self._api.db, self._files, host_output_directory(), None
|
||||
)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._runs = {}
|
||||
return self._runs
|
||||
|
||||
def get(self, path):
|
||||
if self._found is None:
|
||||
self._found = self._api._recorded_runs_for(self._files)
|
||||
return self._found.get(path)
|
||||
"""The stored prompt id history attributes to ``path``, else None."""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
run_id = found[0]
|
||||
if run_id not in self._prompts:
|
||||
try:
|
||||
self._prompts[run_id] = named_prompt(self._api.db, run_id)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._prompts[run_id] = None
|
||||
return self._prompts[run_id]
|
||||
|
||||
def recorded(self, path):
|
||||
"""``RecordedOutput(saver, {})`` for an attributed file, else None.
|
||||
|
||||
``saver`` is set only when the run reported the file from one node.
|
||||
"""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
nodes = found[1]
|
||||
return RecordedOutput(next(iter(nodes)) if len(nodes) == 1 else None, {})
|
||||
|
||||
|
||||
class AdminRoutesMixin:
|
||||
@@ -1672,24 +1700,6 @@ class AdminRoutesMixin:
|
||||
note(media_file, status)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(
|
||||
self,
|
||||
media_file,
|
||||
@@ -1700,148 +1710,81 @@ class AdminRoutesMixin:
|
||||
records=None,
|
||||
history=None,
|
||||
):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
|
||||
"""Store the prompt the shared per-file decision names (#247).
|
||||
|
||||
Returns the file's scan status, or None without prompt metadata. With
|
||||
``detailed``, a stored or linked file returns a dict instead: its
|
||||
``status``, the prompt's display ``title`` and, for a resolved pair,
|
||||
the pair counts. When the resolver finds the file ambiguous (whether
|
||||
or not the browser parser then links it) and ``ambiguous`` is a list,
|
||||
``media_file`` is appended to it; only a file that then returns
|
||||
``"ambiguous"`` (nothing linked it) is a Needs review case. An
|
||||
ambiguous result with nothing to choose (``reviewable`` is False: fewer
|
||||
than two candidates carry a prompt) that nothing links returns
|
||||
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
|
||||
The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict.
|
||||
``utils.file_prompt.decide_file_prompt`` (scan policy) decides; this
|
||||
stores the result. Returns the file's scan status, or None without
|
||||
prompt metadata. With ``detailed``, a stored or linked file returns a
|
||||
dict instead: its ``status``, the prompt's display ``title`` and, for
|
||||
a resolved pair, the pair counts. When the resolver finds the file
|
||||
ambiguous (whether or not a later step then links it) and
|
||||
``ambiguous`` is a list, ``media_file`` is appended to it; only a file
|
||||
that then returns ``"ambiguous"`` (nothing linked it) is a Needs
|
||||
review case. An ambiguous result with nothing to choose
|
||||
(``reviewable`` is False: fewer than two candidates carry a prompt)
|
||||
goes to ``untraced`` instead and returns ``"unresolved"`` when nothing
|
||||
links it. The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict. ``history`` is
|
||||
the batch's lazy recorded-history lookup.
|
||||
"""
|
||||
if not metadata:
|
||||
# No prompt metadata (--disable-metadata, bare savers): only the
|
||||
# recorded run can say which prompt made it (#342)
|
||||
return self._link_recorded_run(media_file, {}, detailed, history)
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: no prompt, link,
|
||||
# recorded-run fallback or Needs review item (#90)
|
||||
return "not_saved"
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it, never a scan
|
||||
return "not_saved_wired"
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return self._link_recorded_run(media_file, canonical, detailed, history)
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: store the positive alone (#90)
|
||||
prompt_text = (
|
||||
resolution["positive"]
|
||||
.get("storage_text", resolution["positive"]["text"])
|
||||
.strip()
|
||||
)
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {
|
||||
"status": status,
|
||||
"title": self._scan_title(media_file, prompt_text),
|
||||
}
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
# Ambiguous without a choice: unresolved unless something links it.
|
||||
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
|
||||
if unchoosable:
|
||||
if untraced is not None:
|
||||
untraced.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = canonical
|
||||
elif resolution["state"] == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = canonical
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
path = str(media_file)
|
||||
if history is None: # one file: history is still asked once
|
||||
history = _BatchHistory(self, [media_file])
|
||||
decision = decide_file_prompt(
|
||||
metadata,
|
||||
file_name=Path(path).name,
|
||||
file_size=functools.partial(_file_size, media_file),
|
||||
in_output=False, # only the monitor policy reads it
|
||||
lookup=functools.partial(history.recorded, path),
|
||||
policy=Policy.SCAN,
|
||||
)
|
||||
if decision.kind in (NOT_SAVED, NOT_SAVED_WIRED):
|
||||
# Save to library off (#90): no write, recorded-run fallback or
|
||||
# Needs review item
|
||||
return decision.kind
|
||||
self._note_review(media_file, decision, ambiguous, untraced, records)
|
||||
if decision.kind == NEVER:
|
||||
if decision.try_recorded_run:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, decision.canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return decision.unlinked_status
|
||||
if decision.kind == PAIR:
|
||||
# A rerun after a library move re-checks the link rows.
|
||||
result = self.db.survive_library_switch(
|
||||
lambda: ingest_resolved_pair(
|
||||
self.db,
|
||||
path,
|
||||
decision.canonical,
|
||||
decision.resolution,
|
||||
marks=SCAN_MARKS,
|
||||
)
|
||||
)
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if unchoosable else resolution["state"]
|
||||
elif resolution["state"] == "ambiguous":
|
||||
return "unresolved" if unchoosable else "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
elif canonical.get("prompt"):
|
||||
# No node takes a positive or negative input (a guider or wrapper
|
||||
# graph): the one wired text, never a guess (#252).
|
||||
found = sampler_less_prompt(canonical["prompt"])
|
||||
prompt_text = found["text"]
|
||||
if prompt_text is None and isinstance(canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
prompt_text = parsed.strip() if parsed and parsed.strip() else None
|
||||
if prompt_text is None:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if found["state"] == "ambiguous" else "found"
|
||||
else:
|
||||
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
title_text = decision.resolution["positive"]["text"]
|
||||
return {**result, "title": self._scan_title(media_file, title_text)}
|
||||
status, _prompt_id = store_scanned_prompt(
|
||||
self.db, path, decision.canonical, decision.text, SCAN_MARKS
|
||||
)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
|
||||
return {"status": status, "title": self._scan_title(media_file, decision.text)}
|
||||
|
||||
def _recorded_runs_for(self, media_files):
|
||||
"""``{path: prompt id or None}`` from recorded history for a scan batch.
|
||||
|
||||
One output lookup per chunk of names (``stored_run_prompts``), so a
|
||||
batch never queries history once per file. An empty dict on failure.
|
||||
"""
|
||||
try:
|
||||
return stored_run_prompts(
|
||||
self.db, [str(path) for path in media_files], host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
return {}
|
||||
@staticmethod
|
||||
def _note_review(media_file, decision, ambiguous, untraced, records):
|
||||
"""Hand an ambiguous file to the batch's Needs review lists."""
|
||||
if decision.review == "untraced":
|
||||
if untraced is not None:
|
||||
untraced.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
elif decision.review == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
|
||||
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
|
||||
"""Link a file recorded history attributes to one stored prompt (#342).
|
||||
@@ -1850,31 +1793,25 @@ class AdminRoutesMixin:
|
||||
``{"status", "title"}``), or None when history names no single stored
|
||||
prompt for the file (``database.run_attribution``). Never creates a
|
||||
prompt. Works without any prompt metadata in the file. ``history`` is
|
||||
the batch's precomputed lookup; without it the file is looked up alone.
|
||||
the batch's lookup (``_BatchHistory``). A file already linked to
|
||||
another prompt is a ``conflict``: one prompt per file.
|
||||
"""
|
||||
if history is not None:
|
||||
prompt_id = history.get(str(media_file))
|
||||
else:
|
||||
try:
|
||||
prompt_id = stored_run_prompt(
|
||||
self.db, str(media_file), host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
f"History attribution failed for {media_file.name}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if history is None:
|
||||
history = _BatchHistory(self, [media_file])
|
||||
prompt_id = history.get(str(media_file))
|
||||
if prompt_id is None:
|
||||
return None
|
||||
try:
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
prompt_id, str(media_file), metadata=canonical
|
||||
status = self.db.survive_library_switch(
|
||||
lambda: link_stored_prompt_or_raise(
|
||||
self.db, str(media_file), canonical, prompt_id
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
|
||||
return "found"
|
||||
status = "linked" if image_id else "unchanged"
|
||||
if status is None:
|
||||
return None
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
prompt = self.db.get_prompt_by_id(prompt_id) or {}
|
||||
@@ -1894,35 +1831,3 @@ class AdminRoutesMixin:
|
||||
except Exception:
|
||||
self.logger.warning("Scan row title could not be read; using its text")
|
||||
return display_title((), text)
|
||||
|
||||
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
|
||||
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
try:
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
existing["id"], str(media_file), metadata=canonical
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link {media_file.name} to existing prompt: {e}"
|
||||
)
|
||||
return "found"
|
||||
return "linked" if image_id else "unchanged"
|
||||
|
||||
prompt_id = self.db.save_prompt(
|
||||
prompt_text,
|
||||
"scanned",
|
||||
["auto-scanned"],
|
||||
None,
|
||||
f"Auto-scanned from {media_file.name}",
|
||||
prompt_hash,
|
||||
)
|
||||
if not prompt_id:
|
||||
return "found"
|
||||
try:
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
|
||||
return "added"
|
||||
|
||||
+74
-17
@@ -13,6 +13,7 @@ import re
|
||||
import stat
|
||||
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
|
||||
import sys
|
||||
import threading
|
||||
import time as _time
|
||||
import urllib.parse
|
||||
from bisect import bisect_left, bisect_right, insort
|
||||
@@ -81,6 +82,7 @@ try:
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
from ...utils.workspace_keys import gallery_root_scope, workspace_keys_for
|
||||
except ImportError:
|
||||
from database.image_collections import CollectionMissing
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
@@ -105,6 +107,7 @@ except ImportError:
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
from utils.workspace_keys import gallery_root_scope, workspace_keys_for
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
@@ -518,7 +521,8 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
|
||||
_READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
|
||||
# Guards the first load of an API's workspace keys (#430, _workspace_keys).
|
||||
_WORKSPACE_KEYS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
|
||||
@@ -618,15 +622,37 @@ def _rated_key(entry: _Listed, rank: int) -> tuple:
|
||||
return (-rank, *_workspace_key(entry, "rating"))
|
||||
|
||||
|
||||
def _workspace_cursor(key: tuple, scope: str) -> str:
|
||||
# Bump whenever the cursor's key tuple changes shape: a cursor now outlives a
|
||||
# restart and an upgrade (#430), so an old shape must be refused, not parsed.
|
||||
_CURSOR_VERSION = 2
|
||||
_CURSOR_STALE = "The image collection changed; refresh the gallery"
|
||||
|
||||
|
||||
class CursorStale(ValueError):
|
||||
"""A well-formed cursor this listing cannot verify (#430).
|
||||
|
||||
Another key, another root list or other filters: the page refreshes its
|
||||
collection instead of retrying the same cursor.
|
||||
"""
|
||||
|
||||
|
||||
def _cursor_mac(secret: bytes, scope: str, payload: bytes) -> bytes:
|
||||
# The listing scope (it hashes every absolute root) is bound in the MAC
|
||||
# only, never sent: the scope is 64 hex, so the "\n" join is unambiguous.
|
||||
return hmac.new(secret, scope.encode() + b"\n" + payload, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _workspace_cursor(key: tuple, scope: str, secret: bytes) -> str:
|
||||
"""The signed keyset cursor of *key*; *secret* is the API's cursor key."""
|
||||
payload = json.dumps(
|
||||
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
|
||||
{"v": _CURSOR_VERSION, "key": key}, separators=(",", ":"), ensure_ascii=False
|
||||
).encode()
|
||||
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
||||
signature = _cursor_mac(secret, scope, payload)
|
||||
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
|
||||
|
||||
|
||||
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) -> tuple:
|
||||
"""The keyset key of *token*; CursorStale when it is not this listing's."""
|
||||
try:
|
||||
if len(token) > 4096:
|
||||
raise ValueError()
|
||||
@@ -634,13 +660,14 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
|
||||
)
|
||||
signature, payload = raw[:32], raw[32:]
|
||||
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
if len(signature) != 32 or not payload:
|
||||
raise ValueError()
|
||||
if not hmac.compare_digest(signature, _cursor_mac(secret, scope, payload)):
|
||||
raise CursorStale(_CURSOR_STALE)
|
||||
data = json.loads(payload)
|
||||
key = data["key"]
|
||||
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
|
||||
raise ValueError()
|
||||
if data["v"] != _CURSOR_VERSION or not isinstance(key, list):
|
||||
raise CursorStale(_CURSOR_STALE)
|
||||
types = (
|
||||
(str, str, int, (float, int))
|
||||
if order in ("name", "name-desc")
|
||||
@@ -654,6 +681,8 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
):
|
||||
raise ValueError()
|
||||
return tuple(key)
|
||||
except CursorStale:
|
||||
raise
|
||||
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
|
||||
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
|
||||
|
||||
@@ -1338,13 +1367,27 @@ def _in_subfolder(rel_dir, subfolder):
|
||||
class ImageRoutesMixin:
|
||||
"""Mixin providing image and gallery-related API endpoints."""
|
||||
|
||||
def _workspace_keys(self):
|
||||
"""This API's scope and cursor keys, loaded once (#430).
|
||||
|
||||
They derive from the secret persisted beside the database, so a restart
|
||||
keeps every scope and cursor an open page holds. The first call reads
|
||||
(or writes) one small file; later calls return the cached keys, which
|
||||
a live library move or restore does not change before a restart.
|
||||
"""
|
||||
keys = self.__dict__.get("_workspace_keys_cache")
|
||||
if keys is None:
|
||||
with _WORKSPACE_KEYS_LOCK:
|
||||
keys = self.__dict__.get("_workspace_keys_cache")
|
||||
if keys is None:
|
||||
db = getattr(self, "db", None)
|
||||
model = getattr(db, "model", None)
|
||||
keys = workspace_keys_for(getattr(model, "db_path", None))
|
||||
self.__dict__["_workspace_keys_cache"] = keys
|
||||
return keys
|
||||
|
||||
def _gallery_root_scope(self, root: Path, index: int) -> str:
|
||||
payload = json.dumps(
|
||||
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
|
||||
return gallery_root_scope(self._workspace_keys(), str(_canonical(root)), index)
|
||||
|
||||
def _gallery_scope_error(
|
||||
self, query: Mapping, roots: list[Path]
|
||||
@@ -2008,6 +2051,8 @@ class ImageRoutesMixin:
|
||||
limit, _offset = parse_page_params(request.query, default_limit=100)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
# #430: the first use may create the key file; never on the event loop.
|
||||
cursor_secret = (await self._run_in_executor(self._workspace_keys)).cursor
|
||||
try:
|
||||
root_index = _workspace_root(request.query, output_dirs)
|
||||
filters = _workspace_filters(request.query)
|
||||
@@ -2031,7 +2076,15 @@ class ImageRoutesMixin:
|
||||
).hexdigest()
|
||||
token = request.query.get("cursor")
|
||||
after = (
|
||||
_workspace_cursor_key(token, scope, filters["order"]) if token else None
|
||||
_workspace_cursor_key(token, scope, filters["order"], cursor_secret)
|
||||
if token
|
||||
else None
|
||||
)
|
||||
except CursorStale as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "code": "cursor-stale", "error": str(exc)},
|
||||
status=400,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
@@ -2472,7 +2525,11 @@ class ImageRoutesMixin:
|
||||
positive_count = _COUNT_OMITTED
|
||||
has_more = len(page) > limit
|
||||
page = page[:limit]
|
||||
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
|
||||
next_cursor = (
|
||||
_workspace_cursor(key(page[-1]), scope, self._workspace_keys().cursor)
|
||||
if has_more
|
||||
else None
|
||||
)
|
||||
return (
|
||||
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
|
||||
total,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The rule (maintainer test of #284/#288): a file is queued only when its
|
||||
final outcome is ambiguous and unlinked. The strict resolver must find it
|
||||
``ambiguous``, the scan's legacy fallback (the browser parser,
|
||||
``_graph_prompt_text``) must not link it, and no saved row may link it
|
||||
``graph_prompt_text``) must not link it, and no saved row may link it
|
||||
already. A file the fallback links, an exact pair the ingester stored and an
|
||||
already linked file are never queued. Neither is an ambiguous file with
|
||||
nothing to choose: fewer than two of its candidates carry a prompt
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Where a stale Images target is now (#430): ``POST /prompt_manager/images/rescope``.
|
||||
|
||||
A page addresses a listed file as ``{root_index, root_scope, path}``. When the
|
||||
sources are renumbered its scope no longer names the root at that index, and
|
||||
every per-file action answers 409 ``source-changed``. Before it gives up, the
|
||||
page asks here. The target is mapped only when its scope is, at its old
|
||||
index, the scope of one of the current roots: the server issued that scope
|
||||
for exactly that folder, so the answer (the file's current listing
|
||||
descriptor) names nothing the page could not already address. A removed or
|
||||
replaced root, a scope of another key and a foreign scope stay refused.
|
||||
|
||||
Only the Images roots (``_get_all_output_dirs``) are mapped; a LoRA-linked
|
||||
root's target is never re-scoped here.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .images import (
|
||||
_forbidden,
|
||||
_is_thumbnail_cache_path,
|
||||
_json_body_or_error,
|
||||
_media_access_error,
|
||||
)
|
||||
from .media_target import media_target
|
||||
from .prompts import bad_request
|
||||
|
||||
# Far above any real source count; keeps the index out of huge-int territory.
|
||||
MAX_ROOT_INDEX = 4095
|
||||
SOURCE_GONE = (
|
||||
"The source of this image changed or was removed. Refresh the image "
|
||||
"collection to find it again."
|
||||
)
|
||||
|
||||
|
||||
def _coded(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message}, status=status
|
||||
)
|
||||
|
||||
|
||||
def _no_store(response: web.Response) -> web.Response:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
class SourceRescopeMixin:
|
||||
"""The rescope route; composed into ``PromptManagerAPI``."""
|
||||
|
||||
def _register_rescope_routes(self, routes):
|
||||
@routes.post("/prompt_manager/images/rescope")
|
||||
async def rescope_route(request):
|
||||
return _no_store(await self.rescope_image(request))
|
||||
|
||||
def _current_root_for(self, scope: str, old_index: int, roots: list) -> int | None:
|
||||
"""The index of the first current root *scope* was issued for at *old_index*."""
|
||||
for index, root in enumerate(roots):
|
||||
if hmac.compare_digest(scope, self._gallery_root_scope(root, old_index)):
|
||||
return index
|
||||
return None
|
||||
|
||||
def _rescope_blocking(self, scope: str, old_index: int, relative: str):
|
||||
"""The answer to one rescope (blocking: it resolves paths)."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
index = self._current_root_for(scope, old_index, roots)
|
||||
if index is None:
|
||||
return _coded(409, "source-changed", SOURCE_GONE)
|
||||
root = Path(roots[index])
|
||||
path = root / relative
|
||||
denied = _media_access_error(path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
try:
|
||||
mtime = path.stat().st_mtime if path.is_file() else None
|
||||
except OSError:
|
||||
mtime = None
|
||||
if mtime is None:
|
||||
return _coded(404, "file-missing", "Image file not found")
|
||||
[image] = self._format_workspace_page([(path, mtime, root, index)])
|
||||
return web.json_response({"success": True, "image": image})
|
||||
|
||||
async def rescope_image(self, request) -> web.Response:
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
if set(data) != {"target"}:
|
||||
return bad_request("Send exactly one target")
|
||||
target, error = media_target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
old_index, scope, relative = target
|
||||
if old_index > MAX_ROOT_INDEX:
|
||||
return bad_request("Invalid gallery root")
|
||||
if _is_thumbnail_cache_path(relative):
|
||||
return _forbidden("Previews are not re-scoped; use the original")
|
||||
return await self._run_in_executor(
|
||||
self._rescope_blocking, scope, old_index, relative
|
||||
)
|
||||
+67
-17
@@ -28,6 +28,11 @@ import weakref
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows: no flock, and no /dev/shm to share
|
||||
fcntl = None
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Set to 1 to run seeders on the live database as before #384 (the equivalence
|
||||
@@ -43,8 +48,13 @@ MEMORY_ROOT = Path("/dev/shm")
|
||||
# the browsers and the other workers; with less free than this, the scratch
|
||||
# copy goes on disk instead.
|
||||
MEMORY_FREE_BYTES = 32 * 1024 * 1024
|
||||
# Scratch folders are named <prefix><owner pid>-<random>.
|
||||
# Scratch folders are created as <prefix>new-<owner pid>-<random>, locked, then
|
||||
# renamed to <prefix>held-<owner pid>-<random>. The pid is for people only:
|
||||
# ownership is the flock on OWNER_LOCK, which every PID namespace sees (#419).
|
||||
SCRATCH_PREFIX = "pm-reset-"
|
||||
STAGING_PREFIX = f"{SCRATCH_PREFIX}new-"
|
||||
HELD_PREFIX = f"{SCRATCH_PREFIX}held-"
|
||||
OWNER_LOCK = "owner.lock"
|
||||
|
||||
|
||||
def _quote(name: str) -> str:
|
||||
@@ -96,29 +106,40 @@ def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, s
|
||||
return result
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
def _hold(folder: Path, *, create: bool) -> int:
|
||||
"""Take ``folder``'s owner lock without waiting; the open descriptor."""
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
fd = os.open(folder / OWNER_LOCK, flags, 0o600)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return True # it exists but belongs to someone else
|
||||
return True
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
def remove_orphaned_scratch(root: Path) -> None:
|
||||
"""Remove scratch folders of servers that died without cleaning up.
|
||||
|
||||
A server killed outright (SIGKILL, a crash) never runs ``close``, and
|
||||
tmpfs keeps its folder in memory until reboot. The folder name carries
|
||||
the owner's process id; a folder whose process is gone is removed.
|
||||
tmpfs keeps its folder in memory until reboot. A live owner holds the
|
||||
flock on the folder's owner lock; the kernel drops it when the owner
|
||||
dies, in whatever PID namespace it ran (a container with ``--ipc=host``
|
||||
shares /dev/shm but not the pids, #419). A folder whose lock is free is
|
||||
removed. Folders without a lock (staging, or named before #419) cannot
|
||||
be proven orphaned and are left alone.
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return # os.kill(pid, 0) would terminate the process on Windows
|
||||
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
|
||||
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
|
||||
if pid.isdigit() and not _process_alive(int(pid)):
|
||||
if fcntl is None:
|
||||
return
|
||||
for folder in root.glob(f"{HELD_PREFIX}*"):
|
||||
try:
|
||||
fd = _hold(folder, create=False)
|
||||
except OSError:
|
||||
continue # held by its owner, no lock to judge by, or gone
|
||||
try:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class _SeedApi:
|
||||
@@ -162,6 +183,7 @@ class ScratchSeeder:
|
||||
self._fallback = Path(directory)
|
||||
self._memory_root = memory_root
|
||||
self._directory: Path | None = None
|
||||
self._owner_lock: int | None = None
|
||||
self._on_live = on_live
|
||||
self._scratch = None
|
||||
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
|
||||
@@ -266,6 +288,9 @@ class ScratchSeeder:
|
||||
if self._directory is not None:
|
||||
shutil.rmtree(self._directory, ignore_errors=True)
|
||||
self._directory = None
|
||||
if self._owner_lock is not None:
|
||||
os.close(self._owner_lock) # after the removal: never a free lock
|
||||
self._owner_lock = None
|
||||
|
||||
def _scratch_directory(self) -> Path:
|
||||
"""A folder in memory (tmpfs) where there is one, else ``directory``.
|
||||
@@ -274,10 +299,35 @@ class ScratchSeeder:
|
||||
kernel is writing back the pages they rewrite.
|
||||
"""
|
||||
root = self._memory_root
|
||||
if root is not None and root.is_dir() and os.access(root, os.W_OK):
|
||||
if (
|
||||
fcntl is not None
|
||||
and root is not None
|
||||
and root.is_dir()
|
||||
and os.access(root, os.W_OK)
|
||||
):
|
||||
remove_orphaned_scratch(root)
|
||||
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
|
||||
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
|
||||
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
|
||||
held = self._held_memory_directory(root)
|
||||
if held is not None:
|
||||
return held
|
||||
self._fallback.mkdir(parents=True, exist_ok=True)
|
||||
return self._fallback
|
||||
|
||||
def _held_memory_directory(self, root: Path) -> Path | None:
|
||||
"""A new folder under ``root`` that this seeder holds the lock of.
|
||||
|
||||
It is locked under the staging name, which no cleaner judges, and
|
||||
only then renamed: no cleaner ever sees it held-named and unlocked.
|
||||
"""
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(prefix=f"{STAGING_PREFIX}{os.getpid()}-", dir=root)
|
||||
)
|
||||
try:
|
||||
fd = _hold(staging, create=True)
|
||||
except OSError:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return None # no locks on this filesystem: use the disk
|
||||
held = root / (HELD_PREFIX + staging.name[len(STAGING_PREFIX) :])
|
||||
os.rename(staging, held)
|
||||
self._owner_lock = fd
|
||||
return held
|
||||
|
||||
@@ -38,6 +38,9 @@ async function chooseDestination(page) {
|
||||
|
||||
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
|
||||
// workspace-stale-source.spec.mjs).
|
||||
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
|
||||
try {
|
||||
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
|
||||
|
||||
@@ -46,7 +46,9 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
from tests.browser.source_change_fixture import register_source_change_commands
|
||||
from tests.browser.workspace_review_fixture import register_review_commands
|
||||
from tests.browser.workspace_collections_fixture import register_collections_commands
|
||||
from tests.browser.workspace_diagnostics_fixture import (
|
||||
register_diagnostics_commands,
|
||||
repoint_logs,
|
||||
@@ -230,8 +232,10 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
restore_sources = register_source_change_commands(routes, api)
|
||||
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
|
||||
register_review_commands(routes, api, root)
|
||||
register_collections_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
@@ -437,6 +441,7 @@ def create_app(root: Path) -> web.Application:
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
nonlocal history_fixture
|
||||
request_holds.release()
|
||||
restore_sources()
|
||||
# No preview queued by the last test may land in the new fixture.
|
||||
await api._run_in_executor(api.close_auto_thumbnails)
|
||||
library_editor_commands.reset()
|
||||
|
||||
@@ -76,13 +76,41 @@ function savedNav(nav) {
|
||||
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
|
||||
}
|
||||
|
||||
/**
|
||||
* D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's
|
||||
* "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each
|
||||
* place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to
|
||||
* open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav),
|
||||
* its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content.
|
||||
*/
|
||||
function collectionsNav(nav,navs) {
|
||||
const byName=[...nav.rows].sort((a,b)=>a.text<b.text?-1:a.text>b.text?1:0);
|
||||
if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))};
|
||||
const saved=navs.find(item=>item.id==='saved-nav');
|
||||
const {id,label,rows,...frame}=saved;
|
||||
return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text,
|
||||
count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count,
|
||||
icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon,
|
||||
width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))};
|
||||
}
|
||||
|
||||
/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */
|
||||
function contentFree(navs) {
|
||||
return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav,
|
||||
rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))});
|
||||
}
|
||||
|
||||
/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */
|
||||
function comparableNavs(reference,navs) {
|
||||
return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs);
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs);
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
@@ -99,9 +127,8 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
// D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either.
|
||||
labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
@@ -134,8 +161,9 @@ function headingFrame(heading) {
|
||||
function compare(route,reference,actual) {
|
||||
const expected=mapShellConcept(reference);
|
||||
const heading=FULL_HEADING.has(route);
|
||||
const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)};
|
||||
return [
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||||
];
|
||||
@@ -242,7 +270,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]);
|
||||
const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows;
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Source changes under an open workspace page (#430), undone by every reset.
|
||||
|
||||
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
|
||||
order, as System › Sources would renumber them, so every held
|
||||
``root_scope`` names another index and the page must re-scope its file.
|
||||
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
|
||||
change, as when ``workspace-scope.key`` is lost before a restart: a held
|
||||
scope then cannot be mapped and the page offers Refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
_CACHE = "_workspace_keys_cache"
|
||||
|
||||
|
||||
def register_source_change_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI
|
||||
) -> Callable[[], None]:
|
||||
"""Register the commands; returns the restore every reset runs."""
|
||||
# Imported here: py.config needs the host stubs create_app installs first.
|
||||
from py.config import GalleryConfig
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
def restore() -> None:
|
||||
if "roots" in saved:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
|
||||
api.invalidate_gallery_cache(retire=True)
|
||||
if "keys" in saved:
|
||||
keys = saved.pop("keys")
|
||||
if keys is None:
|
||||
api.__dict__.pop(_CACHE, None)
|
||||
else:
|
||||
api.__dict__[_CACHE] = keys
|
||||
|
||||
@routes.post("/__test__/sources/reverse")
|
||||
async def reverse(request: web.Request) -> web.Response:
|
||||
if request.query or request.can_read_body:
|
||||
raise web.HTTPBadRequest()
|
||||
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
saved.setdefault("roots", roots)
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
|
||||
await api._run_in_executor(api.invalidate_gallery_cache, True)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/scope-key/rotate")
|
||||
async def rotate(request: web.Request) -> web.Response:
|
||||
if request.query or request.can_read_body:
|
||||
raise web.HTTPBadRequest()
|
||||
saved.setdefault("keys", api.__dict__.get(_CACHE))
|
||||
api.__dict__[_CACHE] = WorkspaceKeys(
|
||||
secrets.token_bytes(32), secrets.token_bytes(32), False
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
return restore
|
||||
@@ -231,6 +231,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
|
||||
const status = () => tagDialog(page).locator('#tag-generation-status');
|
||||
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
|
||||
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
|
||||
// Unavailable model.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
@@ -269,7 +271,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
|
||||
await expect(status()).toHaveText('');
|
||||
await shot('production-file-changed');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -278,7 +281,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
await shot('production-unknown-outcome');
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
|
||||
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/collections';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const breadcrumb = page => page.locator('#workspace-breadcrumb');
|
||||
const description = page => main(page).locator('.heading p.muted');
|
||||
const missingNote = page => main(page).locator('.heading p.small');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
|
||||
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
|
||||
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
|
||||
const row = (page, name) => rows(page).filter({ hasText: name });
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
const editButton = page => main(page).locator('.heading-actions > button').first();
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
|
||||
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
|
||||
async function seed(request) {
|
||||
const response = await request.post('/__test__/collections/seed');
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).collections;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.collectionErrors = [];
|
||||
page.on('pageerror', error => page.collectionErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.collectionErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('a collection row opens its page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// D136: name order, the listed-member count, no "PROPOSED" badge.
|
||||
await expect(collectionsLabel(page)).toBeVisible();
|
||||
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(rows(page).nth(0)).toContainText('Experiments');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscapes');
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
|
||||
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
|
||||
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(page).toHaveTitle('Collection · Prompt Manager');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
for (const path of manifest.files.Landscapes) {
|
||||
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
|
||||
}
|
||||
// MC3: the member whose file is gone is not listed and is counted in the note.
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
|
||||
// Every Images filter narrows the members.
|
||||
await search(page).fill('landscape_03');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await search(page).fill('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('without collections the group shows no label and no rows', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(collectionsNav(page)).toHaveCount(1);
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
await expect(collectionsLabel(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('experiment');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_01');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(search(page)).toHaveValue('experiment');
|
||||
expect(manifest.collections.Landscapes.count).toBe(12);
|
||||
});
|
||||
|
||||
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(editButton(page)).toHaveText('Edit');
|
||||
await editButton(page).click();
|
||||
await expect(nameField(page)).toHaveValue('Landscapes');
|
||||
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
|
||||
// CUJ 3: names are unique ignoring case.
|
||||
await nameField(page).fill('experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
|
||||
await nameField(page).fill(' Landscape grids ');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
await expect(heading(page)).toHaveText('Landscape grids');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscape grids');
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
|
||||
});
|
||||
|
||||
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
|
||||
: route.fallback());
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
|
||||
await expect(editDialog(page)).toBeVisible();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
failing = false;
|
||||
});
|
||||
|
||||
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
// The stored count: 12 listed members and the missing one.
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(deleteDialog(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
|
||||
});
|
||||
|
||||
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/collection/999999`);
|
||||
await expect(heading(page)).toHaveText('Collection');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
});
|
||||
|
||||
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
|
||||
await expect(description(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const view = manifest.saved_view;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(breadcrumb(page)).toHaveText(view.name);
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
// A saved view is not the collection page: no missing note.
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
|
||||
// Save view on a collection page stores the collection.
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_1');
|
||||
await expect(imageCards(page)).toHaveCount(3);
|
||||
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Grid seed');
|
||||
await save.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
|
||||
const saved = views.find(item => item.name === 'Grid seed');
|
||||
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
|
||||
|
||||
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
|
||||
await expect(main(page).locator('.empty')).toBeVisible();
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides
|
||||
// every nav count there, so the rows keep their counts only at desktop width.
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
|
||||
test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible();
|
||||
const count = row(page, 'Landscapes').locator('.nav-count');
|
||||
if (width > 950) await expect(count).toBeVisible();
|
||||
else await expect(count).toBeHidden();
|
||||
// Phone width: the label stays hidden as every sidebar label is; desktop shows it.
|
||||
if (width > 950) await expect(collectionsLabel(page)).toBeVisible();
|
||||
else await expect(collectionsLabel(page)).toBeHidden();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`));
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
});
|
||||
}
|
||||
|
||||
test('a finished restore reads the collections again', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
try {
|
||||
const backup = await request.get('/prompt_manager/backup');
|
||||
await expect(backup).toBeOK();
|
||||
const bytes = await backup.body();
|
||||
const [experiments] = await listed(request);
|
||||
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/system/backups`);
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
|
||||
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||||
// The list was read again without a reload.
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
} finally {
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
}
|
||||
});
|
||||
|
||||
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editButton(page).click();
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('That collection no longer exists.');
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
|
||||
async function holdListReads(page, answer = route => route.fallback()) {
|
||||
const held = [];
|
||||
let open = false;
|
||||
await page.route(`**${API}`, route => {
|
||||
if (route.request().method() !== 'GET') return route.fallback();
|
||||
if (open) return answer(route);
|
||||
held.push(route);
|
||||
return undefined;
|
||||
});
|
||||
return {
|
||||
held,
|
||||
async release() {
|
||||
open = true;
|
||||
for (const route of held.splice(0)) await answer(route).catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
|
||||
const [file] = listing.images;
|
||||
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
// Another tab makes "Fresh"; this tab does not list it yet.
|
||||
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
|
||||
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
|
||||
await expect(added).toBeOK();
|
||||
const fresh = (await added.json()).collection.id;
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
const hold = await holdListReads(page);
|
||||
// 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).toBeGreaterThanOrEqual(2);
|
||||
await expect(main(page)).not.toContainText('Collections could not be loaded.');
|
||||
await hold.release();
|
||||
await expect(heading(page)).toHaveText('Fresh');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
let gone = true;
|
||||
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
|
||||
? route.fulfill({ status: 404, contentType: 'application/json',
|
||||
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
// The shell's re-read is held, then fails: the page must not stay blank.
|
||||
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: '{"success":false,"error":"Collections could not be read."}' }));
|
||||
await row(page, 'Experiments').click();
|
||||
await expect.poll(() => hold.held.length).toBe(1);
|
||||
await hold.release();
|
||||
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
|
||||
await expect(retry).toBeVisible();
|
||||
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
|
||||
gone = false;
|
||||
await page.unroute(`**${API}`);
|
||||
await retry.click();
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
|
||||
// The list read again still lists it: the same Retry, never a blank page.
|
||||
gone = true;
|
||||
await page.evaluate(() => { window.location.hash = '#/images'; });
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(retry).toBeVisible();
|
||||
gone = false;
|
||||
await retry.click();
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
});
|
||||
|
||||
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
let missing = null;
|
||||
await page.route(`**${API}`, async route => {
|
||||
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
|
||||
return route.fulfill({ response, json: data });
|
||||
});
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
const rename = async name => {
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill(name);
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
};
|
||||
missing = 3;
|
||||
await rename('Landscapes A');
|
||||
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
|
||||
missing = 0;
|
||||
await rename('Landscapes B');
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
missing = 1;
|
||||
await rename('Landscapes C');
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
});
|
||||
|
||||
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
let release;
|
||||
const released = new Promise(resolve => { release = resolve; });
|
||||
await page.route(`**${API}/${id}`, async route => {
|
||||
if (route.request().method() !== 'PATCH') return route.fallback();
|
||||
await released;
|
||||
return route.fallback();
|
||||
});
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Later');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await expect(save).toBeVisible();
|
||||
release();
|
||||
await expect(heading(page)).toHaveText('Later');
|
||||
await expect(save).toBeVisible();
|
||||
});
|
||||
|
||||
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
|
||||
await page.route(`**${API}`, route => route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback());
|
||||
await page.evaluate(() => { window.location.hash = '#/images'; });
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
|
||||
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
|
||||
await expect(heading(page)).toBeFocused();
|
||||
|
||||
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback());
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
|
||||
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
});
|
||||
@@ -569,6 +569,10 @@ test('source and folder discovery failure stays visible and Import Rescan restor
|
||||
async function staleSourceRecovery(page, request, roots) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
|
||||
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
|
||||
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
|
||||
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
|
||||
await page.goto(workspace);
|
||||
const source = refineDialog(page).getByLabel('Source', { exact: true });
|
||||
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
|
||||
|
||||
@@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
@@ -73,7 +73,8 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D136: the COLLECTIONS label stays hidden while no collection is listed.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
|
||||
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
|
||||
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
|
||||
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
|
||||
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
|
||||
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
|
||||
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
|
||||
const cards = page => page.locator('.gallery-body article[data-image-key]');
|
||||
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
|
||||
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
page.staleErrors = [];
|
||||
page.on('pageerror', error => page.staleErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.staleErrors).toEqual([]);
|
||||
// The next spec file starts on the standard sources and key.
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
});
|
||||
|
||||
async function openTag(page, path = FIRST, root = 0) {
|
||||
const observed = answered(page, '/image-annotations/observe');
|
||||
await tagButton(page, path, root).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
return observed;
|
||||
}
|
||||
async function savedTags(request, path, root) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const item = listing.images.find(image => image.path === path && image.root_index === root);
|
||||
const target = { root_index: root, root_scope: item.root_scope, path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
|
||||
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect((await openTag(page)).status()).toBe(200);
|
||||
await manual(page).fill('kept_draft');
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
|
||||
const refused = answered(page, '/image-annotations/suggest');
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
const observedAgain = answered(page, '/image-annotations/observe');
|
||||
await generate(page).click();
|
||||
expect((await refused).status()).toBe(409);
|
||||
const mapped = await (await rescoped).json();
|
||||
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
|
||||
const observed = await observedAgain;
|
||||
expect(observed.status()).toBe(200);
|
||||
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveValue('kept_draft');
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
|
||||
// The page listed again under the new numbering and kept the file selected.
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST, 0)).toHaveCount(0);
|
||||
|
||||
const suggested = answered(page, '/image-annotations/suggest');
|
||||
await generate(page).click();
|
||||
expect((await suggested).status()).toBe(200);
|
||||
await expect(chips(page).first()).toBeVisible();
|
||||
const saved = answered(page, '/image-annotations/tags', 'PUT');
|
||||
await primary(page).click();
|
||||
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
|
||||
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
|
||||
});
|
||||
|
||||
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(page.locator('.bulkbar')).toBeVisible();
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
|
||||
new URL(item.url()).searchParams.get('root') === '0');
|
||||
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
|
||||
new URL(item.url()).searchParams.get('root') === '1');
|
||||
await infoButton(page, SECOND).click();
|
||||
expect((await refused).status()).toBe(409);
|
||||
expect((await reread).status()).toBe(200);
|
||||
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page)).not.toContainText('The source changed');
|
||||
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
|
||||
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
|
||||
await expect(page.locator('.bulkbar')).toBeHidden();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
expect((await openTag(page)).status()).toBe(409);
|
||||
expect((await rescoped).status()).toBe(409);
|
||||
|
||||
await expect(notice(page)).toContainText(SOURCE_CHANGED);
|
||||
await expect(status(page)).toHaveText('');
|
||||
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
|
||||
await expect(refresh).toBeVisible();
|
||||
for (const button of [generate(page), primary(page)]) {
|
||||
await expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(button).toHaveCSS('opacity', '0.55');
|
||||
}
|
||||
// The notice is its own first row of the sticky actions, above the buttons.
|
||||
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
|
||||
const noticeBox = actions.firstElementChild.getBoundingClientRect();
|
||||
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
|
||||
});
|
||||
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
|
||||
const preview = dialog(page).locator('.tag-image-preview');
|
||||
await expect(preview).toHaveText('Preview unavailable');
|
||||
await expect(preview).toHaveAttribute('role', 'img');
|
||||
const suggest = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
|
||||
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
|
||||
await generate(page).dispatchEvent('click');
|
||||
await primary(page).dispatchEvent('click');
|
||||
expect(suggest).toEqual([]);
|
||||
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
|
||||
|
||||
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
|
||||
await refresh.click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await relisted;
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
expect((await openTag(page)).status()).toBe(200);
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
await infoButton(page, SECOND).click();
|
||||
expect((await rescoped).status()).toBe(409);
|
||||
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
|
||||
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
|
||||
await relisted;
|
||||
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page)).not.toContainText('The source changed');
|
||||
});
|
||||
|
||||
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
await expect(cards(page).first()).toBeVisible();
|
||||
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
|
||||
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
|
||||
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
|
||||
const answer = await stale;
|
||||
expect(answer.status()).toBe(400);
|
||||
expect((await answer.json()).code).toBe('cursor-stale');
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
|
||||
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
|
||||
expect((await relisted).status()).toBe(200);
|
||||
await expect(continuation).not.toContainText('The image collection changed');
|
||||
});
|
||||
|
||||
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const mapped = await (await rescoped).json();
|
||||
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
// Opened again, it shows the file under its current source.
|
||||
await card(page, FIRST, 1).locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
|
||||
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
|
||||
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
|
||||
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
|
||||
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
|
||||
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
|
||||
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
|
||||
await expect(added).toBeOK();
|
||||
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
|
||||
const { id } = collections.find(item => item.name === name);
|
||||
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
expect((await (await rescoped).json()).image.root_index).toBe(1);
|
||||
expect((await relisted).status()).toBe(200);
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
// Tag image works on the member under its new source.
|
||||
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -472,7 +472,7 @@ async function openImages(page) {
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
|
||||
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
|
||||
expect(created.status()).toBe(201);
|
||||
const view = (await created.json()).view;
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,8 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
// D147: the reason a state blocks Save, on its own row in the sticky actions.
|
||||
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
@@ -728,7 +730,8 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
|
||||
let lost = true;
|
||||
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
|
||||
@@ -548,12 +548,16 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
|
||||
// and its nav empty while no collection exists.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
|
||||
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
|
||||
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
|
||||
await expect(page.locator('.new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -580,7 +584,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Owned, deterministic collections for the workspace collection workflows (#331).
|
||||
|
||||
Twenty-one PNGs under the output source: twelve listed members of
|
||||
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
|
||||
also holds one member whose file does not exist (MC3: hidden, counted as
|
||||
missing). One saved view reopens "Experiments" (D140). Members are added
|
||||
through ``ImageCollectionOperations`` with favorites' keys
|
||||
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from tests.browser.folder_trash import replace_folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
FOLDER = "collections"
|
||||
LANDSCAPES = "Landscapes"
|
||||
EXPERIMENTS = "Experiments"
|
||||
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
|
||||
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
|
||||
LOOSE_FILE = "loose.png"
|
||||
MISSING_FILE = "gone.png"
|
||||
SAVED_VIEW = "Experiments view"
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
_IMAGES_STATE = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_png(path: Path, shade: int, stamp: float) -> None:
|
||||
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
|
||||
image.save(path)
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
|
||||
"""Replace this server's disposable rows and files with the two collections."""
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
replace_folder(root / name)
|
||||
folder = root / "output" / FOLDER
|
||||
folder.mkdir()
|
||||
# Newest first in this order: Landscapes, then Experiments, then the loose file.
|
||||
now = time.time()
|
||||
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
|
||||
for index, name in enumerate(ordered):
|
||||
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
|
||||
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
members = {
|
||||
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
|
||||
EXPERIMENTS: EXPERIMENT_FILES,
|
||||
}
|
||||
ids = {}
|
||||
for name, files in members.items():
|
||||
added = db.add_to_collection(
|
||||
name=name,
|
||||
items=[
|
||||
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
|
||||
],
|
||||
)
|
||||
ids[name] = added["collection"]["id"]
|
||||
view = db.create_saved_view(
|
||||
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
|
||||
)
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"collections": {
|
||||
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
|
||||
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
|
||||
},
|
||||
"files": {
|
||||
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
|
||||
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
|
||||
"loose": f"{FOLDER}/{LOOSE_FILE}",
|
||||
},
|
||||
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
|
||||
}
|
||||
|
||||
|
||||
def register_collections_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> None:
|
||||
"""Register the fixed seed command; no path or SQL comes from a request."""
|
||||
|
||||
@routes.post("/__test__/collections/seed")
|
||||
async def seed(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if data != {} or request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(seed_collections, api, root)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
@@ -174,6 +174,8 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
@@ -193,6 +195,10 @@ _TABLES = (
|
||||
)
|
||||
|
||||
|
||||
# The concept's collections and the slice of its images each holds (#331).
|
||||
CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20)))
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
@@ -495,6 +501,19 @@ def seed_workspace_concept(
|
||||
)
|
||||
images.reverse()
|
||||
runs.reverse()
|
||||
# The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the
|
||||
# first 12 images in the concept's order and "Experiments" the next 8, so
|
||||
# the rows and counts compare as the concept shows them (#331 D136).
|
||||
collections = {}
|
||||
for name, members in CONCEPT_COLLECTIONS:
|
||||
added = db.add_to_collection(
|
||||
name=name,
|
||||
items=[
|
||||
(favorite_root, _favorite_relative_key(image["path"]))
|
||||
for image in images[members]
|
||||
],
|
||||
)
|
||||
collections[name] = added["collection"]["id"]
|
||||
seedless = []
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
@@ -531,4 +550,5 @@ def seed_workspace_concept(
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
"tags": tags,
|
||||
"collections": collections,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -78,6 +78,8 @@ LIMITED_SAMPLERS = 17
|
||||
# More than one queue page (40), so Load more has a second page.
|
||||
MAX_ITEMS = 45
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -56,6 +56,8 @@ def _valid_missing(value) -> bool:
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
@@ -192,9 +194,16 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
routes: web.RouteTableDef,
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
model,
|
||||
on_reset=lambda: None,
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
|
||||
|
||||
``on_reset`` runs first on every tagging reset (the source-change undo).
|
||||
"""
|
||||
# The fake download (``/__test__/tagging/download``): waits on ``event``,
|
||||
# then answers ``result``. Every reset sets the event and puts back the
|
||||
# model's raising default, so no test can leave a worker hanging.
|
||||
@@ -228,6 +237,7 @@ def register_tagging_commands(
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
on_reset()
|
||||
model.unavailable = set()
|
||||
model.missing_packages = {}
|
||||
model.suggestions = None
|
||||
|
||||
@@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -83,6 +83,8 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
// #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 });
|
||||
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 unknown = (await capture(execution, second)).contexts[0];
|
||||
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
|
||||
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 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).toBeNull();
|
||||
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 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(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
|
||||
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
|
||||
});
|
||||
|
||||
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
|
||||
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
|
||||
}
|
||||
});
|
||||
|
||||
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
|
||||
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', 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);
|
||||
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
|
||||
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: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw.provenance).toBe('unknown');
|
||||
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.evidence.positive.effective.source).toBe('host_cache_output');
|
||||
});
|
||||
|
||||
|
||||
@@ -287,6 +287,14 @@ 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 });
|
||||
@@ -309,6 +317,8 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,6 +339,7 @@ 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'); }
|
||||
});
|
||||
}
|
||||
@@ -352,6 +363,9 @@ 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'); }
|
||||
});
|
||||
|
||||
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
} });
|
||||
expect(rewired.cached).toContain(first.promptNode);
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(1);
|
||||
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
|
||||
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
|
||||
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
|
||||
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
|
||||
expect(cachedContext.positive_prompt_id).toBeNull();
|
||||
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((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"POST /prompt_manager/images/generate-thumbnails",
|
||||
"POST /prompt_manager/images/generate-thumbnails/progress",
|
||||
"POST /prompt_manager/images/link",
|
||||
"POST /prompt_manager/images/rescope",
|
||||
"POST /prompt_manager/images/reveal",
|
||||
"POST /prompt_manager/jobs/autotag",
|
||||
"POST /prompt_manager/jobs/duplicates",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the
|
||||
// mapping of the routes' error codes.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/collections.mjs');
|
||||
|
||||
const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' });
|
||||
|
||||
function apiError(status, code) {
|
||||
return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null });
|
||||
}
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options?.cache]);
|
||||
if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] });
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true,
|
||||
added: body.items.length, already: 0, not_found: 0 });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } });
|
||||
},
|
||||
del(url, options) {
|
||||
calls.push(['DELETE', url, options?.body]);
|
||||
return api.deleteResult ?? Promise.resolve(options?.body
|
||||
? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length }
|
||||
: { success: true, deleted: true });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const LISTED = { success: true, collections: [
|
||||
{ id: 4, name: 'Experiments', count: 8, missing: 0 },
|
||||
{ id: 2, name: 'Landscapes', count: 12, missing: 1 },
|
||||
] };
|
||||
|
||||
/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */
|
||||
async function readyStore(data = LISTED) {
|
||||
const { createCollections } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const timers = [];
|
||||
const controller = new AbortController();
|
||||
const store = createCollections(api, controller.signal, {
|
||||
setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; },
|
||||
clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; },
|
||||
});
|
||||
store.load();
|
||||
pending.shift().resolve(data);
|
||||
await store.settled();
|
||||
return { store, api, pending, timers, controller };
|
||||
}
|
||||
|
||||
test('the route hash, the name rule and the limits', async () => {
|
||||
const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS,
|
||||
COLLECTION_COUNT_DELAY_MS } = await source;
|
||||
assert.equal(collectionHash(12), '#/collection/12');
|
||||
assert.throws(() => collectionHash(0), TypeError);
|
||||
assert.throws(() => collectionHash('3'), TypeError);
|
||||
assert.equal(MAX_COLLECTION_NAME_LENGTH, 100);
|
||||
assert.equal(MAX_COLLECTION_ITEMS, 500);
|
||||
assert.equal(COLLECTION_COUNT_DELAY_MS, 300);
|
||||
// Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1).
|
||||
assert.equal(normalizeCollectionName(' LoRa testing grids '), 'LoRa testing grids');
|
||||
assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100));
|
||||
for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) {
|
||||
assert.equal(normalizeCollectionName(bad), null, String(bad));
|
||||
}
|
||||
// A tab or newline is whitespace, collapsed rather than refused.
|
||||
assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c');
|
||||
});
|
||||
|
||||
test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => {
|
||||
const { parseCollections, parseCollection } = await source;
|
||||
assert.deepEqual(parseCollections({ success: true, collections: [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 0, name: 'bad id' },
|
||||
{ id: 1, name: ' ', count: 1, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: -1, missing: 1.5 },
|
||||
] }), [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: null, missing: null },
|
||||
]);
|
||||
assert.equal(parseCollection(null), null);
|
||||
assert.throws(() => parseCollections({ success: true }), TypeError);
|
||||
});
|
||||
|
||||
test('every user-facing string of the design (§7.4, §8)', async () => {
|
||||
const c = await source;
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }),
|
||||
'Added 1 image to “X”. 1 was already in it.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }),
|
||||
'Added 2 images to “X”. 3 were already in it. 1 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }),
|
||||
'Added 1 image to “X”. 2 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }),
|
||||
'All selected images were already in “X”.');
|
||||
assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.');
|
||||
assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.');
|
||||
assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.');
|
||||
assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.');
|
||||
assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.');
|
||||
assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.');
|
||||
assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.');
|
||||
assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle],
|
||||
['Edit collection', 'Collection name', 'Delete collection']);
|
||||
assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.');
|
||||
assert.equal(c.RENAMED_TOAST, 'Collection renamed.');
|
||||
assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.');
|
||||
assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.');
|
||||
// The stored count: listed members plus missing ones.
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }),
|
||||
'Delete “X”? Its 1 image stays where it is; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }),
|
||||
'Delete “X”? Its 4 images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }),
|
||||
'Delete “X”? Its images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 }));
|
||||
assert.equal(c.DELETED_TOAST, 'Collection deleted.');
|
||||
assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.');
|
||||
assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.');
|
||||
assert.equal(c.OPEN_IMAGES_LINK, 'Open Images');
|
||||
assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.');
|
||||
assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.');
|
||||
assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.');
|
||||
assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.');
|
||||
for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), '');
|
||||
assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.');
|
||||
assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION],
|
||||
['Add selected images to collection', 'Collection name', 'Add']);
|
||||
assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.');
|
||||
assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.');
|
||||
assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…');
|
||||
assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.');
|
||||
assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.');
|
||||
});
|
||||
|
||||
test('a write failure maps the routes\' error codes to their strings', async () => {
|
||||
const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST,
|
||||
FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source;
|
||||
assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST);
|
||||
assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST);
|
||||
assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST);
|
||||
for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) {
|
||||
assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST);
|
||||
}
|
||||
assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true);
|
||||
assert.equal(isCollectionGone(apiError(404)), false);
|
||||
// The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open.
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST });
|
||||
});
|
||||
|
||||
test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => {
|
||||
const { createCollections, COLLECTIONS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createCollections(api);
|
||||
assert.equal(store.status, 'loading');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve(LISTED);
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']);
|
||||
assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 });
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.collections.map(item => item.id), [4, 2]);
|
||||
|
||||
store.load();
|
||||
store.load();
|
||||
pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] });
|
||||
pending[2].resolve(LISTED);
|
||||
await store.settled();
|
||||
assert.deepEqual(store.collections.map(item => item.id), [8]);
|
||||
assert.deepEqual(seen.at(-1), ['ready', [8]]);
|
||||
// A malformed listing is a failed read.
|
||||
store.load();
|
||||
pending[4].resolve({ success: true });
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
});
|
||||
|
||||
test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => {
|
||||
const { store, api, pending, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL } = await source;
|
||||
const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]);
|
||||
assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]);
|
||||
assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 });
|
||||
// The new collection is listed at once (its count arrives with the re-read), in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]);
|
||||
api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0,
|
||||
already: 1, not_found: 1 });
|
||||
const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]);
|
||||
assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] });
|
||||
assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 });
|
||||
// One trailing re-read for the run of writes.
|
||||
assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]);
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
timers.at(-1).run();
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve(LISTED);
|
||||
await store.settled();
|
||||
});
|
||||
|
||||
test('a write is refused before sending when its target or files are not valid', async () => {
|
||||
const { store, api } = await readyStore();
|
||||
const sent = api.calls.length;
|
||||
const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` }));
|
||||
for (const [target, items] of [
|
||||
[{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]],
|
||||
[{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'],
|
||||
]) {
|
||||
await assert.rejects(store.add(target, items), TypeError);
|
||||
}
|
||||
await assert.rejects(store.remove(0, [FILE]), TypeError);
|
||||
await assert.rejects(store.remove(2, []), TypeError);
|
||||
await assert.rejects(store.rename(2, ' '), TypeError);
|
||||
await assert.rejects(store.delete(-1), TypeError);
|
||||
await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError);
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
|
||||
test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => {
|
||||
const { store, api, pending } = await readyStore();
|
||||
const { isCollectionGone } = await source;
|
||||
api.postResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error));
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] });
|
||||
await store.settled();
|
||||
assert.equal(store.find(2), null);
|
||||
// Another failure re-reads nothing.
|
||||
api.deleteResult = Promise.reject(apiError(500));
|
||||
await assert.rejects(store.remove(4, [FILE]), error => error.status === 500);
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
});
|
||||
|
||||
test('remove sends the collection and files; membership reads one file\'s collections', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source;
|
||||
assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 });
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.deepEqual(await store.membership(FILE), [2]);
|
||||
const [method, url] = api.calls.at(-1);
|
||||
assert.equal(method, 'GET');
|
||||
const query = new URL(url, 'http://local');
|
||||
assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL);
|
||||
assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path });
|
||||
api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] });
|
||||
await assert.rejects(store.membership(FILE), TypeError);
|
||||
});
|
||||
|
||||
test('rename and delete change the listed row at once and re-read the counts', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTIONS_URL } = await source;
|
||||
const renamed = await store.rename(2, ' A grids ');
|
||||
assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]);
|
||||
assert.deepEqual(renamed, { id: 2, name: 'A grids' });
|
||||
// The row keeps its counts and moves to its new place in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]);
|
||||
assert.equal(await store.delete(4), 4);
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]);
|
||||
assert.deepEqual(store.collections.map(item => item.id), [2]);
|
||||
// Already gone (another tab): the outcome the user asked for.
|
||||
api.deleteResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
assert.equal(await store.delete(2), 2);
|
||||
assert.deepEqual(store.collections, []);
|
||||
api.patchResult = Promise.reject(apiError(409, 'name-taken'));
|
||||
await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken');
|
||||
assert.equal(timers.filter(timer => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => {
|
||||
const { store, timers, controller, api, pending } = await readyStore();
|
||||
await store.rename(2, 'Kept');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push(next));
|
||||
store.load();
|
||||
controller.abort();
|
||||
assert.equal(timers.at(-1).cleared, true);
|
||||
pending.shift().resolve(LISTED);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(seen.filter(next => next.status === 'ready').length, 0);
|
||||
const sent = api.calls.length;
|
||||
await store.load();
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
@@ -253,15 +253,16 @@ test('an unreadable or malformed models read falls back to the documented defaul
|
||||
|
||||
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
|
||||
let answer = () => fail(500, 'observe-failed');
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canRetry, true);
|
||||
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
|
||||
answer = () => response(200, observed({ tag_revision: -1 }));
|
||||
await c.retry();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
@@ -274,9 +275,71 @@ test('observe failures and malformed answers end in observe-error and retry obse
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.blocked, false);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 4);
|
||||
});
|
||||
|
||||
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
|
||||
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'source-changed');
|
||||
assert.equal(c.state.statusText, SOURCE);
|
||||
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
|
||||
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
|
||||
await c.retarget(fresh);
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
|
||||
assert.deepEqual({ ...c.target }, fresh);
|
||||
// Generate and Save now carry the new target.
|
||||
c.setManual('kept draft');
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
|
||||
});
|
||||
|
||||
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
|
||||
for (const route of [SUGGEST, TAGS]) {
|
||||
const method = route === TAGS ? 'PUT' : 'POST';
|
||||
let stale = true;
|
||||
const { c, h } = await opened({
|
||||
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
|
||||
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
|
||||
});
|
||||
c.setManual('kept draft');
|
||||
if (route === TAGS) await c.save(); else await c.generate();
|
||||
assert.equal(c.state.phase, 'source-changed');
|
||||
assert.equal(c.state.statusText, 'server says source');
|
||||
stale = false;
|
||||
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
|
||||
}
|
||||
});
|
||||
|
||||
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
|
||||
const gate = deferred();
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
|
||||
const saving = c.save();
|
||||
await flush();
|
||||
assert.equal(c.state.phase, 'saving');
|
||||
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
|
||||
gate.resolve(response(200, receipt(['Minimal'])));
|
||||
await saving;
|
||||
c.cancel();
|
||||
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('model change clears suggestions count and status but keeps manual', async () => {
|
||||
const { c } = await opened();
|
||||
c.setManual('Minimal, dune');
|
||||
@@ -547,7 +610,7 @@ test('write errors keep the draft and allow another save', async () => {
|
||||
});
|
||||
|
||||
test('storage-changed and file-changed disable save until reopen', async () => {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
const message = `server says ${code}`;
|
||||
let answer = () => fail(409, code, message);
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
|
||||
|
||||
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
|
||||
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
|
||||
|
||||
function fakeApi(answer) {
|
||||
const calls = [];
|
||||
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
|
||||
const { rescopeImage, RESCOPE_URL } = await load();
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
const signal = new AbortController().signal;
|
||||
const fresh = await rescopeImage(api, OLD, { signal });
|
||||
assert.deepEqual(fresh, FRESH);
|
||||
assert.ok(Object.isFrozen(fresh));
|
||||
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
|
||||
});
|
||||
|
||||
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
|
||||
const { rescopeImage } = await load();
|
||||
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
|
||||
assert.equal(await rescopeImage(refused, OLD), null);
|
||||
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
|
||||
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
|
||||
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
|
||||
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
|
||||
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
|
||||
}
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
|
||||
assert.equal(await rescopeImage(api, image), null);
|
||||
}
|
||||
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
|
||||
});
|
||||
|
||||
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
let answer = { success: true, image: FRESH };
|
||||
const api = fakeApi(() => answer);
|
||||
const applied = [];
|
||||
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
|
||||
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
|
||||
assert.deepEqual([first, second], [FRESH, FRESH]);
|
||||
assert.equal(api.calls.length, 1);
|
||||
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
|
||||
answer = { success: false };
|
||||
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
|
||||
recovery.reset();
|
||||
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
|
||||
assert.equal(api.calls.length, 2);
|
||||
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
|
||||
assert.equal(api.calls.length, 2);
|
||||
});
|
||||
|
||||
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
const page = new AbortController();
|
||||
let applied = 0;
|
||||
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
|
||||
page.abort();
|
||||
assert.equal(await recovery.recover(OLD), null);
|
||||
assert.equal(applied, 0);
|
||||
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
|
||||
assert.equal(await failing.recover(OLD), null);
|
||||
assert.throws(() => createSourceRecovery({ api }), TypeError);
|
||||
// An api without post (an embedded gallery's page adapter) finds nothing.
|
||||
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
|
||||
});
|
||||
|
||||
test('the memo forgets its oldest pairs past its limit', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
|
||||
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
|
||||
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
|
||||
assert.equal(api.calls.length, 65);
|
||||
await recovery.recover({ ...OLD, path: 'f64.png' });
|
||||
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
|
||||
await recovery.recover({ ...OLD, path: 'f0.png' });
|
||||
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
|
||||
});
|
||||
|
||||
function fakePage({ selected = true, root = 'all', current = true } = {}) {
|
||||
const log = [];
|
||||
const page = {
|
||||
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
|
||||
current: started => { log.push(['current', started]); return current; },
|
||||
selected: image => selected && image.path === OLD.path,
|
||||
inspect: image => log.push(['inspect', image.root_index]),
|
||||
dropSelection: () => log.push(['drop']),
|
||||
stream: {
|
||||
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
|
||||
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
|
||||
},
|
||||
updateScope: () => log.push(['scope']),
|
||||
closeViewer: () => log.push(['close-viewer']),
|
||||
clearChecks: () => log.push(['clear-checks']),
|
||||
listsNow: () => true,
|
||||
rediscover: () => log.push(['rediscover']),
|
||||
};
|
||||
return { page, log };
|
||||
}
|
||||
|
||||
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage({ root: '0' });
|
||||
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
|
||||
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
|
||||
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
|
||||
});
|
||||
|
||||
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage({ root: '2', selected: false });
|
||||
await recoveredSource(page)(OLD, FRESH, 0);
|
||||
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
|
||||
assert.ok(log.some(entry => entry[0] === 'drop'));
|
||||
assert.ok(!log.some(entry => entry[0] === 'inspect'));
|
||||
const all = fakePage({ root: 'all' });
|
||||
await recoveredSource(all.page)(OLD, FRESH, 0);
|
||||
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
|
||||
});
|
||||
|
||||
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const same = { ...FRESH, root_index: 0 };
|
||||
const { page, log } = fakePage();
|
||||
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
|
||||
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
|
||||
const unselected = fakePage({ selected: false });
|
||||
await recoveredSource(unselected.page)(OLD, same, 1);
|
||||
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
|
||||
const moved = fakePage({ current: false });
|
||||
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
|
||||
assert.deepEqual(moved.log, [['current', 1]]);
|
||||
await flush();
|
||||
});
|
||||
|
||||
test('a scope that still names its root is not stale: nothing is applied', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage();
|
||||
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
|
||||
assert.deepEqual(log, []);
|
||||
});
|
||||
|
||||
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
let down = true;
|
||||
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
|
||||
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
|
||||
assert.equal(await recovery.recover(OLD), null);
|
||||
down = false;
|
||||
assert.deepEqual(await recovery.recover(OLD), FRESH);
|
||||
assert.equal(api.calls, 2);
|
||||
});
|
||||
|
||||
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
|
||||
const { page, log } = fakePage();
|
||||
page.stream.refresh = async () => { log.push(['refresh']); };
|
||||
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
|
||||
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
|
||||
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
|
||||
});
|
||||
@@ -141,6 +141,22 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('a collection page asks the listing for that collection only (#331 D137)', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({collection:'12',q:'fog'});
|
||||
assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false);
|
||||
await stream.reset({collection:7});
|
||||
assert.equal(query.get('collection'),'7');
|
||||
for(const collection of ['',null,undefined]) {
|
||||
await stream.reset({collection});
|
||||
assert.equal(query.has('collection'),false);
|
||||
}
|
||||
for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/);
|
||||
// Favorites and a collection are never both a page's filter.
|
||||
await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const calls=[];
|
||||
@@ -517,5 +533,7 @@ test('listingFilter is the listing query as an object, without paging (#392)', a
|
||||
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
|
||||
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
|
||||
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
|
||||
// #331: "Select all N matching" on a collection page acts on that collection's listing.
|
||||
assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'});
|
||||
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
@@ -12,8 +12,17 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
|
||||
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
|
||||
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''.
|
||||
for (const collection of ['1', '12', '999999999']) {
|
||||
assert.equal(normalizeViewState('images', { collection }).collection, collection);
|
||||
}
|
||||
for (const collection of ['0', 'x', 12, '012', '1234567890', null]) {
|
||||
assert.equal(normalizeViewState('images', { collection }).collection, '');
|
||||
}
|
||||
// Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does.
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' });
|
||||
// D51: favorites is a boolean; anything else is the whole listing.
|
||||
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
|
||||
// Each rejected value falls back on its own; the rest of the view still applies.
|
||||
@@ -63,11 +72,17 @@ 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: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
|
||||
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: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
|
||||
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 });
|
||||
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 ''.
|
||||
const inCollection = { ...filters, favorites: false, collection: '7' };
|
||||
assert.equal(imagesViewState(inCollection).collection, '7');
|
||||
assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection);
|
||||
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
|
||||
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
|
||||
|
||||
@@ -311,3 +311,29 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou
|
||||
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
|
||||
assert.deepEqual([...counts], [['review', 4]]);
|
||||
});
|
||||
|
||||
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
|
||||
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
|
||||
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
|
||||
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
|
||||
const route=resolveRoute('#/collection/7');
|
||||
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
|
||||
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
|
||||
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
|
||||
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
|
||||
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/collection/3'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId]));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
+15
-9
@@ -2205,24 +2205,30 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
"""A failed link rolls the new prompt back: found, nothing left behind."""
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
|
||||
self.api.db.link_image_to_prompt = _raise
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.api.db._insert_image_link = _raise
|
||||
with self.assertLogs("prompt_manager.database.scanned_prompts", "ERROR"):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_ingest_save_failure_counts_as_found(self):
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
|
||||
self.api.db.save_prompt = lambda *a, **k: None
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
with patch(
|
||||
"py.api.admin.store_scanned_prompt", return_value=("found", None)
|
||||
) as store:
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(store.call_args.args[3], "a bird")
|
||||
|
||||
def test_batch_counts_and_per_file_errors(self):
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
|
||||
self.api.db.get_prompt_by_hash = _raise
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
with patch("py.api.admin.store_scanned_prompt", side_effect=_raise):
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.assertEqual(
|
||||
counts,
|
||||
{
|
||||
|
||||
@@ -2556,9 +2556,10 @@ class TestRootScopeServing(ImageAPITestCase):
|
||||
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
|
||||
descriptor = (await listing.json())["images"][0]
|
||||
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
|
||||
with patch.object(
|
||||
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
|
||||
):
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
||||
|
||||
@@ -1418,9 +1418,10 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self):
|
||||
png(self.output_dir / "image.png", prompt=graph())
|
||||
descriptor = await self.scoped_descriptor()
|
||||
import py.api.images as images_module
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"):
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""POST /prompt_manager/images/rescope: a stale target's current identity (#430).
|
||||
|
||||
A page that holds ``{root_index, root_scope, path}`` after the sources were
|
||||
renumbered asks where that file is now. The server maps the target only when
|
||||
its scope was issued, at its old index, for one of the current roots, so the
|
||||
answer names nothing the page could not already address; a removed or
|
||||
replaced root, a foreign scope or a changed key stay refused.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError: # imported as tests.test_api_rescope
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
from utils.workspace_keys import WorkspaceKeys # noqa: E402
|
||||
|
||||
RESCOPE = "/prompt_manager/images/rescope"
|
||||
|
||||
|
||||
class TestRescopeRoute(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.other_dir = self.comfy_dir / "other"
|
||||
self.other_dir.mkdir()
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
make_png(self.other_dir / "sub" / "a.png", color=(0, 0, 255))
|
||||
return app
|
||||
|
||||
def target(self, path="sub/a.png", index=0, root=None):
|
||||
root = root or self.output_dir
|
||||
return {
|
||||
"root_index": index,
|
||||
"root_scope": self.api._gallery_root_scope(root, index),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
async def post(self, body):
|
||||
response = await self.client.post(RESCOPE, json=body)
|
||||
data = await response.json()
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
||||
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
||||
return response.status, data
|
||||
|
||||
async def listed(self):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
return {
|
||||
(item["root_index"], item["path"]): item
|
||||
for item in (await response.json())["images"]
|
||||
}
|
||||
|
||||
async def test_an_unchanged_root_answers_the_listing_descriptor(self):
|
||||
status, data = await self.post({"target": self.target()})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["image"], (await self.listed())[(0, "sub/a.png")])
|
||||
|
||||
async def test_a_renumbered_root_maps_to_its_new_index(self):
|
||||
held = self.target()
|
||||
self.api._get_all_output_dirs = lambda: [self.other_dir, self.output_dir]
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual(status, 200)
|
||||
image = data["image"]
|
||||
self.assertEqual((image["root_index"], image["path"]), (1, "sub/a.png"))
|
||||
self.assertEqual(
|
||||
image["root_scope"], self.api._gallery_root_scope(self.output_dir, 1)
|
||||
)
|
||||
self.assertEqual(image, (await self.listed())[(1, "sub/a.png")])
|
||||
|
||||
async def test_a_removed_replaced_or_foreign_root_is_source_changed(self):
|
||||
held = self.target()
|
||||
cases = {
|
||||
"removed": lambda: [],
|
||||
"replaced": lambda: [self.other_dir],
|
||||
}
|
||||
for name, roots in cases.items():
|
||||
with self.subTest(name):
|
||||
self.api._get_all_output_dirs = roots
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
for scope in ("0" * 64, self.api._gallery_root_scope(self.output_dir, 1)):
|
||||
with self.subTest(scope=scope[:8]):
|
||||
status, data = await self.post(
|
||||
{"target": {**held, "root_scope": scope}}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
|
||||
async def test_a_changed_key_cannot_be_mapped(self):
|
||||
held = self.target()
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
|
||||
async def test_the_first_of_two_spellings_of_one_root_wins(self):
|
||||
held = self.target()
|
||||
self.api._get_all_output_dirs = lambda: [
|
||||
self.other_dir,
|
||||
self.output_dir,
|
||||
self.output_dir / "sub" / "..",
|
||||
]
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["image"]["root_index"]), (200, 1))
|
||||
|
||||
async def test_a_missing_file_is_not_found(self):
|
||||
status, data = await self.post({"target": self.target("sub/gone.png")})
|
||||
self.assertEqual((status, data["code"]), (404, "file-missing"))
|
||||
(self.output_dir / "dir.png").mkdir()
|
||||
status, data = await self.post({"target": self.target("dir.png")})
|
||||
self.assertEqual((status, data["code"]), (404, "file-missing"))
|
||||
|
||||
async def test_a_path_leaving_the_root_is_refused(self):
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
self._make_symlink(outside, self.output_dir / "escape.png")
|
||||
status, _data = await self.post({"target": self.target("escape.png")})
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
async def test_previews_are_not_rescoped(self):
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
status, _data = await self.post(
|
||||
{"target": self.target("thumbnails/a_thumb.png")}
|
||||
)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
async def test_malformed_bodies_are_refused_before_any_file_access(self):
|
||||
good = self.target()
|
||||
bodies = [
|
||||
b"{",
|
||||
[],
|
||||
{},
|
||||
{"target": good, "extra": 1},
|
||||
{"target": {**good, "root_index": -1}},
|
||||
{"target": {**good, "root_index": True}},
|
||||
{"target": {**good, "root_index": 4096}},
|
||||
# Past Python's int-to-text digit limit: never a 500.
|
||||
b'{"target": {"root_index": 1' + b"0" * 5000 + b"}}",
|
||||
{"target": {**good, "root_scope": "A" * 64}},
|
||||
{"target": {**good, "root_scope": "abc"}},
|
||||
{"target": {**good, "path": ""}},
|
||||
{"target": {**good, "path": "../a.png"}},
|
||||
{"target": {**good, "path": "/abs.png"}},
|
||||
{"target": {**good, "path": "C:/a.png"}},
|
||||
{"target": {**good, "path": "sub\\a.png"}},
|
||||
{"target": {**good, "path": "sub//a.png"}},
|
||||
{"target": {**good, "path": "notes.txt"}},
|
||||
{"target": {**good, "path": "x" * 2000 + ".png"}},
|
||||
]
|
||||
with patch("pathlib.Path.is_file", side_effect=AssertionError("file access")):
|
||||
for body in bodies:
|
||||
with self.subTest(body=str(body)[:60]):
|
||||
if isinstance(body, bytes):
|
||||
response = await self.client.post(
|
||||
RESCOPE,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
else:
|
||||
response = await self.client.post(RESCOPE, json=body)
|
||||
self.assertIn(response.status, (400, 403))
|
||||
self.assertFalse((await response.json())["success"])
|
||||
@@ -1882,9 +1882,13 @@ class TestWorkspaceCursorEncoding(unittest.TestCase):
|
||||
for order in ("newest", "oldest", "name", "name-desc"):
|
||||
with self.subTest(sort=order):
|
||||
key = _workspace_key(entry, order)
|
||||
token = _workspace_cursor(key, "scope")
|
||||
token = _workspace_cursor(key, "scope", b"k" * 32)
|
||||
self.assertLessEqual(len(token), 4096)
|
||||
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
|
||||
self.assertEqual(
|
||||
_workspace_cursor_key(token, "scope", order, b"k" * 32), key
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
_workspace_cursor_key(token, "scope", order, b"o" * 32)
|
||||
|
||||
|
||||
# The pre-#379 candidate read, kept as the differential reference for the
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""A ComfyUI restart keeps an open workspace page's scopes and cursors (#430).
|
||||
|
||||
A restart is a new ``PromptManagerAPI`` on the same database in a new
|
||||
process. Here a second API instance on its own aiohttp server stands in for
|
||||
it (``tests/test_workspace_keys.py`` also derives the scope in a real child
|
||||
process); it must accept every ``root_scope`` and listing cursor the first one
|
||||
handed out, while a changed root list or key still refuses them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError: # imported as tests.test_api_workspace_restart
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from utils.workspace_keys import KEY_FILE_NAME, WorkspaceKeys # noqa: E402
|
||||
|
||||
LISTING = "/prompt_manager/images/output"
|
||||
|
||||
|
||||
def _payload(token):
|
||||
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
||||
return json.loads(raw[32:])
|
||||
|
||||
|
||||
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
for index in range(3):
|
||||
make_png(self.output_dir / f"image_{index}.png")
|
||||
return app
|
||||
|
||||
async def first_page(self, client=None, **params):
|
||||
response = await (client or self.client).get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", **params}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
return await response.json()
|
||||
|
||||
async def restarted(self):
|
||||
"""A second API on the same database and roots, on its own server."""
|
||||
api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
|
||||
async def close():
|
||||
await client.close()
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
|
||||
self.addAsyncCleanup(close)
|
||||
return api, client
|
||||
|
||||
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
|
||||
page = await self.first_page()
|
||||
image, cursor = page["images"][0], page["next_cursor"]
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(os.path.dirname(self._temp_db.name), KEY_FILE_NAME)
|
||||
)
|
||||
)
|
||||
_api, client = await self.restarted()
|
||||
|
||||
more = await client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(more.status, 200, await more.text())
|
||||
self.assertNotEqual((await more.json())["images"][0]["path"], image["path"])
|
||||
|
||||
inspected = await client.get(
|
||||
f"/prompt_manager/images/inspect/{quote(image['path'])}",
|
||||
params={"root": "0", "root_scope": image["root_scope"]},
|
||||
)
|
||||
self.assertEqual(inspected.status, 200, await inspected.text())
|
||||
served = await client.get(
|
||||
f"/prompt_manager/images/serve/{quote(image['path'])}",
|
||||
params={"root": "0", "root_scope": image["root_scope"]},
|
||||
)
|
||||
self.assertEqual(served.status, 200)
|
||||
relisted = await self.first_page(client)
|
||||
self.assertEqual(relisted["images"][0]["root_scope"], image["root_scope"])
|
||||
|
||||
async def test_a_key_change_refuses_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
response = await self.client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_renumbered_roots_refuse_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
other = self.comfy_dir / "other"
|
||||
other.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [other, self.output_dir]
|
||||
response = await self.client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_another_listing_refuses_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
response = await self.client.get(
|
||||
LISTING,
|
||||
params={"mode": "workspace", "limit": "1", "cursor": cursor, "q": "x"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_a_malformed_cursor_is_a_plain_bad_request(self):
|
||||
# A 32-byte signature over a payload is well formed: a wrong one is stale.
|
||||
for cursor in ("!!", "a" * 5000, base64.urlsafe_b64encode(b"x" * 20).decode()):
|
||||
with self.subTest(cursor=cursor[:10]):
|
||||
response = await self.client.get(
|
||||
LISTING,
|
||||
params={"mode": "workspace", "limit": "1", "cursor": cursor},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertNotEqual((await response.json()).get("code"), "cursor-stale")
|
||||
|
||||
async def test_the_cursor_carries_no_digest_of_the_root_paths(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
payload = _payload(cursor)
|
||||
# v2: the listing scope (it hashes every absolute root) is in the MAC only.
|
||||
self.assertEqual(set(payload), {"v", "key"})
|
||||
self.assertEqual(payload["v"], 2)
|
||||
|
||||
async def test_another_database_folder_gets_other_scopes(self):
|
||||
page = await self.first_page()
|
||||
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
|
||||
other_db.parent.mkdir()
|
||||
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
|
||||
self.addCleanup(api.db.close_all)
|
||||
self.addCleanup(api.close_auto_thumbnails)
|
||||
self.assertNotEqual(
|
||||
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
|
||||
)
|
||||
|
||||
|
||||
# A fresh interpreter: the API on the given database, as after a restart.
|
||||
_CHILD = """
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from tests.host_stubs import install_folder_paths_stub
|
||||
install_folder_paths_stub()
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from py.api.images import _workspace_cursor_key
|
||||
api = PromptManagerAPI(db=PromptDatabase(sys.argv[2]))
|
||||
scope = api._gallery_root_scope(Path(sys.argv[3]), 0)
|
||||
secret = api._workspace_keys().cursor
|
||||
key = _workspace_cursor_key(sys.argv[4], "s" * 64, "newest", secret)
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
print(json.dumps({"scope": scope, "key": list(key)}))
|
||||
"""
|
||||
|
||||
|
||||
class TestRestartInANewProcess(ImageAPITestCase):
|
||||
"""The real thing: two other interpreters on the same database."""
|
||||
|
||||
def child(self, cursor):
|
||||
import subprocess
|
||||
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
_CHILD,
|
||||
repo,
|
||||
self._temp_db.name,
|
||||
str(self.output_dir),
|
||||
cursor,
|
||||
],
|
||||
cwd=self.comfy_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
async def test_new_processes_derive_the_same_scope_and_accept_the_cursor(self):
|
||||
from py.api.images import _workspace_cursor
|
||||
|
||||
make_png(self.output_dir / "image.png")
|
||||
listing = await self.client.get(LISTING, params={"mode": "workspace"})
|
||||
scope = (await listing.json())["images"][0]["root_scope"]
|
||||
key = (-12.5, "image.png", "0" * 64, 0)
|
||||
cursor = _workspace_cursor(key, "s" * 64, self.api._workspace_keys().cursor)
|
||||
for _restart in range(2):
|
||||
self.assertEqual(self.child(cursor), {"scope": scope, "key": list(key)})
|
||||
@@ -22,6 +22,9 @@ from database.operations import PromptDatabase
|
||||
from tests.browser import fast_reset
|
||||
from tests.browser.fast_reset import ScratchSeeder, table_digests
|
||||
|
||||
# The owner lock of /dev/shm scratch folders needs POSIX flock (#419).
|
||||
HAS_FLOCK = unittest.skipUnless(os.name == "posix", "needs POSIX flock")
|
||||
|
||||
|
||||
def digests(db: PromptDatabase) -> dict:
|
||||
return table_digests(db.model.get_connection())
|
||||
@@ -275,6 +278,7 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.assertEqual(digests(self.live), expected)
|
||||
self.assertFalse((self.root / "unused").exists())
|
||||
|
||||
@HAS_FLOCK
|
||||
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
@@ -288,6 +292,16 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
seeder.close()
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
|
||||
@unittest.skipIf(os.name == "posix", "without flock the memory root is unused")
|
||||
def test_without_flock_the_scratch_stays_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
seeder.run(reseed)
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
@@ -303,26 +317,32 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "process probe is POSIX only")
|
||||
def test_scratch_folders_of_dead_servers_are_removed(self):
|
||||
@HAS_FLOCK
|
||||
def test_the_scratch_folder_is_held_by_a_lock_until_close(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
orphan = memory / f"pm-reset-{dead.pid}-abc"
|
||||
alive = memory / f"pm-reset-{os.getpid()}-def"
|
||||
other = memory / "pm-reset-notapid"
|
||||
for folder in (orphan, alive, other):
|
||||
folder.mkdir()
|
||||
(folder / "seed.db").write_bytes(b"x")
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
seeder.run(reseed)
|
||||
self.assertFalse(orphan.exists())
|
||||
self.assertTrue(alive.exists())
|
||||
self.assertTrue(other.exists())
|
||||
own = [p for p in memory.glob(f"pm-reset-{os.getpid()}-*") if p != alive]
|
||||
self.assertEqual(len(own), 1)
|
||||
(folder,) = memory.iterdir()
|
||||
self.assertTrue(folder.name.startswith(f"pm-reset-held-{os.getpid()}-"))
|
||||
self.assertFalse(lock_is_free(folder / "owner.lock"))
|
||||
seeder.close()
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
|
||||
@HAS_FLOCK
|
||||
def test_a_lock_that_cannot_be_taken_puts_the_scratch_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
refuse = mock.patch.object(
|
||||
fast_reset.fcntl, "flock", side_effect=OSError("no locks here")
|
||||
)
|
||||
with refuse:
|
||||
seeder.run(reseed)
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
def test_close_releases_the_scratch_files(self):
|
||||
self.seeder.run(reseed)
|
||||
@@ -331,5 +351,95 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.seeder.close() # idempotent
|
||||
|
||||
|
||||
# A process that takes the owner lock of a folder, says so, and waits.
|
||||
HOLDER = """
|
||||
import fcntl, os, sys, time
|
||||
fd = os.open(os.path.join(sys.argv[1], "owner.lock"), os.O_RDWR | os.O_CREAT)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
print("held", flush=True)
|
||||
time.sleep(600)
|
||||
"""
|
||||
|
||||
|
||||
def lock_is_free(path: Path) -> bool:
|
||||
fd = os.open(path, os.O_RDWR)
|
||||
try:
|
||||
fast_reset.fcntl.flock(fd, fast_reset.fcntl.LOCK_EX | fast_reset.fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return False
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
|
||||
|
||||
@HAS_FLOCK
|
||||
class TestOrphanedScratch(unittest.TestCase):
|
||||
"""Only folders whose owner lock is free are orphans (#419)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.memory = Path(self.tmp.name)
|
||||
|
||||
def folder(self, name: str) -> Path:
|
||||
folder = self.memory / name
|
||||
folder.mkdir()
|
||||
(folder / "seed.db").write_bytes(b"x")
|
||||
return folder
|
||||
|
||||
def hold(self, folder: Path) -> subprocess.Popen:
|
||||
holder = subprocess.Popen(
|
||||
[sys.executable, "-c", HOLDER, str(folder)],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.addCleanup(holder.wait)
|
||||
self.addCleanup(holder.stdout.close)
|
||||
self.addCleanup(holder.kill)
|
||||
self.assertEqual(holder.stdout.readline().strip(), "held")
|
||||
return holder
|
||||
|
||||
def test_a_folder_whose_owner_was_killed_is_removed(self):
|
||||
folder = self.folder("pm-reset-held-123-abc")
|
||||
holder = self.hold(folder)
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
def test_a_held_folder_is_kept_whatever_pid_its_name_carries(self):
|
||||
# Under --ipc=host the owner's pid is from another PID namespace: it
|
||||
# does not exist here, but its lock does.
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
folder = self.folder(f"pm-reset-held-{dead.pid}-abc")
|
||||
self.hold(folder)
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
self.assertTrue(folder.exists())
|
||||
self.assertTrue((folder / "seed.db").exists())
|
||||
|
||||
def test_folders_without_an_owner_lock_are_kept(self):
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
legacy = self.folder(f"pm-reset-{dead.pid}-abc")
|
||||
staging = self.folder(f"pm-reset-new-{dead.pid}-def")
|
||||
unlocked = self.folder(f"pm-reset-held-{dead.pid}-ghi")
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
for folder in (legacy, staging, unlocked):
|
||||
self.assertTrue(folder.exists(), folder.name)
|
||||
|
||||
def test_a_folder_held_by_another_seeder_in_this_process_is_kept(self):
|
||||
api = types.SimpleNamespace(db=PromptDatabase(str(self.memory / "live.db")))
|
||||
self.addCleanup(api.db.close_all)
|
||||
root = self.memory / "shm"
|
||||
root.mkdir()
|
||||
other = ScratchSeeder(api, self.memory / "fallback", memory_root=root)
|
||||
self.addCleanup(other.close)
|
||||
other.run(reseed)
|
||||
(folder,) = root.iterdir()
|
||||
fast_reset.remove_orphaned_scratch(root)
|
||||
self.assertTrue(folder.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+193
-1
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
|
||||
return counts
|
||||
|
||||
|
||||
class TestReceipts(unittest.TestCase):
|
||||
class ReceiptCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.context = ("j1", None)
|
||||
self.runs = CachedRuns(
|
||||
@@ -686,6 +686,8 @@ class TestReceipts(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"):
|
||||
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
|
||||
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())
|
||||
|
||||
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
|
||||
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
|
||||
|
||||
def begin(
|
||||
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
|
||||
self,
|
||||
kind="PromptManager",
|
||||
raw=" raw ",
|
||||
prepend=" pre ",
|
||||
append=" post ",
|
||||
negative="negative",
|
||||
):
|
||||
g = {
|
||||
"p": {
|
||||
"class_type": kind,
|
||||
"inputs": {
|
||||
"text": raw,
|
||||
"negative_text": "negative",
|
||||
"negative_text": negative,
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
},
|
||||
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
|
||||
},
|
||||
},
|
||||
}
|
||||
if type(raw) is list or type(prepend) is list:
|
||||
if any(type(value) is list for value in (raw, prepend, negative)):
|
||||
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
|
||||
g["stateful"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
@@ -445,3 +450,138 @@ 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")
|
||||
|
||||
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
|
||||
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
|
||||
|
||||
|
||||
class HistoryHooksTests(unittest.TestCase):
|
||||
class HooksCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
@@ -122,6 +122,8 @@ class HistoryHooksTests(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 (
|
||||
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
|
||||
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"]))
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
"""The per-file prompt decision shared by the scan and the monitor (#247).
|
||||
|
||||
One test per case, named by the ladder step of the design
|
||||
(``docs/egm/247-foreign-workflows``). The ladder does no file or database
|
||||
I/O: history comes in through ``lookup``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from database.run_attribution import RecordedOutput
|
||||
from tests.test_pair_scan import flux_graph, sampled_graph
|
||||
from tests.test_prompt_attribution import issue_graph, pair_evidence
|
||||
from tests import test_save_to_library as save_tests
|
||||
from utils import file_prompt
|
||||
from utils.file_prompt import Policy, decide_file_prompt, junk_text
|
||||
from utils.prompt_inspector import InspectionResult
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import inspection_parity as parity
|
||||
import saver_anchor_graphs as shapes
|
||||
|
||||
SHARED_NAME = "IMG_GEN_00371_.png"
|
||||
LORA = "<lora:style:1.00>"
|
||||
|
||||
|
||||
def _decide(
|
||||
metadata,
|
||||
*,
|
||||
policy=Policy.SCAN,
|
||||
lookup=None,
|
||||
in_output=True,
|
||||
file_name=SHARED_NAME,
|
||||
file_size=None,
|
||||
):
|
||||
return decide_file_prompt(
|
||||
metadata,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
in_output=in_output,
|
||||
lookup=lookup,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _graph(graph):
|
||||
return {"prompt": json.dumps(graph)}
|
||||
|
||||
|
||||
class _Lookup:
|
||||
"""A history lookup that counts its calls."""
|
||||
|
||||
def __init__(self, saver=None):
|
||||
self.calls = 0
|
||||
self.found = RecordedOutput(saver, {}) if saver is not None else None
|
||||
|
||||
def __call__(self):
|
||||
self.calls += 1
|
||||
return self.found
|
||||
|
||||
|
||||
def _inspected(state, malformed=False):
|
||||
resolution = {"state": state, "positive": None, "negative": None}
|
||||
return InspectionResult(resolution, malformed=malformed)
|
||||
|
||||
|
||||
def _untraced_by_both():
|
||||
"""A sampled graph whose positive no profile traces; the legacy parser does."""
|
||||
return mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=_inspected("unresolved")
|
||||
)
|
||||
|
||||
|
||||
def _gated(graph):
|
||||
"""``graph`` past the default profile's node gate, so it is unresolved."""
|
||||
graph = dict(graph)
|
||||
for index in range(510):
|
||||
graph[f"pad{index}"] = {"class_type": "Note", "inputs": {}}
|
||||
return graph
|
||||
|
||||
|
||||
def _prompt_manager_branches(prepend="PRE", append="APP"):
|
||||
"""Two KSampler branches, each fed by a PromptManagerText string."""
|
||||
graph = sampled_graph()
|
||||
del graph["1"]
|
||||
sampler = graph.pop("3")
|
||||
for index, text in enumerate(("a cat", "a dog"), start=1):
|
||||
base = index * 10
|
||||
graph[str(base)] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": text, "prepend_text": prepend, "append_text": append},
|
||||
}
|
||||
graph[str(base + 1)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": [str(base), 0]},
|
||||
}
|
||||
graph[str(base + 2)] = {
|
||||
**sampler,
|
||||
"inputs": {**sampler["inputs"], "positive": [str(base + 1), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _numeric_first_branches():
|
||||
"""Three branches; the legacy parser's answer (the first) is a number."""
|
||||
graph = sampled_graph("2048")
|
||||
for index, text in ((5, "a cat"), (6, "a dog")):
|
||||
graph[str(index * 10)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text},
|
||||
}
|
||||
graph[str(index * 10 + 1)] = {
|
||||
**graph["3"],
|
||||
"inputs": {**graph["3"]["inputs"], "positive": [str(index * 10), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
class TestStepsBeforeResolving(unittest.TestCase):
|
||||
def test_step_1_no_metadata_tries_the_recorded_run(self):
|
||||
for metadata in ({}, None):
|
||||
decision = _decide(metadata)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {})
|
||||
|
||||
def test_step_3_neither_prompt_nor_parameters_tries_the_recorded_run(self):
|
||||
decision = _decide({"workflow": json.dumps({"nodes": []})})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {"workflow": {"nodes": []}})
|
||||
|
||||
def test_strings_are_parsed_and_malformed_ones_kept(self):
|
||||
decision = _decide({"prompt": "{not json", "workflow": "{}"})
|
||||
self.assertEqual(decision.canonical["prompt"], "{not json")
|
||||
self.assertEqual(decision.canonical["workflow"], {})
|
||||
|
||||
def test_the_file_size_is_recorded_unless_the_metadata_has_one(self):
|
||||
meta = _graph(sampled_graph())
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 42}
|
||||
)
|
||||
meta["file_info"] = {"size": 7, "width": 3}
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 7, "width": 3}
|
||||
)
|
||||
self.assertNotIn("file_info", _decide(_graph(sampled_graph())).canonical)
|
||||
|
||||
def test_a_file_size_reader_is_called_only_for_prompt_metadata(self):
|
||||
reads = []
|
||||
|
||||
def size():
|
||||
reads.append(1)
|
||||
return 9
|
||||
|
||||
for metadata in ({}, {"workflow": "{}"}):
|
||||
_decide(metadata, file_size=size)
|
||||
self.assertEqual(reads, [])
|
||||
decision = _decide(_graph(sampled_graph()), file_size=size)
|
||||
self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9}))
|
||||
|
||||
|
||||
def _off_behind(middle, key):
|
||||
"""A switched-off PromptManagerText reaching the encoder through
|
||||
``middle``'s ``key`` input."""
|
||||
graph = save_tests.run_graph()
|
||||
graph["1"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "SECRET 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
|
||||
|
||||
|
||||
class TestSaveToLibrarySteps(unittest.TestCase):
|
||||
"""#90: a PromptManager node with Save to library off stores nothing."""
|
||||
|
||||
def test_step_2_an_opted_out_file_is_not_saved(self):
|
||||
lookup = _Lookup("9")
|
||||
graph = save_tests.run_graph(save=False)
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["2", 0] # ambiguous otherwise
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_is_not_saved_wired(self):
|
||||
graph = save_tests.run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved_wired")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self):
|
||||
"""The #90 graph walk does not follow ``anything`` or ``value``; the
|
||||
inspector does, so its positive is checked against the switch."""
|
||||
for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")):
|
||||
with self.subTest(middle=middle):
|
||||
graph = _off_behind(middle, key)
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}}
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["9", 0]
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_2_an_off_node_on_any_positive_input_is_not_saved(self):
|
||||
"""Any input name upstream of a positive, not only prompt-like ones."""
|
||||
guider = _off_behind("MyNode", "value")
|
||||
guider["3"] = {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["4", 0], "conditioning": ["8", 0]},
|
||||
}
|
||||
prepended = _off_behind("MyNode", "value")
|
||||
prepended["1"]["inputs"]["prepend_text"] = "pre"
|
||||
pair = _off_behind("MyNode", "value")
|
||||
pair["1"] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "x", "negative_text": "y", "save_to_library": False},
|
||||
}
|
||||
pair["2"]["inputs"]["text"] = ["1", 1]
|
||||
cases = {"sampler-less guider": guider, "prepend": prepended, "pair": pair}
|
||||
for name, graph in cases.items():
|
||||
with self.subTest(name=name):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_behind_a_pass_through_is_not_saved_wired(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
|
||||
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved_wired")
|
||||
|
||||
def test_a_switched_on_node_behind_a_pass_through_is_stored(self):
|
||||
"""Regression guard: on, or no switch at all (older files)."""
|
||||
for value in (True, None):
|
||||
with self.subTest(value=value):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
if value is None:
|
||||
del graph["1"]["inputs"]["save_to_library"]
|
||||
else:
|
||||
graph["1"]["inputs"]["save_to_library"] = value
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.text, "SECRET OFF TEXT")
|
||||
|
||||
def test_an_off_node_on_a_negative_input_name_never_blocks(self):
|
||||
"""The walk skips negative inputs: #90's own rule (``neg``,
|
||||
``neg_text``, ``text_neg``) and any name containing "negative"."""
|
||||
for key in (
|
||||
"neg",
|
||||
"neg_text",
|
||||
"text_neg",
|
||||
"negative_text",
|
||||
"negative2",
|
||||
"negativeprompt",
|
||||
"NegativePrompt",
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "bad hands", "save_to_library": False},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "a cat", "save_to_library": True},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "PipeEncode",
|
||||
"inputs": {"pos_text": ["9", 0], key: ["1", 0]},
|
||||
},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["7", 0],
|
||||
"negative": ["7", 1],
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertIsNone(file_prompt._switched_off_upstream(graph))
|
||||
self.assertNotIn(
|
||||
_decide(_graph(graph)).kind, ("not_saved", "not_saved_wired")
|
||||
)
|
||||
|
||||
def test_the_guards_accept_malformed_nodes(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerText", "inputs": None},
|
||||
"2": {"class_type": "PromptManagerText"},
|
||||
}
|
||||
self.assertIsNone(file_prompt._off_text(graph, "x"))
|
||||
side = {"provenance": ["not", "a", "dict"]}
|
||||
with mock.patch.object(file_prompt, "side_opted_out", return_value=True):
|
||||
self.assertIsNone(file_prompt._side_switch(graph, side))
|
||||
|
||||
def test_the_off_text_guard_reads_a_nodes_resolved_text(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": ["30", 0], "save_to_library": False},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "SECRET OFF TEXT"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "WIRED", "save_to_library": ["9", 0]},
|
||||
},
|
||||
"9": {"class_type": "SomeBoolNode", "inputs": {}},
|
||||
}
|
||||
self.assertEqual(file_prompt._off_text(graph, " SECRET OFF TEXT"), "not_saved")
|
||||
self.assertEqual(file_prompt._off_text(graph, "WIRED"), "not_saved_wired")
|
||||
self.assertIsNone(file_prompt._off_text(graph, "other"))
|
||||
|
||||
def test_steps_5_and_7_check_the_resolved_positive_too(self):
|
||||
"""A resolved positive through an off node the #90 walk missed."""
|
||||
cases = {
|
||||
"pair": save_tests.run_graph(save=False),
|
||||
"negative off": save_tests.run_graph(save=False, negative_save=False),
|
||||
}
|
||||
for name, graph in cases.items():
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
mock.patch.object(
|
||||
file_prompt, "metadata_opted_out", return_value=False
|
||||
),
|
||||
):
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved")
|
||||
|
||||
def test_step_5_a_junk_positive_is_never_stored(self):
|
||||
"""Pinned: the positive alone still passes the junk gate, and a blank
|
||||
positive (never storable) stores nothing either."""
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
graph["1"]["inputs"]["text"] = "2048"
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
graph["1"]["inputs"]["text"] = ""
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.text), ("never", None))
|
||||
|
||||
def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self):
|
||||
workflow = {
|
||||
"nodes": [{"id": 1, "type": "PromptManager", "widgets_values": [False]}]
|
||||
}
|
||||
decision = _decide({"workflow": json.dumps(workflow)})
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
|
||||
def test_step_5_an_opted_out_negative_stores_the_positive_alone(self):
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "opted-out-negative")
|
||||
self.assertEqual(decision.text, save_tests.POSITIVE)
|
||||
|
||||
|
||||
class TestResolvedSteps(unittest.TestCase):
|
||||
def test_step_6_a_blank_positive_counts_as_unresolved(self):
|
||||
meta = {
|
||||
"prompt": json.dumps(issue_graph()),
|
||||
"comfyassets_prompt_pairs": json.dumps(pair_evidence()),
|
||||
}
|
||||
decision = _decide(meta, policy=Policy.MONITOR, in_output=False)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_7_a_resolved_pair(self):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(sampled_graph("cat", "blur")), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.source, "resolver")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "blur")
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0, "decided before step 8: history not asked")
|
||||
|
||||
def test_step_7_a_blank_negative_stays_a_pair(self):
|
||||
decision = _decide(_graph(sampled_graph("cat", "")))
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "")
|
||||
|
||||
|
||||
class TestUndecidedGraph(unittest.TestCase):
|
||||
def test_step_8_1_the_inspector_alone_decides(self):
|
||||
decision = _decide({"prompt": parity.build("f03")["prompt"]})
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "inspector")
|
||||
self.assertEqual(decision.text, parity.POS)
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_8_1_the_recorded_saver_names_its_branch(self):
|
||||
lookup = _Lookup("23")
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_1_stores_the_storage_text_without_prepend_or_append(self):
|
||||
graph = shapes.shared_prefix()
|
||||
graph["20"]["inputs"]["text"] = ["25", 1]
|
||||
graph["25"] = {
|
||||
"class_type": "PromptManagerSplit",
|
||||
"inputs": {
|
||||
"text": shapes.BRANCH_TEXTS[1],
|
||||
"prepend_text": "trigger",
|
||||
"append_text": "detail",
|
||||
},
|
||||
}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup("23"))
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
|
||||
def test_step_8_1_a_file_name_naming_another_saver_anchors_nothing(self):
|
||||
graph = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
graph[str(index * 10 + 3)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
decision = _decide(
|
||||
_graph(graph), lookup=_Lookup("43"), file_name="s45_branch_2_00001_.png"
|
||||
)
|
||||
# The name contradicts history's branch 4: no recorded anchor.
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
|
||||
def test_step_8_1_the_file_name_anchor_alone_never_decides(self):
|
||||
"""#390: the file-name anchor never decides a link. The scan's legacy
|
||||
floor may still link the file (as before #247), and a file nothing
|
||||
links is queued with the anchor's choice per #390; the monitor
|
||||
stores nothing."""
|
||||
name = "s45_branch_2_00001_.png"
|
||||
scan = _decide(_graph(shapes.branch_bench()), lookup=_Lookup(), file_name=name)
|
||||
self.assertEqual((scan.kind, scan.source), ("positive", "legacy-parser"))
|
||||
self.assertEqual(scan.review, "ambiguous")
|
||||
monitor = _decide(
|
||||
_graph(shapes.branch_bench()),
|
||||
policy=Policy.MONITOR,
|
||||
lookup=_Lookup(),
|
||||
file_name=name,
|
||||
)
|
||||
self.assertEqual(monitor.kind, "never")
|
||||
recorded = _decide(
|
||||
_graph(shapes.branch_bench()), lookup=_Lookup("43"), file_name=name
|
||||
)
|
||||
# The recorded saver still decides: history says branch 4, but the
|
||||
# file name names branch 2, which contradicts it.
|
||||
self.assertNotEqual(recorded.source, "recorded-run")
|
||||
recorded = _decide(_graph(shapes.branch_bench()), lookup=_Lookup("43"))
|
||||
self.assertEqual(recorded.source, "recorded-run")
|
||||
self.assertEqual(recorded.text, shapes.BRANCH_TEXTS[3])
|
||||
|
||||
def test_step_8_2_the_monitor_waits_for_history_inside_the_output_folder(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), policy=Policy.MONITOR)
|
||||
self.assertEqual(decision.kind, "history")
|
||||
|
||||
def test_step_8_2_outside_the_output_folder_steps_8_3_and_8_4_apply(self):
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
with _untraced_by_both():
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph())), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(
|
||||
(decision.kind, decision.source), ("positive", "legacy-parser")
|
||||
)
|
||||
|
||||
def test_step_8_3_an_ambiguous_file_is_left_unlinked_by_the_monitor(self):
|
||||
lookup = _Lookup("missing")
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, lookup=lookup
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_4_joined_prompt_manager_text_is_never_the_legacy_answer(self):
|
||||
"""The legacy parser returns prepend + text + append; the stored text
|
||||
of a PromptManager node cannot be told from it."""
|
||||
decision = _decide(_graph(_prompt_manager_branches()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# A linked part the parser does not follow: its answer is the node's
|
||||
# own text, which is what the node stores (#170 parity).
|
||||
linked = _prompt_manager_branches(prepend=["99", 0], append="")
|
||||
decision = _decide(_graph(linked), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self):
|
||||
"""Regression guard (passes on the base): no prepend or append."""
|
||||
graph = _prompt_manager_branches(prepend="", append=" ")
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_an_ambiguous_file_gets_the_legacy_answer_in_the_scan(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[0])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
|
||||
def test_step_8_4_an_untraceable_file_gets_the_legacy_answer_in_both(self):
|
||||
for policy in Policy:
|
||||
for inspected in (_inspected("unresolved"), _inspected("x", True)):
|
||||
with (
|
||||
self.subTest(policy=policy, malformed=inspected.malformed),
|
||||
mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=inspected
|
||||
),
|
||||
):
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph("cat"))),
|
||||
policy=policy,
|
||||
lookup=_Lookup(),
|
||||
)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
|
||||
def test_step_8_4_nothing_traced_tries_the_recorded_run(self):
|
||||
graph = sampled_graph()
|
||||
graph["5"] = {**graph["3"]}
|
||||
for sampler in ("3", "5"):
|
||||
graph[sampler]["inputs"]["positive"] = ["9", 0]
|
||||
graph["9"] = {"class_type": "BasicGuider", "inputs": {"width": 2048}}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# Ambiguous without a choice is reported unresolved.
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
self.assertEqual(decision.review, "untraced")
|
||||
|
||||
|
||||
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
|
||||
def test_step_9_ambiguous_parameters_only(self):
|
||||
meta = {
|
||||
"parameters": (
|
||||
"cat\nNegative prompt: blur\nNegative prompt: noise"
|
||||
"\nSteps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_10_unresolved_without_a1111_text(self):
|
||||
decision = _decide({"prompt": {}, "parameters": 5})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_11_sampler_less_with_one_text(self):
|
||||
decision = _decide(_graph(flux_graph()))
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "sampler-less"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_with_several_texts(self):
|
||||
graph = flux_graph()
|
||||
graph["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "a lora note"},
|
||||
}
|
||||
graph["22"]["inputs"]["model"] = ["50", 0]
|
||||
graph["6"]["inputs"]["clip"] = ["50", 1]
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
meta = {**_graph(graph), "parameters": "a red fox\nNegative prompt: x"}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_without_a_text(self):
|
||||
graph = {"40": {"class_type": "easy showAnything", "inputs": {"text": "x"}}}
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_12_a1111_only(self):
|
||||
decision = _decide({"parameters": "a cat"})
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": "Negative prompt: blur"})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
|
||||
def test_step_12_parsed_metadata_never_raises(self):
|
||||
# The monitor hands parsed values: a dict prompt, non-string parameters.
|
||||
decision = _decide({"prompt": {}, "parameters": "a cat"})
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": 5})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
|
||||
|
||||
class TestJunkGate(unittest.TestCase):
|
||||
JUNK = {
|
||||
"blank": " ",
|
||||
"number": "2048",
|
||||
"lora-only": f"{LORA}, <lora:detail:0.5> |",
|
||||
"model-file": "models/checkpoints/Flux-Dev.SafeTensors",
|
||||
}
|
||||
|
||||
def test_each_junk_class_is_named(self):
|
||||
for name, text in self.JUNK.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(junk_text(text), name)
|
||||
for ending in ("ckpt", "pt", "pth", "bin", "gguf", "sft"):
|
||||
self.assertEqual(junk_text(f"model.{ending}"), "model-file")
|
||||
|
||||
def test_near_misses_are_not_junk(self):
|
||||
for text in (
|
||||
f"a red fox {LORA}",
|
||||
"a.safetensors b.safetensors",
|
||||
"nan",
|
||||
"a portrait saved as final.safetensors",
|
||||
"4k",
|
||||
"C:\\My Models\\x.safetensors",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(junk_text(text))
|
||||
|
||||
def test_step_13_junk_from_any_step_is_never_stored(self):
|
||||
cases = {
|
||||
"resolver": _graph(sampled_graph("2048", "blur")),
|
||||
"sampler-less": _graph(flux_graph(text=LORA)),
|
||||
"a1111": {"parameters": "model.safetensors"},
|
||||
}
|
||||
for step, meta in cases.items():
|
||||
with self.subTest(step=step):
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.source.startswith("junk:"), decision.source)
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_13_a_junk_legacy_answer_is_never_stored(self):
|
||||
with _untraced_by_both():
|
||||
decision = _decide(_graph(_gated(sampled_graph(LORA))), lookup=_Lookup())
|
||||
self.assertEqual(decision.source, "junk:lora-only")
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_13_junk_at_step_8_keeps_that_steps_status_and_review(self):
|
||||
# An ambiguous file whose legacy answer is a number is still a
|
||||
# Needs review case: its status stays "ambiguous".
|
||||
decision = _decide(_graph(_numeric_first_branches()), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
|
||||
|
||||
class TestParseComfyuiPrompt(unittest.TestCase):
|
||||
def test_a_parsed_prompt_and_non_string_values_are_kept(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt(
|
||||
{"prompt": {"1": {}}, "workflow": {"nodes": []}, "steps": 20}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], {"1": {}})
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
def test_non_string_parameters_are_ignored(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt({"parameters": {"a": 1}})
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
|
||||
"""Ingestion decides with the default resolver profile first (D1, #247 D-Q1).
|
||||
|
||||
The live monitor and the admin scan write pairs and links to the user's
|
||||
database, so their resolver semantics stay byte-for-byte today's. f03 (506
|
||||
executable nodes) separates the profiles: the default profile's 500-node gate
|
||||
leaves it unresolved, while the inspection profile resolves it.
|
||||
database, so the default profile decides every file it can, byte-for-byte as
|
||||
before. f03 (506 executable nodes) separates the profiles: the default
|
||||
profile's 500-node gate leaves it unresolved, while the inspection profile
|
||||
resolves it. Only such an undecided file reaches the inspector's resolve
|
||||
(``inspection_resolution``), and only its positive is stored (#247).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -68,24 +70,26 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
||||
spy.assert_default_and_gated(self)
|
||||
handler.db_manager.link_image_to_prompt_or_raise.assert_called_once()
|
||||
|
||||
def test_admin_scan_ingestion_uses_the_default_profile(self):
|
||||
def test_admin_scan_ingestion_uses_the_default_profile_first(self):
|
||||
from py.api import admin
|
||||
from utils import file_prompt
|
||||
|
||||
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
mixin.db = Mock()
|
||||
mixin.db.get_prompt_by_hash.return_value = None
|
||||
mixin.db.save_prompt.return_value = 7
|
||||
spy = _Spy()
|
||||
with patch.object(admin, "resolve_prompt_pair", spy):
|
||||
with (
|
||||
patch.object(file_prompt, "resolve_prompt_pair", spy),
|
||||
patch.object(
|
||||
admin, "store_scanned_prompt", return_value=("added", 7)
|
||||
) as store,
|
||||
):
|
||||
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
||||
spy.assert_default_and_gated(self)
|
||||
# The resolver leaves f03 unresolved; the scan links the prompt the
|
||||
# browser parser traces, as the inspector shows it.
|
||||
# The default profile leaves f03 unresolved; the inspector's resolve
|
||||
# names the prompt the inspector shows, and the scan stores it.
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual(
|
||||
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
||||
)
|
||||
mixin.db.link_image_to_prompt.assert_called_once()
|
||||
self.assertEqual(store.call_args.args[3], "a red fox in snow, detailed fur")
|
||||
mixin.db.link_image_to_prompt.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -209,6 +209,38 @@ class TestMove(_MoveCase):
|
||||
)
|
||||
self.assertEqual(ll.read_marker(self.target)["state"], ll.MARKER_MOVED)
|
||||
|
||||
def test_the_workspace_key_moves_with_the_library(self):
|
||||
# #430: open pages keep their scopes after the restart that follows.
|
||||
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
|
||||
|
||||
secret = load_install_secret(self.ext)
|
||||
result = self.move()
|
||||
self.assertIn(KEY_FILE_NAME, result.files)
|
||||
self.assertEqual(load_install_secret(self.target), secret)
|
||||
if os.name == "posix":
|
||||
mode = os.stat(os.path.join(self.target, KEY_FILE_NAME)).st_mode
|
||||
self.assertEqual(mode & 0o777, 0o600)
|
||||
|
||||
def test_a_key_that_cannot_be_copied_never_fails_the_move(self):
|
||||
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
|
||||
|
||||
load_install_secret(self.ext)
|
||||
real = lm._hash_copy
|
||||
|
||||
def failing(source, destination):
|
||||
if os.path.basename(source) == KEY_FILE_NAME:
|
||||
raise OSError("disk error")
|
||||
return real(source, destination)
|
||||
|
||||
with mock.patch.object(lm, "_hash_copy", failing):
|
||||
with self.assertLogs("prompt_manager.database.library_move", "WARNING"):
|
||||
result = self.move()
|
||||
self.assertNotIn(KEY_FILE_NAME, result.files)
|
||||
self.assertEqual(_rows(self.new_db), self.before["rows"])
|
||||
self.assertFalse(
|
||||
any(n.startswith(lm.TEMP_PREFIX) for n in os.listdir(self.target))
|
||||
)
|
||||
|
||||
def test_new_files_are_private(self):
|
||||
if os.name != "posix":
|
||||
self.skipTest("POSIX permissions")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
@@ -449,3 +452,159 @@ class TestSamplerLessScan(AdminAPITestCase):
|
||||
path = self.output_dir / "saver.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
|
||||
class TestScanDecision(AdminAPITestCase):
|
||||
"""The scan applies the shared per-file decision (#247): one prompt per
|
||||
file, no junk text, marks on every prompt it creates."""
|
||||
|
||||
def _path(self, name):
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
return path
|
||||
|
||||
def _links(self, path):
|
||||
rows = self.api.db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
def _prompt(self, text):
|
||||
return self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
path = self._path("fox.png")
|
||||
other = self.api.db.save_prompt(
|
||||
"a dog", prompt_hash=generate_prompt_hash("a dog")
|
||||
)
|
||||
self.api.db.link_image_to_prompt(other, str(path))
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self._links(path), [other])
|
||||
self.assertIsNone(self._prompt("a red fox"))
|
||||
|
||||
def test_junk_text_is_never_stored(self):
|
||||
cases = {
|
||||
"lora.png": {"prompt": json.dumps(flux_graph(text="<lora:style:1.00>"))},
|
||||
"model.png": {"parameters": "models/flux-dev.safetensors"},
|
||||
}
|
||||
for name, meta in cases.items():
|
||||
with self.subTest(name=name):
|
||||
path = self._path(name)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertEqual(self._links(path), [])
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_created_prompts_carry_the_scan_marks(self):
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
for text in ("cat", "blur"):
|
||||
prompt = self._prompt(text)
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from pair.png")
|
||||
|
||||
def test_outcome_of_a_positive_reports_its_title(self):
|
||||
"""Regression guard (passes on the base): the detailed positive row."""
|
||||
path = self._path("fox.png")
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
first = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(first, {"status": "added", "title": "a red fox"})
|
||||
again = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(again["status"], "unchanged")
|
||||
other = self._path("fox2.png")
|
||||
linked = self.api._ingest_scanned_file(other, meta, detailed=True)
|
||||
self.assertEqual(linked["status"], "linked")
|
||||
|
||||
def test_outcome_of_a_pair_reports_its_counts(self):
|
||||
"""Regression guard (passes on the base): the detailed pair row."""
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
result = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual((result["pairs_added"], result["links_added"]), (1, 1))
|
||||
self.assertEqual(result["title"], "cat")
|
||||
|
||||
def test_outcome_without_a_store_reports_the_unlinked_status(self):
|
||||
"""Regression guard (passes on the base): never-stored rows."""
|
||||
path = self._path("notes.png")
|
||||
meta = {"prompt": json.dumps({"40": {"class_type": "Textbox", "inputs": {}}})}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertIsNone(self.api._ingest_scanned_file(path, {}))
|
||||
|
||||
def test_review_lists_get_the_default_profiles_ambiguous_files(self):
|
||||
"""Regression guard (passes on the base): Needs review inputs."""
|
||||
g = sampled_graph()
|
||||
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
|
||||
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
|
||||
ambiguous, untraced, records = [], [], {}
|
||||
path = self._path("two.png")
|
||||
status = self.api._ingest_scanned_file(
|
||||
path,
|
||||
{"prompt": json.dumps(g)},
|
||||
ambiguous=ambiguous,
|
||||
untraced=untraced,
|
||||
records=records,
|
||||
)
|
||||
# Linked by the legacy floor, still listed (queued only if unlinked).
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual((ambiguous, untraced), ([path], []))
|
||||
self.assertEqual(records["canonical"]["prompt"], g)
|
||||
|
||||
def test_an_ambiguous_file_with_a_junk_answer_is_still_queued(self):
|
||||
from tests.test_file_prompt import _numeric_first_branches
|
||||
|
||||
path = self._path("numbers.png")
|
||||
meta = {"prompt": json.dumps(_numeric_first_branches())}
|
||||
with patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
counts = self.api._ingest_scan_batch_sync([(path, meta)])
|
||||
self.assertEqual(counts["ambiguous"], 1, counts)
|
||||
record.assert_called_once()
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
def test_a_conflict_row_says_the_link_was_kept(self):
|
||||
from py.api.admin import _scan_outcome
|
||||
|
||||
_group, row, _line = _scan_outcome(self._path("c.png"), [], "conflict")
|
||||
self.assertEqual(row["detail"], "Kept: already linked to a different prompt")
|
||||
|
||||
def test_a_library_move_reruns_the_scan_writes(self):
|
||||
from py.api import admin
|
||||
|
||||
real = admin.ingest_resolved_pair
|
||||
model = self.api.db.model
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
calls = []
|
||||
path = self._path("pair.png")
|
||||
with patch.object(admin, "ingest_resolved_pair", side_effect=moved_once):
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
def test_an_off_node_behind_a_pass_through_stores_nothing(self):
|
||||
"""#90 regression guard for the scan (main stored nothing here)."""
|
||||
from tests.test_save_to_library import run_graph
|
||||
|
||||
g = run_graph(save=False)
|
||||
g["1"]["class_type"] = "PromptManagerText"
|
||||
g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}}
|
||||
g["8"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
|
||||
}
|
||||
g["3"]["inputs"]["positive"] = ["8", 0]
|
||||
path = self._path("off.png")
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
@@ -99,7 +99,10 @@ 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)
|
||||
"1",
|
||||
("cat", ""),
|
||||
Facts(usage_tracking.generate_prompt_hash("cat"), None),
|
||||
(("cat", "", ""), ("", "", "")),
|
||||
)
|
||||
|
||||
def test_validation_rejection_never_counts_or_saves(self):
|
||||
|
||||
@@ -308,7 +308,11 @@ 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"))
|
||||
"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", ""),),
|
||||
)
|
||||
# Hook-visible text: the node counts nothing itself
|
||||
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
|
||||
@@ -319,7 +323,9 @@ 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__":
|
||||
|
||||
@@ -375,5 +375,89 @@ class TestRecordedTexts(unittest.TestCase):
|
||||
self.assertEqual(evidence["resolution"]["sampler_id"], sampler)
|
||||
|
||||
|
||||
class TestInspectionResolution(unittest.TestCase):
|
||||
"""``inspection_resolution``: the inspector's resolve and recorded anchor,
|
||||
shared with ingestion (#247), so the two cannot drift."""
|
||||
|
||||
def _result(self, graph, saver=None, *, as_text=True, name="IMG_GEN_00371_.png"):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
prompt = json.dumps(graph) if as_text else graph
|
||||
return inspection_resolution({"prompt": prompt}, name, saver)
|
||||
|
||||
def test_the_recorded_saver_names_its_branch_by_record(self):
|
||||
result = self._result(shapes.shared_prefix(), _saver(2))
|
||||
self.assertTrue(result.by_record)
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "resolved")
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIs(result.anchored, result.resolution)
|
||||
|
||||
def test_without_a_saver_nothing_is_anchored(self):
|
||||
result = self._result(shapes.shared_prefix())
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_saver_that_names_nothing_keeps_the_open_result(self):
|
||||
result = self._result(shapes.shared_prefix(), "missing")
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNotNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_file_that_resolves_alone_is_never_anchored(self):
|
||||
result = self._result(
|
||||
shapes.branch_bench(), _saver(4), name="s45_branch_2_00001_.png"
|
||||
)
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_parsed_metadata_resolves_as_the_file_text_does(self):
|
||||
# The monitor hands parsed dicts; the scan hands the file's strings.
|
||||
parsed = self._result(shapes.shared_prefix(), _saver(3), as_text=False)
|
||||
text = self._result(shapes.shared_prefix(), _saver(3))
|
||||
self.assertEqual(parsed.resolution, text.resolution)
|
||||
|
||||
def test_malformed_metadata_is_reported_as_malformed(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"prompt": "{not json"}, None, None)
|
||||
self.assertTrue(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "unresolved")
|
||||
self.assertEqual(result.resolution["reason_code"], "malformed")
|
||||
|
||||
def test_metadata_without_a_role_record_is_absent(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"workflow": "{}"}, None, "9")
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "absent")
|
||||
|
||||
def test_without_the_name_anchor_a_file_name_names_no_branch(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
graph = {"prompt": json.dumps(shapes.branch_bench())}
|
||||
name = "s45_branch_2_00001_.png"
|
||||
named = inspection_resolution(graph, name, None)
|
||||
self.assertEqual(_positive(named.resolution), shapes.BRANCH_TEXTS[1])
|
||||
blind = inspection_resolution(graph, name, None, name_anchor=False)
|
||||
self.assertEqual(blind.resolution["state"], "ambiguous")
|
||||
# The recorded saver still decides, and the name still contradicts it.
|
||||
recorded = inspection_resolution(graph, name, _saver(2), name_anchor=False)
|
||||
self.assertTrue(recorded.by_record)
|
||||
shared = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
bench = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
bench[_saver(index)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
contradicted = inspection_resolution(
|
||||
{"prompt": json.dumps(bench)}, name, _saver(4), name_anchor=False
|
||||
)
|
||||
self.assertFalse(contradicted.by_record)
|
||||
self.assertTrue(
|
||||
inspection_resolution(shared, name, _saver(4), name_anchor=False).by_record
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -17,6 +17,8 @@ from database.run_attribution import (
|
||||
from tests import test_execution_history_database as history_tests
|
||||
from tests.background_stops import close_handler
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests.test_pair_scan import sampled_graph
|
||||
from tests.test_execution_context_database import known, snapshot
|
||||
from tests.test_prompt_attribution import issue_graph, llm_text_graph, make_tracker
|
||||
from utils.execution_contexts import (
|
||||
@@ -326,7 +328,7 @@ class TestRecordedOutputNode(RecordedRuns, unittest.TestCase):
|
||||
self.assertIsNone(recorded_output_lookup(broken)(self.image))
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution._recorded_runs",
|
||||
"database.run_attribution.recorded_runs",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
),
|
||||
self.assertLogs("prompt_manager.run_attribution", "WARNING"),
|
||||
@@ -451,6 +453,86 @@ class TestScanUsesHistory(RecordedRuns, AdminAPITestCase):
|
||||
self.assertIsNone(self.linked_prompt(path))
|
||||
self.assertEqual(db.list_prompt_pairs()["pairs"], [])
|
||||
|
||||
def scan_dataset(self, names, nodes):
|
||||
"""Two branches of the shared-prefix dataset graph, one run, then a scan."""
|
||||
paths = [self.write(name) for name in names]
|
||||
self.record_run(
|
||||
self.api.db,
|
||||
str(self.output_dir),
|
||||
[("", path.name) for path in paths],
|
||||
[],
|
||||
nodes=nodes,
|
||||
)
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
return paths, self.api._ingest_scan_batch_sync([(p, meta) for p in paths])
|
||||
|
||||
def test_the_recorded_saver_links_each_dataset_file_to_its_branch(self):
|
||||
"""#247: before, the legacy parser gave every branch the first prompt."""
|
||||
paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00001_.png", "IMG_GEN_00002_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
for path, text in zip(paths, shapes.BRANCH_TEXTS):
|
||||
prompt = self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
self.assertEqual(self.linked_prompt(path), prompt["id"])
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
|
||||
def test_a_file_the_legacy_answer_linked_is_left_as_it_is(self):
|
||||
"""#247 Q5: a rescan never adds a second link to the branch prompt."""
|
||||
db = self.api.db
|
||||
legacy = shapes.BRANCH_TEXTS[0]
|
||||
legacy_id = db.save_prompt(legacy, prompt_hash=generate_prompt_hash(legacy))
|
||||
path = self.write("IMG_GEN_00002_.png")
|
||||
db.link_image_to_prompt(legacy_id, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [], nodes=["23"])
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self.linked_prompt(path), legacy_id)
|
||||
self.assertIsNone(
|
||||
db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[1]))
|
||||
)
|
||||
|
||||
def test_a_recorded_run_never_adds_a_second_link(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
other = db.save_prompt(text="a dog", prompt_hash=generate_prompt_hash("a dog"))
|
||||
path = self.write("bare_00003_.png")
|
||||
db.link_image_to_prompt(other, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, {}), "conflict")
|
||||
rows = db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
self.assertEqual([row[0] for row in rows], [other])
|
||||
|
||||
def test_a_single_file_asks_history_once(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
path = self.write("llm_00002_.png")
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
meta = {"prompt": json.dumps(llm_text_graph())}
|
||||
statements = []
|
||||
conn = db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "linked")
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
lookups = [s for s in statements if "FROM execution_run_outputs" in s]
|
||||
self.assertEqual(len(lookups), 1)
|
||||
|
||||
def test_a_file_the_recorded_saver_links_is_not_queued(self):
|
||||
"""A default-ambiguous file linked at the inspector's step is no
|
||||
Needs review case."""
|
||||
with mock.patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
_paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00003_.png", "IMG_GEN_00004_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
record.assert_not_called()
|
||||
|
||||
|
||||
class TestHostHelpers(unittest.TestCase):
|
||||
"""The host lookups degrade to "no proof", never to an error."""
|
||||
@@ -752,13 +834,17 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
"""M5: a scan batch asks history once per chunk of names, not per file."""
|
||||
|
||||
def test_a_batch_its_metadata_links_never_asks_history(self):
|
||||
"""R3: the batch lookup is made only when a file needs it."""
|
||||
"""R3: the batch lookup is made only when a file needs it.
|
||||
|
||||
Since #247 every file the default profile leaves undecided asks (the
|
||||
recorded saver may name its branch), so these files resolve as pairs.
|
||||
"""
|
||||
files = []
|
||||
for n in range(3):
|
||||
path = self.output_dir / f"typed_{n:05}_.png"
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
meta = {"prompt": json.dumps(issue_graph(text="typed mountains"))}
|
||||
meta = {"prompt": json.dumps(sampled_graph("typed mountains"))}
|
||||
statements = []
|
||||
conn = self.api.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
@@ -802,6 +888,72 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
}
|
||||
self.assertEqual(linked, {f.name for f in files[:4]})
|
||||
|
||||
def test_the_batch_saver_equals_the_files_recorded_output(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
db = self.api.db
|
||||
names = ["one_node.png", "two_nodes.png", "two_runs.png", "unrecorded.png"]
|
||||
files = []
|
||||
for name in names:
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
root = str(self.output_dir)
|
||||
self.record_run(db, root, [("", "one_node.png")], [], nodes=["13"])
|
||||
self.record_run(
|
||||
db, root, [("", "two_nodes.png")] * 2, [], item=1, nodes=["13", "23"]
|
||||
)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=2)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=3)
|
||||
history = _BatchHistory(self.api, files)
|
||||
with mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
):
|
||||
for path in files:
|
||||
with self.subTest(path=path.name):
|
||||
expected = recorded_output(db, str(path), root)
|
||||
found = history.recorded(str(path))
|
||||
if expected is None:
|
||||
self.assertIsNone(found)
|
||||
else:
|
||||
self.assertEqual(found.saver_id, expected.saver_id)
|
||||
self.assertEqual(history.recorded(str(files[0])).saver_id, "13")
|
||||
self.assertIsNone(history.recorded(str(files[1])).saver_id)
|
||||
|
||||
def test_a_failed_batch_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "x.png"
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"py.api.admin.recorded_runs", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.recorded(str(path)))
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
|
||||
def test_a_failed_prompt_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "one.png"
|
||||
path.write_bytes(b"png")
|
||||
root = str(self.output_dir)
|
||||
self.record_run(self.api.db, root, [("", path.name)], [LLM_TEXT])
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
),
|
||||
mock.patch(
|
||||
"py.api.admin.named_prompt", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
self.assertEqual(history.recorded(str(path)).saver_id, "9")
|
||||
|
||||
|
||||
class TestLinkedOutputRoot(RecordedRuns, unittest.TestCase):
|
||||
"""R2: ComfyUI's output folder reached through a symlink or junction."""
|
||||
|
||||
@@ -40,6 +40,7 @@ IMAGES_STATE = {
|
||||
"media": "all",
|
||||
"tags": ["fluffy"],
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
IMAGES_DEFAULTS = {
|
||||
@@ -51,6 +52,7 @@ IMAGES_DEFAULTS = {
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
|
||||
# A row saved by an older build: no favorites key, a retired key, a sort
|
||||
@@ -230,6 +232,48 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
|
||||
async def test_write_requires_the_collection_key(self):
|
||||
# #331 D140: a view saved on a collection page keeps the collection id.
|
||||
without = {k: v for k, v in IMAGES_STATE.items() if k != "collection"}
|
||||
good = {"page": "images", "name": "Grid", "state": IMAGES_STATE}
|
||||
cases = [
|
||||
{**good, "state": without},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "0"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "x"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": 12}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "012"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "1" * 10}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": None}},
|
||||
# Favorites and a collection are never both a view's filter.
|
||||
{
|
||||
**good,
|
||||
"state": {**IMAGES_STATE, "favorites": True, "collection": "3"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
with self.subTest(body=body):
|
||||
response, data = await self.send("POST", body=body)
|
||||
self.assertEqual(response.status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
for collection in ("1", "12", "9" * 9):
|
||||
view = await self.create(state={"collection": collection})
|
||||
self.assertEqual(view["state"]["collection"], collection)
|
||||
self.assertIs(view["state"]["favorites"], False)
|
||||
|
||||
async def test_an_old_row_without_a_collection_reads_empty(self):
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Old', ?, 0, 'x', 'x')",
|
||||
(json.dumps({"q": "cat", "favorites": True}),),
|
||||
)
|
||||
_, data = await self.send("GET")
|
||||
(view,) = data["views"]
|
||||
self.assertEqual(
|
||||
view["state"], {**IMAGES_DEFAULTS, "q": "cat", "favorites": True}
|
||||
)
|
||||
self.assertEqual(view["state"]["collection"], "")
|
||||
|
||||
async def test_rename_and_delete_validate_id_and_body(self):
|
||||
view = await self.create()
|
||||
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
|
||||
@@ -618,6 +662,17 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
),
|
||||
{**IMAGES_DEFAULTS, "folder": "kept"},
|
||||
)
|
||||
# A stored row with both filters (never written by this build) opens the
|
||||
# collection, as a listing URL with both does.
|
||||
self.assertEqual(
|
||||
normalize_state("images", {"favorites": True, "collection": "4"}),
|
||||
{**IMAGES_DEFAULTS, "collection": "4"},
|
||||
)
|
||||
for bad in ("0", "x", 12, "012"):
|
||||
with self.subTest(collection=bad):
|
||||
self.assertEqual(
|
||||
normalize_state("images", {"collection": bad}), IMAGES_DEFAULTS
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Storing a file's prompt from its text: one transaction, one prompt each (#247)."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
from tests import test_execution_history_database as history_tests
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
import test_execution_history_database as history_tests
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import (
|
||||
RUN_MARKS,
|
||||
SCAN_MARKS,
|
||||
Marks,
|
||||
ingest_resolved_pair,
|
||||
)
|
||||
from database.scanned_prompts import store_scanned_prompt, store_scanned_prompt_or_raise
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
META = {"prompt": {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}}}
|
||||
|
||||
|
||||
def resolved(positive="cat", negative="blur"):
|
||||
return {
|
||||
"state": "resolved",
|
||||
"positive": {"text": positive, "provenance": {"source": "api"}},
|
||||
"negative": {"text": negative, "provenance": {"source": "api"}},
|
||||
}
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
|
||||
self.path = str(Path(self.temp.name) / "gen.png")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def prompts(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT id, text FROM prompts ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def links(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT prompt_id, image_path FROM generated_images ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def save(self, text, **fields):
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **fields
|
||||
)
|
||||
|
||||
|
||||
class TestStoreScannedPrompt(StoreCase):
|
||||
def test_a_new_text_is_created_with_the_scan_marks_and_linked(self):
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, " a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(status, "added")
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["text"], "a cat")
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from gen.png")
|
||||
self.assertEqual(prompt["run_count"], 0)
|
||||
self.assertEqual(self.links(), [(prompt_id, self.path)])
|
||||
|
||||
def test_a_run_created_prompt_names_the_workflow_run(self):
|
||||
_status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", RUN_MARKS
|
||||
)
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["notes"], "Saved from a workflow run: gen.png")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
|
||||
def test_a_stored_text_is_reused_by_hash_and_keeps_its_fields(self):
|
||||
existing = self.save("A Cat", category="mine", notes="kept")
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, prompt_id), ("linked", existing))
|
||||
prompt = self.db.get_prompt_by_id(existing)
|
||||
self.assertEqual((prompt["category"], prompt["notes"]), ("mine", "kept"))
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
|
||||
def test_a_repeat_is_unchanged(self):
|
||||
first = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
again = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(again, ("unchanged", first[1]))
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
other = self.save("a dog")
|
||||
# Linked under another spelling of the same file.
|
||||
spelled = os.path.join(self.temp.name, ".", "gen.png")
|
||||
self.db.link_image_to_prompt(other, spelled)
|
||||
result = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("conflict", None))
|
||||
self.assertEqual(self.prompts(), [(other, "a dog")])
|
||||
self.assertEqual(self.links(), [(other, spelled)])
|
||||
|
||||
def test_a_failed_link_raises_and_leaves_no_prompt_behind(self):
|
||||
with mock.patch.object(
|
||||
self.db, "_insert_image_link", side_effect=sqlite3.OperationalError("x")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
with self.assertLogs("prompt_manager", "ERROR"):
|
||||
result = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("found", None))
|
||||
self.assertEqual(self.prompts(), [])
|
||||
self.assertEqual(self.links(), [])
|
||||
|
||||
def test_two_concurrent_writers_store_one_prompt_and_one_link(self):
|
||||
hold = history_tests.FirstWriterHold()
|
||||
outcome = self.db._pair_component_outcome
|
||||
started = threading.Barrier(2)
|
||||
|
||||
def held_open(*args, **kwargs):
|
||||
hold()
|
||||
return outcome(*args, **kwargs)
|
||||
|
||||
def write(_index):
|
||||
started.wait(timeout=30)
|
||||
try:
|
||||
return store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)[0]
|
||||
finally:
|
||||
self.db.model.close()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
self.db, "_pair_component_outcome", side_effect=held_open
|
||||
),
|
||||
ThreadPoolExecutor(max_workers=2) as pool,
|
||||
):
|
||||
statuses = sorted(pool.map(write, range(2)))
|
||||
self.assertEqual(statuses, ["added", "unchanged"])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_library_move_reruns_the_store(self):
|
||||
from database import scanned_prompts
|
||||
|
||||
real = scanned_prompts.store_scanned_prompt_or_raise
|
||||
calls = []
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
self.db.model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(
|
||||
scanned_prompts, "store_scanned_prompt_or_raise", side_effect=moved_once
|
||||
):
|
||||
status, _id = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, calls), ("added", [1]))
|
||||
|
||||
def _linked_folder(self):
|
||||
"""``(out_real, out_link)``: a symlink to the same output folder."""
|
||||
from tests.test_api_duplicate_files import TestReview
|
||||
|
||||
if not TestReview._can_symlink(self.temp.name): # pragma: no cover
|
||||
self.skipTest("symlinks are not available")
|
||||
real = Path(self.temp.name) / "out_real"
|
||||
real.mkdir()
|
||||
link = Path(self.temp.name) / "out_link"
|
||||
os.symlink(str(real), str(link))
|
||||
(real / "a.png").write_bytes(b"png")
|
||||
return os.path.realpath(str(real)), str(link)
|
||||
|
||||
def test_a_file_linked_through_its_real_folder_is_found_through_a_link(self):
|
||||
"""The scan stores realpath spellings, the monitor the watcher's."""
|
||||
real, link = self._linked_folder()
|
||||
other = self.save("a dog")
|
||||
self.db.link_image_to_prompt(other, os.path.join(real, "a.png"))
|
||||
through_link = os.path.join(link, "a.png")
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a cat", SCAN_MARKS
|
||||
),
|
||||
("conflict", None),
|
||||
)
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a dog", SCAN_MARKS
|
||||
),
|
||||
("unchanged", other),
|
||||
)
|
||||
pair = ingest_resolved_pair(self.db, through_link, META, resolved())
|
||||
self.assertEqual(pair["status"], "conflict")
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
|
||||
class TestPairMarks(StoreCase):
|
||||
def test_marks_reach_only_created_components(self):
|
||||
kept = self.save("cat", category="mine")
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(), marks=RUN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
positive = self.db.get_prompt_by_id(kept)
|
||||
self.assertEqual((positive["category"], positive["tags"]), ("mine", []))
|
||||
negative_id = next(pid for pid, text in self.prompts() if text == "blur")
|
||||
negative = self.db.get_prompt_by_id(negative_id)
|
||||
self.assertEqual(negative["category"], "scanned")
|
||||
self.assertEqual(negative["tags"], ["auto-scanned"])
|
||||
self.assertEqual(negative["notes"], "Saved from a workflow run: gen.png")
|
||||
|
||||
def test_without_marks_created_components_stay_bare(self):
|
||||
"""Regression guard (passes on the base): the node path is unchanged."""
|
||||
ingest_resolved_pair(self.db, self.path, META, resolved())
|
||||
for prompt_id, _text in self.prompts():
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertIsNone(prompt["category"])
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
|
||||
def test_a_blank_negative_creates_only_the_positive(self):
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(negative=""), marks=SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual([text for _id, text in self.prompts()], ["cat"])
|
||||
|
||||
def test_marks_are_a_frozen_value(self):
|
||||
self.assertEqual(
|
||||
SCAN_MARKS, Marks("scanned", ("auto-scanned",), SCAN_MARKS.note)
|
||||
)
|
||||
with self.assertRaises(AttributeError):
|
||||
SCAN_MARKS.category = "x"
|
||||
|
||||
|
||||
class TestPublicRunAttribution(unittest.TestCase):
|
||||
def test_recorded_runs_and_named_prompt_are_public(self):
|
||||
from database import run_attribution
|
||||
|
||||
self.assertTrue(callable(run_attribution.recorded_runs))
|
||||
self.assertTrue(callable(run_attribution.named_prompt))
|
||||
self.assertFalse(hasattr(run_attribution, "_recorded_runs"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""The per-install workspace secret (#430, utils/workspace_keys.py).
|
||||
|
||||
A ComfyUI restart must not change the gallery root scopes or the workspace
|
||||
cursors while the output roots stay the same, so the secret that keys them is
|
||||
persisted beside the database instead of drawn per process.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import utils.workspace_keys as workspace_keys # noqa: E402
|
||||
from utils.workspace_keys import ( # noqa: E402
|
||||
KEY_FILE_NAME,
|
||||
WorkspaceKeys,
|
||||
gallery_root_scope,
|
||||
load_install_secret,
|
||||
workspace_keys_for,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TempFolderCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.folder = Path(os.path.realpath(folder.name))
|
||||
self.key_file = self.folder / KEY_FILE_NAME
|
||||
|
||||
def leftovers(self):
|
||||
return sorted(p.name for p in self.folder.iterdir() if p.name != KEY_FILE_NAME)
|
||||
|
||||
|
||||
class TestLoadInstallSecret(TempFolderCase):
|
||||
def test_a_missing_file_is_created_with_64_hex_and_owner_only_mode(self):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(len(secret), 32)
|
||||
text = self.key_file.read_text(encoding="ascii")
|
||||
self.assertEqual(text, secret.hex() + "\n")
|
||||
self.assertEqual(self.leftovers(), [], "no temp file is left behind")
|
||||
if os.name == "posix":
|
||||
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode) & 0o077, 0)
|
||||
|
||||
def test_an_existing_file_is_reused(self):
|
||||
first = load_install_secret(str(self.folder))
|
||||
self.assertEqual(load_install_secret(str(self.folder)), first)
|
||||
|
||||
def test_an_uppercase_or_unterminated_file_is_still_read(self):
|
||||
self.key_file.write_text("AB" * 32, encoding="ascii")
|
||||
self.assertEqual(
|
||||
load_install_secret(str(self.folder)), bytes.fromhex("ab" * 32)
|
||||
)
|
||||
|
||||
def test_a_corrupt_file_is_replaced_with_a_warning(self):
|
||||
for content in (b"", b"short", b"zz" * 32, b"ab" * 33, b"\xff" * 64):
|
||||
with self.subTest(content=content):
|
||||
self.key_file.write_bytes(content)
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(len(secret), 32)
|
||||
self.assertEqual(
|
||||
self.key_file.read_text(encoding="ascii"), secret.hex() + "\n"
|
||||
)
|
||||
self.assertIn("replaced", "\n".join(logs.output))
|
||||
self.assertNotIn(secret.hex(), "\n".join(logs.output))
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_lost_creation_race_reads_the_winners_file(self):
|
||||
winner = "cd" * 32 + "\n"
|
||||
real_link = os.link
|
||||
|
||||
def racing_link(source, target, *args, **kwargs):
|
||||
Path(target).write_text(winner, encoding="ascii")
|
||||
return real_link(source, target, *args, **kwargs) # FileExistsError
|
||||
|
||||
with patch.object(workspace_keys.os, "link", racing_link):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(secret, bytes.fromhex("cd" * 32))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), winner)
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_file_system_without_hard_links_falls_back_to_replace(self):
|
||||
def no_link(*args, **kwargs):
|
||||
raise OSError(1, "Operation not permitted")
|
||||
|
||||
with patch.object(workspace_keys.os, "link", no_link):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), secret.hex() + "\n")
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_an_unwritable_folder_returns_none_and_leaves_nothing(self):
|
||||
def refuse(*args, **kwargs):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
with patch.object(workspace_keys.os, "open", refuse):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertFalse(self.key_file.exists())
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder / "missing")))
|
||||
|
||||
def test_an_unreadable_key_path_returns_none(self):
|
||||
self.key_file.mkdir() # a folder where the file should be
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
|
||||
def test_a_refused_replace_of_a_corrupt_key_falls_back(self):
|
||||
# Windows refuses os.replace while another process holds the file open.
|
||||
self.key_file.write_text("broken", encoding="ascii")
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise PermissionError(13, "Access is denied")
|
||||
|
||||
with patch.object(workspace_keys.os, "replace", refuse):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), "broken")
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_lost_race_against_an_unfinished_file_is_unusable_not_replaced(self):
|
||||
def racing_link(source, target, *args, **kwargs):
|
||||
Path(target).write_text("ab", encoding="ascii") # still being written
|
||||
raise FileExistsError(target)
|
||||
|
||||
with patch.object(workspace_keys.os, "link", racing_link):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), "ab")
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX permission bits")
|
||||
def test_an_existing_key_readable_by_others_is_tightened(self):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
os.chmod(self.key_file, 0o644)
|
||||
self.assertEqual(load_install_secret(str(self.folder)), secret)
|
||||
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode), 0o600)
|
||||
|
||||
def test_old_temp_files_are_swept_and_fresh_ones_kept(self):
|
||||
old = self.folder / f".{KEY_FILE_NAME}.dead.tmp"
|
||||
fresh = self.folder / f".{KEY_FILE_NAME}.live.tmp"
|
||||
other = self.folder / "prompts.db"
|
||||
for path in (old, fresh, other):
|
||||
path.write_text("x")
|
||||
os.utime(old, (1, 1))
|
||||
load_install_secret(str(self.folder))
|
||||
self.assertEqual(self.leftovers(), sorted([fresh.name, other.name]))
|
||||
|
||||
|
||||
class TestWorkspaceKeys(TempFolderCase):
|
||||
def test_purposes_are_domain_separated_and_stable(self):
|
||||
keys = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertTrue(keys.persisted)
|
||||
self.assertNotEqual(keys.root_scope, keys.cursor)
|
||||
again = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertEqual(
|
||||
(again.root_scope, again.cursor), (keys.root_scope, keys.cursor)
|
||||
)
|
||||
self.assertNotIn(
|
||||
self.key_file.read_text().strip(),
|
||||
(keys.root_scope.hex(), keys.cursor.hex()),
|
||||
)
|
||||
|
||||
def test_another_database_folder_gets_other_keys(self):
|
||||
other = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(other.cleanup)
|
||||
first = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
second = workspace_keys_for(os.path.join(other.name, "prompts.db"))
|
||||
self.assertNotEqual(first.root_scope, second.root_scope)
|
||||
|
||||
def test_an_unusable_folder_falls_back_to_a_process_key_with_a_warning(self):
|
||||
with patch.object(workspace_keys, "load_install_secret", return_value=None):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
|
||||
keys = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertFalse(keys.persisted)
|
||||
self.assertEqual(len(keys.root_scope), 32)
|
||||
self.assertIn("restart", "\n".join(logs.output))
|
||||
|
||||
def test_memory_and_blank_paths_never_write_into_the_working_folder(self):
|
||||
for path in (":memory:", "", None):
|
||||
with self.subTest(path=path):
|
||||
with patch.object(workspace_keys, "load_install_secret") as load:
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
keys = workspace_keys_for(path)
|
||||
load.assert_not_called()
|
||||
self.assertFalse(keys.persisted)
|
||||
|
||||
def test_the_scope_is_the_historic_payload_under_the_root_scope_key(self):
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
keys = WorkspaceKeys(b"r" * 32, b"c" * 32, True)
|
||||
payload = json.dumps(
|
||||
["prompt-manager-gallery-root-v1", 3, "/x/ü"],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
self.assertEqual(
|
||||
gallery_root_scope(keys, "/x/ü", 3),
|
||||
hmac.new(b"r" * 32, payload, hashlib.sha256).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
class TestRestartKeepsScopes(TempFolderCase):
|
||||
"""A new process or a reloaded module derives the same scope (#430)."""
|
||||
|
||||
def test_a_module_reload_keeps_the_scope(self):
|
||||
db = str(self.folder / "prompts.db")
|
||||
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
|
||||
reloaded = importlib.reload(workspace_keys)
|
||||
self.addCleanup(importlib.reload, workspace_keys)
|
||||
self.assertEqual(
|
||||
reloaded.gallery_root_scope(reloaded.workspace_keys_for(db), "/out", 0),
|
||||
before,
|
||||
)
|
||||
|
||||
def test_a_new_process_derives_the_same_scope(self):
|
||||
db = str(self.folder / "prompts.db")
|
||||
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
|
||||
code = (
|
||||
"import sys\n"
|
||||
"from utils.workspace_keys import gallery_root_scope, workspace_keys_for\n"
|
||||
"print(gallery_root_scope(workspace_keys_for(sys.argv[1]), '/out', 0))\n"
|
||||
)
|
||||
# -I would drop the repo from sys.path; the child imports only stdlib code.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code, db],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=True,
|
||||
)
|
||||
self.assertEqual(result.stdout.strip(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+20
@@ -27,3 +27,23 @@ test('browser coverage maps nested JS/MJS and excludes unrelated and traversal p
|
||||
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
|
||||
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
|
||||
});
|
||||
|
||||
// #432: every raw file repeated each script's full source (about 84% of its bytes) and the coverage merge ran out of
|
||||
// memory. c8 reads the source from the mapped file on disk, so the entries keep only the URL, id and functions.
|
||||
test('browser coverage writes no script source, only what c8 maps', async t => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
|
||||
const previous = process.env.PM_JS_COVERAGE;
|
||||
process.env.PM_JS_COVERAGE = directory;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
|
||||
else process.env.PM_JS_COVERAGE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const functions = [{ functionName: 'f', isBlockCoverage: true, ranges: [{ startOffset: 0, endOffset: 9, count: 1 }] }];
|
||||
await saveCoverage({ coverage: { stopJSCoverage: async () => [{ url: 'http://127.0.0.1:8765/prompt_manager/js/gallery.js',
|
||||
scriptId: '7', source: 'x'.repeat(100_000), functions }] } }, 'chromium');
|
||||
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
|
||||
assert.equal(report.result.length, 1);
|
||||
assert.deepEqual(Object.keys(report.result[0]).sort(), ['functions', 'scriptId', 'url']);
|
||||
assert.deepEqual(report.result[0].functions, functions);
|
||||
});
|
||||
|
||||
Vendored
+4
-1
@@ -12,7 +12,10 @@ export async function startCoverage(page, browserName) {
|
||||
export async function saveCoverage(page, browserName) {
|
||||
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
|
||||
const entries = await page.coverage.stopJSCoverage();
|
||||
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
|
||||
// The source is left out (#432): c8 reads it from the mapped file on disk, and repeating every script's text in every
|
||||
// raw file made the coverage merge run out of memory.
|
||||
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') })
|
||||
.map(({ source: _source, ...entry }) => entry);
|
||||
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
|
||||
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
|
||||
}
|
||||
|
||||
+27
-15
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 37,
|
||||
"branches": 61,
|
||||
"functions": 32,
|
||||
"statements": 37
|
||||
"lines": 47,
|
||||
"branches": 64,
|
||||
"functions": 50,
|
||||
"statements": 47
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
@@ -38,7 +38,7 @@
|
||||
"focused-node.js": {
|
||||
"lines": 97,
|
||||
"branches": 87,
|
||||
"functions": 90,
|
||||
"functions": 91,
|
||||
"statements": 97
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
@@ -48,10 +48,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 49,
|
||||
"branches": 68,
|
||||
"functions": 50,
|
||||
"statements": 49
|
||||
"lines": 54,
|
||||
"branches": 72,
|
||||
"functions": 57,
|
||||
"statements": 54
|
||||
},
|
||||
"html-escape.js": {
|
||||
"lines": 98,
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"image-helpers.js": {
|
||||
"lines": 96,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -203,6 +203,12 @@
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/collections.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 93,
|
||||
@@ -260,7 +266,7 @@
|
||||
"workspace/image-tagging.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 92,
|
||||
"functions": 88,
|
||||
"functions": 89,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
@@ -293,6 +299,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-source-recovery.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -409,7 +421,7 @@
|
||||
},
|
||||
"workspace/local-resolver.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 86,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -529,7 +541,7 @@
|
||||
},
|
||||
"workspace/system-diagnostics.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 86,
|
||||
"branches": 87,
|
||||
"functions": 94,
|
||||
"statements": 95
|
||||
},
|
||||
@@ -625,7 +637,7 @@
|
||||
},
|
||||
"workspace/vocabulary-dialog.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"branches": 89,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -638,7 +650,7 @@
|
||||
"workspace/vocabulary.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/workspace-dialogs.mjs": {
|
||||
|
||||
+201
-28
@@ -15,9 +15,14 @@ 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). In memory only and
|
||||
bounded; starts no threads.
|
||||
this process, nothing is counted (the behavior before #347) and the history
|
||||
fields stay unknown. In memory only and bounded; starts no threads.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -39,6 +44,7 @@ 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
|
||||
@@ -53,6 +59,7 @@ 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")
|
||||
|
||||
@@ -61,9 +68,15 @@ 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):
|
||||
@@ -96,6 +109,8 @@ class Seen(NamedTuple):
|
||||
class _Item(NamedTuple):
|
||||
outputs: Tuple[str, ...]
|
||||
facts: Facts
|
||||
fields: Optional[Fields] = None
|
||||
field_bytes: int = 0
|
||||
|
||||
|
||||
class _Receipt:
|
||||
@@ -108,6 +123,18 @@ 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
|
||||
@@ -117,6 +144,22 @@ 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()
|
||||
@@ -245,11 +288,14 @@ 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
|
||||
@@ -284,7 +330,10 @@ class CachedRuns:
|
||||
self._attempt = None
|
||||
for run_key, receipts in self._receipts.items():
|
||||
if receipts and receipts[0].item_id == item_id:
|
||||
self._receipts[run_key] = _deduplicated(receipts)
|
||||
before = _bytes(receipts)
|
||||
deduplicated = _deduplicated(receipts)
|
||||
self._field_bytes += _bytes(deduplicated) - before
|
||||
self._receipts[run_key] = deduplicated
|
||||
|
||||
def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]:
|
||||
"""The attempt the executing node belongs to, and its list index."""
|
||||
@@ -305,8 +354,19 @@ class CachedRuns:
|
||||
with self._lock:
|
||||
return str(node_id) in attempt.replayed
|
||||
|
||||
def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None:
|
||||
"""Remember what this execution returned and was entitled to count."""
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
attempt, index = self._current()
|
||||
node = attempt.nodes.get(str(node_id)) if attempt else None
|
||||
@@ -317,14 +377,20 @@ class CachedRuns:
|
||||
return
|
||||
if any(not isinstance(value, str) for value in outputs):
|
||||
return
|
||||
item = _Item(tuple(text_digest(value) for value in outputs), facts)
|
||||
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,
|
||||
)
|
||||
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._receipts.pop(run_key, [])
|
||||
receipts = self._take(run_key)
|
||||
if receipts and receipts[0].item_id == item_id:
|
||||
receipt = receipts[0]
|
||||
else:
|
||||
@@ -334,9 +400,44 @@ class CachedRuns:
|
||||
receipt = _Receipt(item_id)
|
||||
receipts.insert(0, receipt)
|
||||
receipt.items[index] = item
|
||||
self._receipts[run_key] = receipts[: self._max_receipts]
|
||||
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)
|
||||
while len(self._receipts) > self._max_keys:
|
||||
self._receipts.popitem(last=False)
|
||||
_, 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)
|
||||
|
||||
# -- cached nodes -------------------------------------------------------
|
||||
|
||||
@@ -393,25 +494,11 @@ 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:
|
||||
receipts = self._receipts.get(run_key)
|
||||
if not receipts:
|
||||
return None
|
||||
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
|
||||
matches = self._matching(run_key, values)
|
||||
if not matches:
|
||||
return None
|
||||
self._receipts.move_to_end(run_key)
|
||||
length = len(values[0])
|
||||
facts = {
|
||||
tuple(receipt.items[index].facts for index in range(length))
|
||||
for receipt in matches
|
||||
@@ -420,13 +507,99 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
if not receipts:
|
||||
return receipts
|
||||
outputs = receipts[0].outputs()
|
||||
return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs]
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]:
|
||||
|
||||
@@ -151,10 +151,77 @@ 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
|
||||
executor: object,
|
||||
message_type: object,
|
||||
message_data: object,
|
||||
*,
|
||||
receipts: object = None,
|
||||
) -> None:
|
||||
"""Copy bounded local strings without cache/provider execution or retention."""
|
||||
"""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``.
|
||||
"""
|
||||
try:
|
||||
binding = active_capture()
|
||||
if binding is None or type(message_type) is not str:
|
||||
@@ -198,18 +265,29 @@ 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(
|
||||
inferred.raw,
|
||||
raw,
|
||||
TextEvidence(value, "recorded", "host_cache_output", None),
|
||||
inferred.prepend,
|
||||
inferred.append,
|
||||
prepend,
|
||||
append,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""The per-file prompt decision shared by the output scan and the monitor (#247).
|
||||
|
||||
``decide_file_prompt`` is the ladder: which prompt one output file names,
|
||||
from its metadata alone plus, when the graph cannot decide, the save node
|
||||
recorded history says wrote it. It does no file or database I/O; the caller
|
||||
reads the metadata, the file size and history (through ``lookup``) and
|
||||
stores the ``Decision``.
|
||||
|
||||
The default resolver profile decides first, as ingestion always has. Only
|
||||
for a file it leaves ambiguous or unresolved does the ladder ask the
|
||||
inspector's resolve (``inspection_resolution``: the inspection profile, then
|
||||
the recorded saver anchor; never the file-name saver anchor, whose files go
|
||||
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
|
||||
The policies differ in one place: for a file the inspector calls ambiguous,
|
||||
the scan keeps the legacy floor and the monitor stores nothing. Junk text
|
||||
(``junk_text``) is never stored, whichever step found it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from .prompt_graph import (
|
||||
PROMPT_MANAGER_TYPES,
|
||||
_positive_roots,
|
||||
is_negative_input,
|
||||
metadata_opted_out,
|
||||
resolve_text,
|
||||
save_setting,
|
||||
side_opted_out,
|
||||
)
|
||||
from .prompt_inspector import inspection_resolution
|
||||
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
|
||||
from .review_capture import reviewable
|
||||
|
||||
try:
|
||||
from ..database.pair_ingestion import blank_positive
|
||||
except ImportError:
|
||||
from database.pair_ingestion import blank_positive
|
||||
|
||||
|
||||
class Policy(Enum):
|
||||
SCAN = "scan"
|
||||
MONITOR = "monitor"
|
||||
|
||||
|
||||
PAIR = "pair"
|
||||
POSITIVE = "positive"
|
||||
HISTORY = "history"
|
||||
NOT_SAVED = "not_saved"
|
||||
NOT_SAVED_WIRED = "not_saved_wired"
|
||||
NEVER = "never"
|
||||
KINDS = (PAIR, POSITIVE, HISTORY, NOT_SAVED, NOT_SAVED_WIRED, NEVER)
|
||||
|
||||
_UNDECIDED = ("ambiguous", "unresolved")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What to store for one file (see the design's "The ladder").
|
||||
|
||||
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
|
||||
``canonical``: the parsed metadata stored with the link. ``resolution``:
|
||||
the resolution that decided a ``pair``, else the default profile's
|
||||
result. ``source``: which step named the text (log lines and the scan's
|
||||
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
|
||||
default profile was ambiguous (the scan's Needs review lists).
|
||||
``try_recorded_run``: the scan tries the recorded run's stored prompt
|
||||
when nothing else links. ``unlinked_status``: the scan status when
|
||||
nothing links (None: no prompt metadata).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
text: Optional[str] = None
|
||||
canonical: dict = field(default_factory=dict)
|
||||
resolution: Optional[dict] = None
|
||||
source: str = "none"
|
||||
review: Optional[str] = None
|
||||
try_recorded_run: bool = False
|
||||
unlinked_status: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _File:
|
||||
"""The ladder's inputs beside the metadata."""
|
||||
|
||||
metadata: Mapping
|
||||
canonical: dict
|
||||
resolution: dict
|
||||
review: Optional[str]
|
||||
file_name: Optional[str]
|
||||
in_output: bool
|
||||
lookup: Optional[Callable[[], Any]]
|
||||
policy: Policy
|
||||
|
||||
|
||||
def decide_file_prompt(
|
||||
metadata: Any,
|
||||
*,
|
||||
file_name: Optional[str],
|
||||
file_size: Union[None, int, Callable[[], Optional[int]]],
|
||||
in_output: bool,
|
||||
lookup: Optional[Callable[[], Any]],
|
||||
policy: Policy,
|
||||
) -> Decision:
|
||||
"""The ladder for one file's raw ``metadata``.
|
||||
|
||||
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
|
||||
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
|
||||
it is None when history must not be asked yet. It is called at most
|
||||
once, and only for a graph the default profile leaves undecided.
|
||||
``file_size`` may be a callable: it is read only for a file with prompt
|
||||
metadata whose ``file_info`` has no size.
|
||||
"""
|
||||
if not metadata:
|
||||
return Decision(NEVER, try_recorded_run=True)
|
||||
canonical = _parsed(metadata)
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: nothing is stored,
|
||||
# no recorded-run fallback, no Needs review item (#90)
|
||||
return Decision(NOT_SAVED, canonical=canonical)
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it
|
||||
return Decision(NOT_SAVED_WIRED, canonical=canonical)
|
||||
switched = _switched_off_upstream(canonical.get("prompt"))
|
||||
if switched is not None:
|
||||
# The #90 walk follows only prompt-like inputs; any input name
|
||||
# upstream of a positive can carry a switched-off node's text
|
||||
return Decision(switched, canonical=canonical)
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
|
||||
_with_size(canonical, file_size)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
switched = (
|
||||
_side_switch(canonical.get("prompt"), resolution["positive"])
|
||||
if resolution["state"] == "resolved"
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The positive passes a node that is off, though the #90 graph walk
|
||||
# did not reach it (steps 5 and 7)
|
||||
return Decision(switched, canonical=canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: the positive alone, never a pair
|
||||
positive = resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"]).strip()
|
||||
return _gated(
|
||||
Decision(
|
||||
POSITIVE,
|
||||
text=text,
|
||||
canonical=canonical,
|
||||
resolution=resolution,
|
||||
source="opted-out-negative",
|
||||
),
|
||||
None,
|
||||
)
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
found = _File(
|
||||
metadata,
|
||||
canonical,
|
||||
resolution,
|
||||
_review(resolution),
|
||||
file_name,
|
||||
in_output,
|
||||
lookup,
|
||||
policy,
|
||||
)
|
||||
return _gated(_decide(found), found)
|
||||
|
||||
|
||||
def _decide(found: _File) -> Decision:
|
||||
state = found.resolution["state"]
|
||||
graph = found.canonical.get("prompt")
|
||||
if state == "resolved":
|
||||
positive = found.resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"])
|
||||
return _decision(found, PAIR, text, "resolver")
|
||||
if graph and state in _UNDECIDED:
|
||||
return _undecided_graph(found)
|
||||
if state == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
|
||||
return _never(found, "unresolved")
|
||||
if graph:
|
||||
return _sampler_less(found, graph)
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is None:
|
||||
return _never(found, "found")
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
|
||||
|
||||
def _undecided_graph(found: _File) -> Decision:
|
||||
recorded = found.lookup() if found.lookup is not None else None
|
||||
saver = recorded.saver_id if recorded is not None else None
|
||||
inspected = inspection_resolution(
|
||||
found.metadata, found.file_name, saver, name_anchor=False
|
||||
)
|
||||
text = _inspected_text(inspected.resolution)
|
||||
switched = (
|
||||
_side_switch(found.canonical["prompt"], inspected.resolution["positive"])
|
||||
if text is not None
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The inspector follows pass-throughs the #90 graph walk does not
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None:
|
||||
source = "recorded-run" if inspected.by_record else "inspector"
|
||||
return _decision(found, POSITIVE, text, source)
|
||||
if found.policy is Policy.MONITOR:
|
||||
if found.lookup is None and found.in_output:
|
||||
return _decision(found, HISTORY, None, "none")
|
||||
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
text = graph_prompt_text(found.metadata)
|
||||
switched = _off_text(found.canonical["prompt"], text) if text else None
|
||||
if switched is not None:
|
||||
# The legacy answer is a switched-off node's own text (#90)
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None and _joined_text(found.canonical["prompt"], text):
|
||||
# The legacy parser joins prepend + text + append: the text a
|
||||
# PromptManager node stores cannot be told from it (#343).
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "legacy-parser")
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
|
||||
|
||||
def _sampler_less(found: _File, graph: Any) -> Decision:
|
||||
# No node takes a positive or negative input (a guider or wrapper graph):
|
||||
# the one wired text, never a guess (#252).
|
||||
wired = sampler_less_prompt(graph)
|
||||
if wired["text"] is not None:
|
||||
return _decision(found, POSITIVE, wired["text"], "sampler-less")
|
||||
if isinstance(found.canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
status = "unresolved" if wired["state"] == "ambiguous" else "found"
|
||||
return _never(found, status, try_recorded_run=True)
|
||||
|
||||
|
||||
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
|
||||
|
||||
|
||||
def _switch_kind(settings: Any) -> Optional[str]:
|
||||
"""``not_saved`` when a setting is off, ``not_saved_wired`` when one is
|
||||
unknowable (wired from a node the graph cannot read), else None."""
|
||||
settings = list(settings)
|
||||
if False in settings:
|
||||
return NOT_SAVED
|
||||
if None in settings:
|
||||
return NOT_SAVED_WIRED
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_manager_ids(graph: Any) -> list:
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _side_switch(graph: Any, side: Any) -> Optional[str]:
|
||||
"""The switch outcome of the PromptManager nodes on a side's path."""
|
||||
if not isinstance(graph, dict) or not side_opted_out(graph, side):
|
||||
return None
|
||||
provenance = side.get("provenance")
|
||||
path = provenance.get("path") if isinstance(provenance, dict) else None
|
||||
path = path if isinstance(path, list) else []
|
||||
nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set(
|
||||
_prompt_manager_ids(graph)
|
||||
)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in nodes)
|
||||
|
||||
|
||||
# Inputs that never carry prompt text or positive conditioning.
|
||||
_NOT_TEXT_INPUTS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"latent_image",
|
||||
"samples",
|
||||
"pixels",
|
||||
"image",
|
||||
"images",
|
||||
"mask",
|
||||
"noise",
|
||||
"sigmas",
|
||||
"sampler",
|
||||
"control_net",
|
||||
"upscale_model",
|
||||
}
|
||||
)
|
||||
_MAX_SWITCH_VISITS = 5000
|
||||
|
||||
|
||||
def _negative_name(key: Any) -> bool:
|
||||
"""#90's negative rule, plus any name containing "negative"
|
||||
(``negative2``, ``NegativePrompt``)."""
|
||||
return is_negative_input(key) or "negative" in str(key).lower()
|
||||
|
||||
|
||||
def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
"""The switch outcome of every PromptManager node upstream of a positive.
|
||||
|
||||
Follows every input except negative and non-text ones (#90's walk follows
|
||||
only prompt-like names). A graph without a recognisable sampler checks
|
||||
every PromptManager node, as #90 does.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
roots = _positive_roots(graph)
|
||||
if not roots:
|
||||
ids = _prompt_manager_ids(graph)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
|
||||
seen, found = set(), set()
|
||||
stack = [str(link[0]) for link in roots]
|
||||
while stack and len(seen) < _MAX_SWITCH_VISITS:
|
||||
node_id = stack.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
found.add(node_id)
|
||||
inputs = node.get("inputs")
|
||||
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
|
||||
if key in _NOT_TEXT_INPUTS or _negative_name(key):
|
||||
continue
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
|
||||
stack.append(str(value[0]))
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
||||
|
||||
|
||||
def _off_text(graph: Any, text: str) -> Optional[str]:
|
||||
"""The switch outcome when ``text`` is the text (resolved, else literal)
|
||||
of a PromptManager-family node whose Save to library is not on."""
|
||||
wanted = text.strip()
|
||||
matched = []
|
||||
for node_id in _prompt_manager_ids(graph):
|
||||
node_text = resolve_text(graph, node_id)
|
||||
if node_text is None:
|
||||
inputs = graph[node_id].get("inputs")
|
||||
literal = inputs.get("text") if isinstance(inputs, dict) else None
|
||||
node_text = literal if isinstance(literal, str) else None
|
||||
if node_text is not None and node_text.strip() == wanted:
|
||||
matched.append(save_setting(graph, node_id))
|
||||
return _switch_kind(matched)
|
||||
|
||||
|
||||
def _joined_text(graph: Any, text: str) -> bool:
|
||||
"""Whether ``text`` may hold a PromptManager node's prepend or append.
|
||||
|
||||
True when a PromptManager-family node has a prepend or append part and
|
||||
``text`` is not the literal ``text`` of one such node (the legacy
|
||||
parser's answer when it does not follow a linked part).
|
||||
"""
|
||||
own = _prompt_manager_parts(graph)
|
||||
return own is not None and text.strip() not in own
|
||||
|
||||
|
||||
def _prompt_manager_parts(graph: Any) -> Optional[set]:
|
||||
"""The literal texts of PromptManager-family nodes with a prepend or
|
||||
append part, or None when no such node exists."""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
found = None
|
||||
for node in graph.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
name, inputs = node.get("class_type"), node.get("inputs")
|
||||
if not isinstance(name, str) or "promptmanager" not in name.lower():
|
||||
continue
|
||||
if not isinstance(inputs, dict) or not _has_part(inputs):
|
||||
continue
|
||||
found = found if found is not None else set()
|
||||
if isinstance(inputs.get("text"), str):
|
||||
found.add(inputs["text"].strip())
|
||||
return found
|
||||
|
||||
|
||||
def _has_part(inputs: dict) -> bool:
|
||||
for key in ("prepend_text", "append_text"):
|
||||
part = inputs.get(key)
|
||||
if part is not None and not (isinstance(part, str) and not part.strip()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _gated(decision: Decision, found: Optional[_File]) -> Decision:
|
||||
"""Junk text is never stored (step 13).
|
||||
|
||||
A junk answer of the undecided-graph step keeps that step's unlinked
|
||||
status, so an ambiguous file is still a Needs review case; any other
|
||||
step reports ``found``.
|
||||
"""
|
||||
if decision.kind not in (PAIR, POSITIVE):
|
||||
return decision
|
||||
junk = junk_text(decision.text)
|
||||
if junk is None:
|
||||
return decision
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=decision.canonical,
|
||||
resolution=decision.resolution,
|
||||
source=f"junk:{junk}",
|
||||
review=decision.review,
|
||||
try_recorded_run=True,
|
||||
unlinked_status=(
|
||||
_unlinked(found)
|
||||
if found is not None and decision.source in _UNDECIDED_SOURCES
|
||||
else "found"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
|
||||
return Decision(
|
||||
kind,
|
||||
text=text,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
source=source,
|
||||
review=found.review,
|
||||
)
|
||||
|
||||
|
||||
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
review=found.review,
|
||||
try_recorded_run=try_recorded_run,
|
||||
unlinked_status=status,
|
||||
)
|
||||
|
||||
|
||||
def _review(resolution: dict) -> Optional[str]:
|
||||
if resolution["state"] != "ambiguous":
|
||||
return None
|
||||
return "ambiguous" if reviewable(resolution) else "untraced"
|
||||
|
||||
|
||||
def _unlinked(found: _File) -> str:
|
||||
"""Ambiguous without a choice is reported unresolved."""
|
||||
if found.review == "untraced":
|
||||
return "unresolved"
|
||||
return found.resolution["state"]
|
||||
|
||||
|
||||
def _inspected_text(resolution: dict) -> Optional[str]:
|
||||
"""The storage text of a positive the inspector shows, else None."""
|
||||
if resolution.get("state") in _UNDECIDED:
|
||||
return None
|
||||
positive = resolution.get("positive")
|
||||
if not isinstance(positive, dict):
|
||||
return None
|
||||
text = positive.get("storage_text", positive.get("text"))
|
||||
return text if isinstance(text, str) and text.strip() else None
|
||||
|
||||
|
||||
def _parsed(metadata: Mapping) -> dict:
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
return canonical
|
||||
|
||||
|
||||
def _with_size(canonical: dict, file_size: Any) -> None:
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a
|
||||
known file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = file_size() if callable(file_size) else file_size
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _a1111_positive(metadata: Mapping) -> Optional[str]:
|
||||
text = parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
return text.strip() if text and text.strip() else None
|
||||
|
||||
|
||||
_LORA_TAG = re.compile(r"<lora:[^>]*>")
|
||||
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
|
||||
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
|
||||
|
||||
|
||||
def junk_text(text: Any) -> Optional[str]:
|
||||
"""The #252 junk class of ``text``, or None when it may be a prompt."""
|
||||
stripped = text.strip() if isinstance(text, str) else ""
|
||||
if not stripped:
|
||||
return "blank"
|
||||
if _numeric(stripped):
|
||||
return "number"
|
||||
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
|
||||
return "lora-only"
|
||||
if _MODEL_FILE.fullmatch(stripped):
|
||||
return "model-file"
|
||||
return None
|
||||
|
||||
|
||||
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
|
||||
_COMMON_FIELDS = (
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
)
|
||||
|
||||
|
||||
def _json_or_raw(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def parse_comfyui_prompt(metadata: Mapping) -> dict:
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata.
|
||||
|
||||
Accepts the file's strings or parsed values: a parsed ``prompt`` or
|
||||
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
|
||||
"""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
params = metadata.get("parameters")
|
||||
if isinstance(params, str):
|
||||
_parse_a1111(params, result)
|
||||
if result["positive_prompt"] is None:
|
||||
for key in ("prompt", "workflow"):
|
||||
if key in metadata:
|
||||
result[key] = _json_or_raw(metadata[key])
|
||||
for name in _COMMON_FIELDS:
|
||||
if name in metadata:
|
||||
result["parameters"][name] = _json_or_raw(metadata[name])
|
||||
return result
|
||||
|
||||
|
||||
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
|
||||
|
||||
|
||||
def _parse_a1111(params: str, result: dict) -> None:
|
||||
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
|
||||
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
Taking the first line as the positive would truncate a multi-line prompt,
|
||||
so the text is split on the settings line instead.
|
||||
"""
|
||||
body = params
|
||||
settings = ""
|
||||
found = re.search(r"\nSteps:\s", body)
|
||||
if found:
|
||||
settings = body[found.start() + 1 :].strip()
|
||||
body = body[: found.start()]
|
||||
negative = None
|
||||
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if found:
|
||||
negative = body[found.end() :].strip()
|
||||
body = body[: found.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
result["parameters"]["parameters"] = params
|
||||
for name in _SETTINGS_FIELDS if settings else ():
|
||||
found = re.search(
|
||||
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if found:
|
||||
key = name.lower().replace(" ", "_")
|
||||
result["parameters"][key] = found.group(1).strip()
|
||||
@@ -41,9 +41,14 @@ _TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
|
||||
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_negative_input(key: Any) -> bool:
|
||||
"""Whether an input name carries a negative prompt (#90's one rule)."""
|
||||
return isinstance(key, str) and bool(_NEGATIVE_KEY.search(key))
|
||||
|
||||
|
||||
def follows_input(key: Any) -> bool:
|
||||
"""Whether an input name carries positive conditioning or prompt text."""
|
||||
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
|
||||
if not isinstance(key, str) or is_negative_input(key):
|
||||
return False
|
||||
return key == "positive" or bool(
|
||||
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
|
||||
|
||||
+132
-65
@@ -1,4 +1,10 @@
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels."""
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels.
|
||||
|
||||
``inspection_resolution`` is the inspector's resolve (inspection profile, then
|
||||
the recorded saver anchor, #386). Ingestion (the output scan, through
|
||||
``utils.file_prompt``) calls it too, only for files the default profile
|
||||
cannot decide (#247), so the inspector and ingestion cannot drift.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
@@ -571,6 +577,102 @@ def _recorded_execution(resolved: dict, texts: Mapping) -> dict | None:
|
||||
return {"prompts": prompts, "resolution": resolution}
|
||||
|
||||
|
||||
_ABSENT_RESOLUTION = {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
_MALFORMED_RESOLUTION = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InspectionResult:
|
||||
"""The inspector's resolution of one file's metadata (#386, #247).
|
||||
|
||||
``resolution`` is the result to use. ``anchored`` is the result anchored
|
||||
on the recorded saver, kept or not (None when no anchored resolve ran).
|
||||
``by_record`` says the anchored result was kept: history's saver chose
|
||||
the samplers and the result names the positive. ``malformed`` marks
|
||||
metadata the inspector cannot read as a graph.
|
||||
"""
|
||||
|
||||
resolution: dict
|
||||
anchored: dict | None = None
|
||||
by_record: bool = False
|
||||
malformed: bool = False
|
||||
|
||||
|
||||
def inspection_resolution(
|
||||
metadata: Mapping,
|
||||
file_name: str | None,
|
||||
recorded_saver_id: str | None,
|
||||
*,
|
||||
profile: ResolverProfile = INSPECTION_PROFILE,
|
||||
name_anchor: bool = True,
|
||||
) -> InspectionResult:
|
||||
"""Resolve raw file metadata as the inspector does.
|
||||
|
||||
Keeps only the role records, parses a string ``prompt`` and attaches the
|
||||
parsed ``workflow`` only when the graph may hold a PromptManager node.
|
||||
Resolves with ``profile``; when that leaves the prompt open and
|
||||
``recorded_saver_id`` is given, resolves again anchored on that saver
|
||||
and keeps the anchored result only when it names the positive.
|
||||
|
||||
``name_anchor=False`` (ingestion, #247) resolves the unanchored pass
|
||||
without ``file_name``, so the saver its file name names anchors nothing:
|
||||
such a file goes to Needs review, never linked (#390 ruling). The
|
||||
recorded-saver pass still gets ``file_name``, only to refuse a saver the
|
||||
name contradicts.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
canonical = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
if not canonical:
|
||||
return InspectionResult(dict(_ABSENT_RESOLUTION))
|
||||
malformed = InspectionResult(dict(_MALFORMED_RESOLUTION), malformed=True)
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(
|
||||
canonical, profile=profile, file_name=file_name if name_anchor else None
|
||||
)
|
||||
if resolved["state"] not in _UNDECIDED or recorded_saver_id is None:
|
||||
return InspectionResult(resolved)
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if (
|
||||
anchored.get("recorded_anchor") is True
|
||||
and anchored["state"] not in _UNDECIDED
|
||||
and isinstance(anchored["positive"], dict)
|
||||
):
|
||||
return InspectionResult(anchored, anchored, True)
|
||||
return InspectionResult(resolved, anchored)
|
||||
|
||||
|
||||
def inspect_prompt_evidence(
|
||||
metadata: dict,
|
||||
saved: dict | None = None,
|
||||
@@ -593,22 +695,13 @@ def inspect_prompt_evidence(
|
||||
``execution``.
|
||||
|
||||
Inspection routes resolve with ``INSPECTION_PROFILE``: bounded work, not
|
||||
graph size. Ingestion (scan, live monitor, admin import) calls the resolver
|
||||
directly with its default profile. ``resolution`` carries the closed
|
||||
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
|
||||
graph size. The resolve itself is ``inspection_resolution``, which
|
||||
ingestion also calls for files its default profile cannot decide (#247).
|
||||
``resolution`` carries the closed ``reason_code`` and, for a blocking
|
||||
node, ``node: {id, class_type}``.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
if not metadata:
|
||||
roles = {key for key in metadata if key in _ROLE_METADATA_KEYS}
|
||||
if not roles:
|
||||
result = {role: _side("absent") for role in ("positive", "negative")}
|
||||
if saved:
|
||||
context = saved.get("pair_context")
|
||||
@@ -621,56 +714,30 @@ def inspect_prompt_evidence(
|
||||
"verified against this file."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"prompts": result,
|
||||
"resolution": {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
canonical = dict(metadata)
|
||||
malformed = {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
return {"prompts": result, "resolution": dict(_ABSENT_RESOLUTION)}
|
||||
inspected = inspection_resolution(
|
||||
metadata, file_name, recorded_saver_id, profile=profile
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(canonical, profile=profile, file_name=file_name)
|
||||
recorded = False
|
||||
if resolved["state"] in _UNDECIDED and recorded_saver_id is not None:
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if anchored.get("recorded_anchor") is True:
|
||||
if anchored["state"] not in _UNDECIDED and isinstance(
|
||||
anchored["positive"], dict
|
||||
):
|
||||
resolved, recorded = anchored, True
|
||||
elif recorded_texts:
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
if inspected.malformed:
|
||||
resolution = inspected.resolution
|
||||
return {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
resolved, recorded = inspected.resolution, inspected.by_record
|
||||
anchored = inspected.anchored
|
||||
if (
|
||||
not recorded
|
||||
and recorded_texts
|
||||
and anchored is not None
|
||||
and anchored.get("recorded_anchor") is True
|
||||
):
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
state = resolved["state"]
|
||||
result = {}
|
||||
for role in ("positive", "negative"):
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The per-install secret behind the workspace's gallery scopes and cursors (#430).
|
||||
|
||||
A page addresses a listed file as ``{root_index, root_scope, path}``. The root
|
||||
scope is an HMAC over the root's index and canonical path, so the server can
|
||||
refuse an action aimed at a root that was renumbered or replaced since the page
|
||||
listed it (``py/api/images.py`` ``_gallery_scope_error``). The listing's keyset
|
||||
cursors are MACed with a second key, so a forged position never reaches the
|
||||
sort code.
|
||||
|
||||
Both keys used to be drawn per process, so every ComfyUI restart made every
|
||||
open workspace tab stale. They now derive from one random secret persisted in
|
||||
``workspace-scope.key`` beside the database (never in ``config.json`` and never
|
||||
in the database, so a backup or an export does not carry it). When the folder
|
||||
cannot hold it, the process falls back to a per-process secret, as before.
|
||||
|
||||
Standard library only: a fresh process can derive the same scope without
|
||||
importing the API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
logger = logging.getLogger("prompt_manager.api")
|
||||
|
||||
KEY_FILE_NAME = "workspace-scope.key"
|
||||
SECRET_BYTES = 32
|
||||
_HEX = re.compile(r"[0-9a-fA-F]{64}")
|
||||
# Bounded read: a valid file is 64 hex characters and a newline.
|
||||
_MAX_FILE_BYTES = 128
|
||||
_ROOT_SCOPE_PURPOSE = b"prompt-manager/gallery-root-scope/v1"
|
||||
_CURSOR_PURPOSE = b"prompt-manager/workspace-cursor/v1"
|
||||
_SCOPE_TAG = "prompt-manager-gallery-root-v1"
|
||||
_TEMP_PREFIX = f".{KEY_FILE_NAME}."
|
||||
_TEMP_SUFFIX = ".tmp"
|
||||
_STALE_TEMP_SECONDS = 60
|
||||
|
||||
|
||||
class WorkspaceKeys(NamedTuple):
|
||||
"""The derived keys; ``persisted`` is False for a per-process fallback."""
|
||||
|
||||
root_scope: bytes
|
||||
cursor: bytes
|
||||
persisted: bool
|
||||
|
||||
|
||||
def _read(path: str) -> bytes | None:
|
||||
"""The secret in *path*, None when it is missing or not 64 hex characters.
|
||||
|
||||
Raises OSError for anything but a missing file (a folder, no permission).
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
raw = handle.read(_MAX_FILE_BYTES + 1)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("ascii").strip()
|
||||
except UnicodeDecodeError:
|
||||
return b""
|
||||
return bytes.fromhex(text) if _HEX.fullmatch(text) else b""
|
||||
|
||||
|
||||
def _write_exclusive(path: str, secret: bytes) -> None:
|
||||
"""Create *path* (it must not exist) holding *secret*, owner-only, fsynced."""
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write((secret.hex() + "\n").encode("ascii"))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except BaseException:
|
||||
_unlink(path)
|
||||
raise
|
||||
|
||||
|
||||
def _unlink(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _sweep_temps(directory: str) -> None:
|
||||
"""Remove temp files a crashed first start left (older than a minute)."""
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return
|
||||
cutoff = time.time() - _STALE_TEMP_SECONDS
|
||||
for name in names:
|
||||
if name.startswith(_TEMP_PREFIX) and name.endswith(_TEMP_SUFFIX):
|
||||
path = os.path.join(directory, name)
|
||||
try:
|
||||
if os.stat(path).st_mtime < cutoff:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _tighten(path: str) -> None:
|
||||
"""On POSIX an existing key readable by group or others becomes 0o600."""
|
||||
if os.name != "posix":
|
||||
return # the file inherits the folder's ACL, as the database does
|
||||
try:
|
||||
if stat.S_IMODE(os.stat(path).st_mode) & 0o077:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _store(directory: str, path: str, secret: bytes, *, replace: bool) -> None:
|
||||
"""Publish *secret* at *path*; a new file never overwrites a concurrent winner.
|
||||
|
||||
The secret is written to a temp file first. ``os.link`` then fails when
|
||||
*path* exists, so of two first starts exactly one file wins
|
||||
(FileExistsError for the other). A file system without hard links (FAT,
|
||||
some shares) creates *path* with ``O_EXCL`` instead. *replace* (a corrupt
|
||||
key) swaps the file with ``os.replace``; Windows refuses that while
|
||||
another process holds the file open, which the caller treats as unusable.
|
||||
"""
|
||||
temp = os.path.join(
|
||||
directory, f"{_TEMP_PREFIX}{secrets.token_hex(8)}{_TEMP_SUFFIX}"
|
||||
)
|
||||
_write_exclusive(temp, secret)
|
||||
try:
|
||||
if replace:
|
||||
os.replace(temp, path)
|
||||
return
|
||||
try:
|
||||
os.link(temp, path)
|
||||
except FileExistsError:
|
||||
raise
|
||||
except OSError:
|
||||
# Not atomic: a second first start reading mid-write would see a
|
||||
# corrupt key. Rare (two first starts on FAT or a share at once);
|
||||
# each process still uses the file it reads back.
|
||||
_write_exclusive(path, secret)
|
||||
finally:
|
||||
_unlink(temp)
|
||||
|
||||
|
||||
def load_install_secret(directory: str) -> bytes | None:
|
||||
"""The persisted secret of *directory*, created on first use; None if unusable.
|
||||
|
||||
After any write the file is read back and its content is what counts, so
|
||||
processes racing on a first start end with the one secret on disk.
|
||||
"""
|
||||
path = os.path.join(directory, KEY_FILE_NAME)
|
||||
try:
|
||||
_sweep_temps(directory)
|
||||
current = _read(path)
|
||||
if current:
|
||||
_tighten(path)
|
||||
return current
|
||||
corrupt = current is not None
|
||||
try:
|
||||
_store(directory, path, secrets.token_bytes(SECRET_BYTES), replace=corrupt)
|
||||
except FileExistsError:
|
||||
pass # another process created it first: its file counts
|
||||
stored = _read(path)
|
||||
if not stored:
|
||||
raise OSError(f"{KEY_FILE_NAME} is not readable after it was written")
|
||||
if corrupt:
|
||||
logger.warning(
|
||||
f"{KEY_FILE_NAME} was not a valid key and was replaced; "
|
||||
"open workspace pages must refresh once"
|
||||
)
|
||||
return stored
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not use {KEY_FILE_NAME}: {exc.__class__.__name__}")
|
||||
return None
|
||||
|
||||
|
||||
def _derive(secret: bytes, purpose: bytes) -> bytes:
|
||||
return hmac.new(secret, purpose, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def workspace_keys_for(db_path: str | None) -> WorkspaceKeys:
|
||||
"""The keys for the library whose database is *db_path* (blocking I/O)."""
|
||||
secret = None
|
||||
if isinstance(db_path, str) and db_path.strip() and db_path != ":memory:":
|
||||
secret = load_install_secret(os.path.dirname(os.path.abspath(db_path)))
|
||||
persisted = secret is not None
|
||||
if not persisted:
|
||||
logger.warning(
|
||||
"Workspace scopes use a per-process key: open workspace pages must "
|
||||
"refresh after a restart"
|
||||
)
|
||||
secret = secrets.token_bytes(SECRET_BYTES)
|
||||
return WorkspaceKeys(
|
||||
_derive(secret, _ROOT_SCOPE_PURPOSE),
|
||||
_derive(secret, _CURSOR_PURPOSE),
|
||||
persisted,
|
||||
)
|
||||
|
||||
|
||||
def gallery_root_scope(keys: WorkspaceKeys, canonical_root: str, index: int) -> str:
|
||||
"""The 64-hex scope of the root at *index* (its canonical path string)."""
|
||||
payload = json.dumps(
|
||||
[_SCOPE_TAG, index, canonical_root], separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
return hmac.new(keys.root_scope, payload, hashlib.sha256).hexdigest()
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`.
|
||||
*
|
||||
* - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists
|
||||
* each collection as `#/collection/<id>` with its listed-member count, without the concept's "PROPOSED" badge.
|
||||
* - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved
|
||||
* views' Edit dialog (D51) with collection nouns.
|
||||
* - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing
|
||||
* string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things.
|
||||
*/
|
||||
import { noun } from './plural.mjs';
|
||||
import { openEditDialog } from './saved-views.mjs';
|
||||
|
||||
export const COLLECTIONS_URL = '/prompt_manager/collections';
|
||||
export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`;
|
||||
export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`;
|
||||
export const MAX_COLLECTION_NAME_LENGTH = 100;
|
||||
// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES).
|
||||
export const MAX_COLLECTION_ITEMS = 500;
|
||||
// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1).
|
||||
export const COLLECTION_COUNT_DELAY_MS = 300;
|
||||
|
||||
// Strings (design §7.4 and §8).
|
||||
export const ADD_DIALOG_TITLE = 'Add selected images to collection';
|
||||
export const ADD_DIALOG_LABEL = 'Collection name';
|
||||
export const ADD_DIALOG_ACTION = 'Add';
|
||||
export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`;
|
||||
export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.';
|
||||
export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.';
|
||||
export const ADD_FAILED_TOAST = 'The images could not be added. Try again.';
|
||||
export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.';
|
||||
export const COLLECTION_GONE_TOAST = 'That collection no longer exists.';
|
||||
export const RENAMED_TOAST = 'Collection renamed.';
|
||||
export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.';
|
||||
export const DELETED_TOAST = 'Collection deleted.';
|
||||
export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.';
|
||||
export const DELETED_PAGE_TEXT = 'This collection no longer exists.';
|
||||
export const OPEN_IMAGES_LINK = 'Open Images';
|
||||
export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.';
|
||||
export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.';
|
||||
export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.';
|
||||
export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.';
|
||||
export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…';
|
||||
export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.';
|
||||
export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999;
|
||||
|
||||
/** The route of one collection. */
|
||||
export function collectionHash(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return `#/collection/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters
|
||||
* without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse.
|
||||
*/
|
||||
export function normalizeCollectionName(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const name = value.trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */
|
||||
export function parseCollection(row) {
|
||||
if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
const counted = isCount(row.count) && isCount(row.missing);
|
||||
return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null };
|
||||
}
|
||||
|
||||
/** The listing's collections in the server's order (name, then id), skipping unusable rows. */
|
||||
export function parseCollections(data) {
|
||||
if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections');
|
||||
return data.collections.map(parseCollection).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
|
||||
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
|
||||
const ordered = list => [...list].sort((a, b) => {
|
||||
const left = nameKey(a.name), right = nameKey(b.name);
|
||||
return left < right ? -1 : left > right ? 1 : a.id - b.id;
|
||||
});
|
||||
|
||||
/** "Added N images to “X”." with what was already in it and what could not be found. */
|
||||
export function addedMessage({ collection, added, already, notFound }) {
|
||||
if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`;
|
||||
let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`;
|
||||
if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`;
|
||||
if (notFound > 0) text += ` ${notFound} could not be found.`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function removedMessage(name, removed) {
|
||||
return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`;
|
||||
}
|
||||
|
||||
export function nameTakenMessage(name) {
|
||||
return `A collection named “${name}” already exists.`;
|
||||
}
|
||||
|
||||
/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */
|
||||
export function deleteQuestion({ name, count, missing }) {
|
||||
if (!isCount(count) || !isCount(missing)) {
|
||||
return `Delete “${name}”? Its images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
const stored = count + missing;
|
||||
return stored === 1
|
||||
? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.`
|
||||
: `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
|
||||
/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */
|
||||
export function missingNote(missing) {
|
||||
if (!isCount(missing) || missing === 0) return '';
|
||||
return missing === 1
|
||||
? '1 image in this collection is not in your current sources.'
|
||||
: `${missing} images in this collection are not in your current sources.`;
|
||||
}
|
||||
|
||||
export const isCollectionGone = error => error?.code === 'collection-not-found';
|
||||
|
||||
/** A failed add or remove's toast: the routes' error codes, else `fallback`. */
|
||||
export function writeFailure(error, fallback) {
|
||||
if (isCollectionGone(error)) return COLLECTION_GONE_TOAST;
|
||||
if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST;
|
||||
if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */
|
||||
export function renameFailure(name) {
|
||||
return error => {
|
||||
if (error?.code === 'name-taken') return { field: nameTakenMessage(name) };
|
||||
if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true };
|
||||
return { toast: RENAME_FAILED_TOAST };
|
||||
};
|
||||
}
|
||||
|
||||
/** The collection nouns of the shared Edit dialog (D51, D137). */
|
||||
export const COLLECTION_NOUNS = Object.freeze({
|
||||
title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion,
|
||||
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
|
||||
});
|
||||
|
||||
/**
|
||||
* D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open;
|
||||
* a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized
|
||||
* name; a delete's 404 counts as deleted (the store's `delete`).
|
||||
*/
|
||||
export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) {
|
||||
let attempted = '';
|
||||
const mapping = error => {
|
||||
const outcome = renameFailure(attempted)(error);
|
||||
if (outcome.close) queueMicrotask(onGone);
|
||||
return outcome;
|
||||
};
|
||||
openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping,
|
||||
onRename: name => {
|
||||
attempted = normalizeCollectionName(name) ?? name;
|
||||
return onRename(attempted);
|
||||
} });
|
||||
}
|
||||
|
||||
function fileTargets(items) {
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) {
|
||||
throw new TypeError('A collection change needs 1 to 500 files');
|
||||
}
|
||||
return items.map(item => {
|
||||
if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' ||
|
||||
!item.root_scope || typeof item.path !== 'string' || !item.path) {
|
||||
throw new TypeError('Invalid collection file');
|
||||
}
|
||||
return { root_index: item.root_index, root_scope: item.root_scope, path: item.path };
|
||||
});
|
||||
}
|
||||
|
||||
function requireId(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return id;
|
||||
}
|
||||
|
||||
function requireName(name) {
|
||||
const normalized = normalizeCollectionName(name);
|
||||
if (normalized === null) throw new TypeError('Invalid collection name');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** The `{id, name}` a write answered with. */
|
||||
function answered(data) {
|
||||
const collection = parseCollection(data?.collection);
|
||||
if (!collection) throw new TypeError('Invalid collection');
|
||||
return { id: collection.id, name: collection.name };
|
||||
}
|
||||
|
||||
function countOf(value) {
|
||||
if (!isCount(value)) throw new TypeError('Invalid collection count');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports
|
||||
* `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading',
|
||||
* 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed
|
||||
* rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is
|
||||
* gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam.
|
||||
*/
|
||||
export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
||||
let collections = [], status = 'loading', request = null, timer = 0;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { collections, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; };
|
||||
signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true });
|
||||
function load() {
|
||||
if (signal?.aborted) return Promise.resolve();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
collections = parseCollections(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
function changed(next) {
|
||||
collections = ordered(next);
|
||||
emit();
|
||||
stopTimer();
|
||||
timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS);
|
||||
}
|
||||
/** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */
|
||||
async function send(write) {
|
||||
try {
|
||||
return await write();
|
||||
} catch (error) {
|
||||
if (isCollectionGone(error)) load();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function add(target, items) {
|
||||
const byId = target?.collectionId !== undefined, byName = target?.name !== undefined;
|
||||
if (byId === byName) throw new TypeError('A collection is named by its id or a name');
|
||||
const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) };
|
||||
body.items = fileTargets(items);
|
||||
const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal }));
|
||||
const result = { collection: answered(data), created: data.created === true, added: countOf(data.added),
|
||||
already: countOf(data.already), notFound: countOf(data.not_found) };
|
||||
const { id, name } = result.collection;
|
||||
changed(collections.some(item => item.id === id)
|
||||
? collections.map(item => item.id === id ? { ...item, name } : item)
|
||||
: [...collections, { id, name, count: null, missing: null }]);
|
||||
return result;
|
||||
}
|
||||
async function remove(id, items) {
|
||||
const body = { collection_id: requireId(id), items: fileTargets(items) };
|
||||
const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body }));
|
||||
const result = { collection: answered(data), removed: countOf(data.removed) };
|
||||
changed(collections);
|
||||
return result;
|
||||
}
|
||||
async function membership(identity) {
|
||||
const [file] = fileTargets([identity]);
|
||||
const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path });
|
||||
const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' });
|
||||
const ids = data?.collection_ids;
|
||||
if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership');
|
||||
return [...ids];
|
||||
}
|
||||
async function rename(id, name) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
const body = { name: requireName(name) };
|
||||
const renamed = answered(await send(() => api.patch(url, body, { signal })));
|
||||
changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item));
|
||||
return renamed;
|
||||
}
|
||||
async function deleteCollection(id) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
try {
|
||||
await api.del(url, { signal });
|
||||
} catch (error) {
|
||||
// Already gone (another tab deleted it): the outcome the user asked for.
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
changed(collections.filter(item => item.id !== id));
|
||||
return id;
|
||||
}
|
||||
return {
|
||||
load, add, remove, membership, rename, delete: deleteCollection,
|
||||
get collections() { return collections; },
|
||||
get status() { return status; },
|
||||
find: id => collections.find(item => item.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -34,9 +34,13 @@ const MAX_SUGGESTIONS = 100;
|
||||
const MAX_INSTRUCTION_CHARS = 4000;
|
||||
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
|
||||
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
|
||||
const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
|
||||
const REOBSERVE = new Set(Object.values(REOPEN));
|
||||
const REOBSERVE = new Set(['storage-changed', 'file-changed', 'capture-expired']);
|
||||
// #430: a changed source (the page's root scope no longer names its root) is never retried as is: the page re-scopes
|
||||
// the target (`retarget`) or the dialog offers a refresh.
|
||||
const REOPEN = Object.freeze({...Object.fromEntries([...REOBSERVE].map(code => [code, code])), 'source-changed': 'source-changed'});
|
||||
const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
|
||||
// States whose reason blocks Save (and Generate): the dialog shows it beside its actions (D147).
|
||||
const BLOCKED = new Set(['observe-error', 'source-changed', 'unknown-outcome', ...REOBSERVE]);
|
||||
// Generic failure envelopes whose fixed sentence the dialog words itself.
|
||||
const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
@@ -269,6 +273,7 @@ function derive(state) {
|
||||
canSave: editable,
|
||||
canGenerate: editable && state.generateSupported,
|
||||
canRetry: RETRYABLE.has(state.phase),
|
||||
blocked: BLOCKED.has(state.phase),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,7 +296,7 @@ function validatePreset(preset) {
|
||||
*/
|
||||
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
|
||||
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
|
||||
const imageTarget = validateTarget(target);
|
||||
let imageTarget = validateTarget(target);
|
||||
requireValue(SOURCES.has(source), 'Unknown tagging source');
|
||||
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
|
||||
const chosen = validatePreset(preset);
|
||||
@@ -364,6 +369,10 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed || serial !== openSerial) return;
|
||||
if (isApiError(error) && error.code === 'source-changed') {
|
||||
set({phase: 'source-changed', statusText: error.message});
|
||||
return;
|
||||
}
|
||||
set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
|
||||
return;
|
||||
}
|
||||
@@ -531,6 +540,16 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
return send(lastBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* #430: the page found the file under a fresh `target` (its sources were renumbered): observe it there, keeping the
|
||||
* typed draft. Refused (false) while a save is in flight or after close; a Generate for the old target is dropped.
|
||||
*/
|
||||
function retarget(next) {
|
||||
if (closed || saving) return Promise.resolve(false);
|
||||
imageTarget = validateTarget(next);
|
||||
return open({keepDraft: observedOnce || Boolean(state.manual)}).then(() => true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (closed) return;
|
||||
set({closed: true});
|
||||
@@ -556,7 +575,8 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
|
||||
return {
|
||||
get state() {return state;},
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
|
||||
get target() {return imageTarget;},
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ import { showWorkspaceToast } from './toast.mjs';
|
||||
import { cardTitle } from './images-state.mjs';
|
||||
|
||||
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
|
||||
// #430: while the page looks for a renumbered source's file, and the preview that a refused capture leaves.
|
||||
const FINDING_SOURCE = 'The source of this image changed. Finding it in the refreshed image collection…';
|
||||
const PREVIEW_UNAVAILABLE = 'Preview unavailable';
|
||||
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
|
||||
// The option inputs per controller key; the classifier keys only exist for WD14 models.
|
||||
const OPTION_IDS = Object.freeze({
|
||||
@@ -194,6 +197,13 @@ function previewOf(image, state) {
|
||||
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
|
||||
return node;
|
||||
}
|
||||
/** D147: the preview box a failed or refused capture leaves, saying so (the reason is beside the actions). */
|
||||
function unavailablePreview() {
|
||||
const node = el('div', PREVIEW_UNAVAILABLE, 'tag-image-preview tag-preview-empty');
|
||||
node.setAttribute('role', 'img');
|
||||
node.setAttribute('aria-label', PREVIEW_UNAVAILABLE);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
|
||||
@@ -207,7 +217,8 @@ function previewOf(image, state) {
|
||||
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
|
||||
*/
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
|
||||
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
|
||||
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null, onSourceChanged = null,
|
||||
onRefresh = null}) {
|
||||
const target = imageTarget(image);
|
||||
if (!target) throw new TypeError('The image has no tagging target');
|
||||
const review = mode === 'review';
|
||||
@@ -216,7 +227,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
|
||||
let advancing = false;
|
||||
const saved = ({filename: name, tags, toast}) => {
|
||||
const event = {target, filename: name, tags, source};
|
||||
// A retargeted dialog (#430) reports the file under its current identity.
|
||||
const event = {target: {...controller.target}, filename: name, tags, source};
|
||||
announceSaved(event);
|
||||
if (toast) showWorkspaceToast(toast);
|
||||
onSaved?.(event);
|
||||
@@ -280,6 +292,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
: (last ? 'Save and finish' : 'Save and next'));
|
||||
if (review) actions.append(cancel, skip, primary);
|
||||
else actions.append(cancel, primary);
|
||||
// D147: the reason a state blocks Save (and Generate), on its own row above the buttons; for a changed source the
|
||||
// page is asked to find the file again (`onSourceChanged`), else Refresh is offered.
|
||||
const notice = el('div', undefined, 'tag-action-notice');
|
||||
const noticeText = el('span');
|
||||
noticeText.setAttribute('role', 'status');
|
||||
const refresh = createButton(onRefresh ? 'Refresh image collection' : 'Reload page');
|
||||
notice.append(noticeText);
|
||||
const tried = new Set();
|
||||
let finding = false, current = image;
|
||||
|
||||
const nodes = [heading];
|
||||
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
|
||||
@@ -334,8 +355,45 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
closeWorkspaceDialog();
|
||||
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
|
||||
}
|
||||
/** #430: ask the page once per target where a renumbered source's file is now; a found file is retargeted. */
|
||||
function findSource() {
|
||||
const {root_index: index, root_scope: held} = controller.target;
|
||||
const attempt = `${index}:${held}`;
|
||||
if (!onSourceChanged || tried.has(attempt)) return;
|
||||
tried.add(attempt);
|
||||
finding = true;
|
||||
Promise.resolve().then(() => onSourceChanged({...current, ...controller.target})).catch(() => null).then(fresh => {
|
||||
finding = false;
|
||||
if (!open || completed) return;
|
||||
const next = imageTarget(fresh);
|
||||
if (next && controller.state.phase === 'source-changed') {
|
||||
current = fresh;
|
||||
controller.retarget(next);
|
||||
} else render(controller.state);
|
||||
});
|
||||
}
|
||||
function renderNotice(state) {
|
||||
const changed = state.phase === 'source-changed';
|
||||
const text = !state.blocked ? '' : changed && finding ? FINDING_SOURCE : state.statusText;
|
||||
setText(noticeText, text);
|
||||
const offer = changed && !finding;
|
||||
if (offer !== refresh.isConnected) {
|
||||
if (offer) notice.append(refresh);
|
||||
else refresh.remove();
|
||||
}
|
||||
// Its own first row; Retry still leads the buttons after it (D116).
|
||||
if (text && actions.firstElementChild !== notice) actions.prepend(notice);
|
||||
else if (!text && notice.isConnected) notice.remove();
|
||||
// The empty preview of a refused or failed capture says why it is empty.
|
||||
if (state.blocked && !state.image && preview.tagName !== 'DIV') {
|
||||
const next = unavailablePreview();
|
||||
preview.replaceWith(next);
|
||||
preview = next;
|
||||
}
|
||||
}
|
||||
function render(state) {
|
||||
if (!open || completed) return;
|
||||
if (state.phase === 'source-changed' && !finding) findSource();
|
||||
if (state.image && state.image !== shownImage) renderImage(state.image);
|
||||
// The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
|
||||
if (state.draftRevision !== shownDraft) {
|
||||
@@ -344,7 +402,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
}
|
||||
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
|
||||
setText(count, state.countText);
|
||||
setText(status, state.statusText);
|
||||
// A blocking reason is shown once, beside the actions (D147).
|
||||
setText(status, state.blocked ? '' : state.statusText);
|
||||
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
|
||||
setDisabled(primary, !state.canSave);
|
||||
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
|
||||
@@ -356,10 +415,16 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
retry.remove();
|
||||
}
|
||||
}
|
||||
renderNotice(state);
|
||||
if (state.phase === 'saved') complete(state);
|
||||
}
|
||||
|
||||
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
|
||||
refresh.addEventListener('click', () => {
|
||||
closeWorkspaceDialog();
|
||||
if (onRefresh) onRefresh();
|
||||
else window.location.reload();
|
||||
});
|
||||
skip.addEventListener('click', () => {
|
||||
if (!open || advancing || disabled(skip)) return;
|
||||
advancing = true;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Finding a file again after its source changed (#430).
|
||||
*
|
||||
* A listed file is addressed as `{root_index, root_scope, path}`. When the sources are renumbered every per-file
|
||||
* action on it answers 409 `source-changed`. `rescopeImage` asks the server (POST /prompt_manager/images/rescope)
|
||||
* for the file's current listing descriptor: the server maps the target only when its scope was issued for one of
|
||||
* the current roots, so a removed or replaced source (or a changed key) answers null and the page offers Refresh.
|
||||
*
|
||||
* `createSourceRecovery` is the page's single flight: one rescope per held `(key, scope)` pair, its result shared
|
||||
* by every caller (the inspector, the viewer and the Tag image dialog may all ask for the same file), and a pair
|
||||
* already tried answers its first result, so a refusal can never loop. `apply(image, fresh, started)` is the page's
|
||||
* part (refresh the collection, move the selection); `started` is the page generation when the rescope began, so a
|
||||
* page whose filters changed meanwhile can leave itself alone. `reset()` (a manual Refresh) forgets every pair.
|
||||
*/
|
||||
import { imageKey } from './images-state.mjs';
|
||||
|
||||
export const RESCOPE_URL = '/prompt_manager/images/rescope';
|
||||
// A page holds a handful of stale files at once; older pairs are forgotten first.
|
||||
const MEMO_LIMIT = 64;
|
||||
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||||
|
||||
function validTarget(value) {
|
||||
return value !== null && typeof value === 'object' && Number.isSafeInteger(value.root_index) && value.root_index >= 0 &&
|
||||
hex64(value.root_scope) && typeof value.path === 'string' && value.path.length > 0;
|
||||
}
|
||||
|
||||
/** The fresh descriptor of `image` (same path, current root and scope), else null. */
|
||||
export async function rescopeImage(api, image, {signal} = {}) {
|
||||
if (!validTarget(image)) return null;
|
||||
let value;
|
||||
try {
|
||||
value = await api.post(RESCOPE_URL, {target: {root_index: image.root_index, root_scope: image.root_scope, path: image.path}},
|
||||
{signal});
|
||||
} catch (error) {
|
||||
// 409 (the source is gone) or 404 (the file is gone): final, the caller offers Refresh. Anything else (no
|
||||
// answer, a 5xx) is rethrown so the pair may be asked again.
|
||||
if (error?.status === 404 || error?.status === 409) return null;
|
||||
throw error;
|
||||
}
|
||||
const fresh = value?.success === true ? value.image : null;
|
||||
if (!validTarget(fresh) || fresh.path !== image.path) return null;
|
||||
return Object.freeze({...fresh});
|
||||
}
|
||||
|
||||
export function createSourceRecovery({api, signal, apply, generation = () => 0}) {
|
||||
// An embedded gallery's own page adapter may have no `post`: its rescope then simply finds nothing.
|
||||
if (typeof apply !== 'function') throw new TypeError('createSourceRecovery needs apply');
|
||||
const memo = new Map();
|
||||
function recover(image) {
|
||||
if (!validTarget(image)) return Promise.resolve(null);
|
||||
const pair = `${imageKey(image)}|${image.root_scope}`;
|
||||
if (memo.has(pair)) return memo.get(pair);
|
||||
const started = generation();
|
||||
const pending = rescopeImage(api, image, {signal})
|
||||
.then(fresh => (fresh && !signal?.aborted ? apply(image, fresh, started) : null))
|
||||
.then(result => result ?? null, () => {
|
||||
if (memo.get(pair) === pending) memo.delete(pair);
|
||||
return null;
|
||||
});
|
||||
memo.set(pair, pending);
|
||||
if (memo.size > MEMO_LIMIT) memo.delete(memo.keys().next().value);
|
||||
return pending;
|
||||
}
|
||||
return {recover, reset: () => memo.clear()};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Images page's `apply` (#430). Keys are `root_index:path`, so after a renumbering nothing from the old list is
|
||||
* kept by key (a checked or selected key could name a same-named file in another source): the viewer closes, the
|
||||
* checks clear, a Source filter on the old index follows the file, and only the file itself stays selected while the
|
||||
* collection lists again. An unchanged numbering re-reads the loaded range in place. `page` holds the page's hooks.
|
||||
*/
|
||||
export function recoveredSource(page) {
|
||||
return async (image, fresh, started) => {
|
||||
// The held scope still names its root: nothing is stale (a media error had another cause).
|
||||
if (fresh.root_index === image.root_index && fresh.root_scope === image.root_scope) return null;
|
||||
if (!page.current(started)) return fresh;
|
||||
const selected = page.selected(image);
|
||||
if (fresh.root_index === image.root_index) {
|
||||
if (selected) page.inspect(fresh);
|
||||
await page.stream.refresh().catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
page.closeViewer();
|
||||
page.clearChecks();
|
||||
const {filters} = page;
|
||||
if (filters.root === String(image.root_index)) filters.root = String(fresh.root_index);
|
||||
else if (filters.root !== 'all') { filters.root = 'all'; filters.subfolder = ''; }
|
||||
if (selected) page.inspect(fresh);
|
||||
else page.dropSelection();
|
||||
page.updateScope();
|
||||
page.stream.reset(filters, {load:page.listsNow()});
|
||||
page.rediscover();
|
||||
return fresh;
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,14 @@ function queryFor(filters, batchSize) {
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
// A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites.
|
||||
const collection = filters.collection ?? '';
|
||||
if (collection !== '') {
|
||||
if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) {
|
||||
throw new TypeError('Invalid collection filter');
|
||||
}
|
||||
query.set('collection', String(collection));
|
||||
}
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ export const REFRESH_FAILED = 'Images could not be refreshed after the tag save.
|
||||
|
||||
/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
|
||||
* `image-tag` does); false for any other control. */
|
||||
export function openCardTagging({api, scope, image, action, embedded}) {
|
||||
export function openCardTagging({api, scope, image, action, embedded, onSourceChanged = null, onRefresh = null}) {
|
||||
if (!isTagButton(action)) return false;
|
||||
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
|
||||
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action, onSourceChanged, onRefresh});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+66
-15
@@ -16,11 +16,13 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs';
|
||||
import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
|
||||
import { createSourceRecovery, recoveredSource } from './images-source-recovery.mjs';
|
||||
export { onFavoriteChange } from './images-favorites.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
@@ -64,7 +66,10 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name',
|
||||
['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.';
|
||||
// The concept's empty collection (app.js updateImages).
|
||||
const NO_MATCHES = 'No matching images. Try another search or category.';
|
||||
const RETRY_REFRESH = 'Refresh image collection to retry.';
|
||||
const CURSOR_STALE = 'The image collection changed. Refresh image collection to continue.';
|
||||
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
|
||||
function loadFailure(sourcesRetry, categoriesFailed) {
|
||||
if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
|
||||
@@ -108,10 +113,13 @@ export function selectedImage() {
|
||||
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
|
||||
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
|
||||
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
|
||||
* Images last visit.
|
||||
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
|
||||
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
|
||||
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
|
||||
* deleted collection, D140) the page lists nothing and says so.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
favorites = false, onSaveView = () => {}, activity = null} = {}) {
|
||||
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -122,15 +130,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
|
||||
const preferences = displayPreferences();
|
||||
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
// The panel's listed file: a changed source is looked up once, the panel's Refresh stays the fallback (#430).
|
||||
let selectedImage = null;
|
||||
scope.cleanup(inspection.subscribe(next => {
|
||||
detailInspector?.setState(next);
|
||||
if (next.refreshRequired && next.code === 'source-changed' && selectedImage && imageKey(selectedImage) === selection) {
|
||||
recovery.recover(selectedImage);
|
||||
}
|
||||
}));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
|
||||
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
|
||||
const handed = !embedded && !favorites && !savedState ? browseState : null;
|
||||
if (!embedded && !favorites) browseState = null;
|
||||
const imagesPage = !embedded && !favorites && collectionId === null;
|
||||
const handed = imagesPage && !savedState ? browseState : null;
|
||||
if (imagesPage) browseState = null;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const prior = embedded || saved || favorites || handed ? null : lastVisit;
|
||||
// The open collection: the route's, else a saved view's (D140); '' on every other page.
|
||||
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
|
||||
const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
@@ -150,7 +168,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
|
||||
...(handed ? {category: handed.category} : {}),
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
// A collection and favorites are never both the filter (DC10).
|
||||
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
|
||||
@@ -161,7 +180,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
|
||||
// Images return state (#249 D51, and M2 for saved views).
|
||||
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
@@ -256,7 +275,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
|
||||
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
|
||||
const showsEmpty = !embedded || typeof emptyText === 'string';
|
||||
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
|
||||
const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty');
|
||||
empty.hidden = true;
|
||||
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
|
||||
if (!embedded) collection.append(caption);
|
||||
@@ -288,7 +307,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
main.append(body);
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
|
||||
}});
|
||||
@@ -300,6 +319,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
function inspect(image) {
|
||||
detailInspector?.dispose();
|
||||
selection = imageKey(image);
|
||||
selectedImage = image;
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
|
||||
@@ -340,7 +360,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const action = event.target.closest('.image-card-actions button');
|
||||
if (event.target.closest('.star-button')) toggleFavorite(api, image);
|
||||
else if (event.target.closest('.image-open')) previewImage(image);
|
||||
else if (action && !openCardTagging({api, scope, image, action, embedded})) inspect(image);
|
||||
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
|
||||
onRefresh:refreshInspectionCollection})) inspect(image);
|
||||
});
|
||||
|
||||
function renderVisible() {
|
||||
@@ -478,8 +499,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
|
||||
});
|
||||
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
|
||||
/**
|
||||
* The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection
|
||||
* page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry,
|
||||
* so a page whose shell cannot confirm the deletion is never left blank.
|
||||
*/
|
||||
const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error);
|
||||
let reportedMissing = null;
|
||||
function continuationText(next) {
|
||||
if (recovering) return 'Images paused until sources and folders are available.';
|
||||
if (collectionGone(next)) return '';
|
||||
if (next.error?.code === 'cursor-stale') return CURSOR_STALE;
|
||||
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
|
||||
if (next.loading && !next.loaded) return 'Loading images…';
|
||||
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
|
||||
@@ -513,12 +543,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const added = next.loaded - previous.loaded;
|
||||
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
|
||||
}
|
||||
const gone = collectionGone(next);
|
||||
setText(message, continuationText(next));
|
||||
setAttr(message, 'role', next.error ? 'alert' : 'status');
|
||||
retry.hidden = !next.error;
|
||||
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
|
||||
retry.hidden = !next.error || gone;
|
||||
// A cursor the server can no longer verify is never resent: Retry becomes the collection refresh (#430).
|
||||
setText(retry, next.error?.code === 'cursor-stale' ? 'Refresh image collection' : 'Retry');
|
||||
more.hidden = !next.hasMore || !!next.error;
|
||||
more.setAttribute('aria-disabled', String(next.loading || recovering));
|
||||
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
|
||||
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
|
||||
reportedMissing = next.error;
|
||||
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
|
||||
}
|
||||
if (showsEmpty) {
|
||||
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
|
||||
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
|
||||
}
|
||||
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
|
||||
if (!selection && next.items.length) inspect(next.items[0]);
|
||||
scheduleLoad();
|
||||
@@ -553,7 +593,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
}
|
||||
scope.listen(more, 'click', pressLoad);
|
||||
scope.listen(retry, 'click', pressLoad);
|
||||
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
|
||||
function maybeLoad() {
|
||||
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
@@ -581,8 +621,18 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
details.replaceChildren(noSelection());
|
||||
}
|
||||
selection = null;
|
||||
selectedImage = null;
|
||||
}
|
||||
// Bumped by every reset: a source recovery that began before it leaves the page alone (#430).
|
||||
let generation = 0;
|
||||
const recovery = createSourceRecovery({api, signal:scope.signal, generation:() => generation, apply:recoveredSource({
|
||||
selected:image => selection === imageKey(image), inspect, dropSelection, stream, filters, updateScope,
|
||||
closeViewer:() => { mediaViewer.stopSlideshow(); main.querySelector('#viewer-dialog[open]')?.close(); },
|
||||
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
|
||||
current:started => !scope.signal.aborted && started === generation,
|
||||
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
|
||||
function reset() {
|
||||
generation += 1;
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('#viewer-dialog[open]')?.close();
|
||||
dropSelection({keepPanel:recovering});
|
||||
@@ -686,6 +736,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
async function refreshInspectionCollection() {
|
||||
if (scope.signal.aborted) return;
|
||||
recovery.reset();
|
||||
// Numeric roots may now name another source; retire the old stream first.
|
||||
recovering = true;
|
||||
filters.root = 'all';filters.subfolder = '';
|
||||
|
||||
+135
-18
@@ -1,6 +1,6 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo,
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
|
||||
@@ -11,6 +11,8 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
|
||||
LIST_FAILED_TEXT } from './collections.mjs';
|
||||
import { createActivityStore } from './activity-store.mjs';
|
||||
import { mountActivityPage, mountActivityTopbar } from './activity.mjs';
|
||||
import { mountActivityJobView } from './activity-job-view.mjs';
|
||||
@@ -44,6 +46,7 @@ function mountWorkspace() {
|
||||
const main = document.getElementById('workspace-main');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const savedNav = document.getElementById('saved-nav');
|
||||
const collectionsNav = document.getElementById('collections-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
@@ -83,8 +86,9 @@ function mountWorkspace() {
|
||||
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
|
||||
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`, a collection's `collection/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}`
|
||||
: route.id === 'collection' ? `collection/${route.collectionId}` : route.id;
|
||||
let currentKey = null;
|
||||
const markCurrent = () => {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
@@ -110,6 +114,24 @@ function mountWorkspace() {
|
||||
markCurrent();
|
||||
}));
|
||||
savedViews.load();
|
||||
// COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once
|
||||
// read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the
|
||||
// last rows; a collection page says the list could not be loaded.
|
||||
const collectionsLabel = collectionsNav.previousElementSibling;
|
||||
const collections = createCollections(api, scope.signal);
|
||||
scope.cleanup(collections.subscribe(({ collections: listed }) => {
|
||||
collectionsLabel.hidden = listed.length === 0;
|
||||
collectionsNav.replaceChildren(...listed.map(item => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name));
|
||||
if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count'));
|
||||
link.href = collectionHash(item.id);
|
||||
link.dataset.route = `collection/${item.id}`;
|
||||
return link;
|
||||
}));
|
||||
markCurrent();
|
||||
}));
|
||||
collections.load();
|
||||
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
||||
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
||||
onSave: name => savedViews.create(page, name, state) });
|
||||
@@ -167,6 +189,7 @@ function mountWorkspace() {
|
||||
forgetLibraryVisit();
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
savedViews.load();
|
||||
collections.load();
|
||||
if (currentKey !== 'activity') navigateTo(window.location.hash);
|
||||
}));
|
||||
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
|
||||
@@ -213,6 +236,10 @@ function mountWorkspace() {
|
||||
openSavedView(route, view);
|
||||
return;
|
||||
}
|
||||
if (route.id === 'collection') {
|
||||
openCollectionPage(route, view);
|
||||
return;
|
||||
}
|
||||
renderPage(route, view);
|
||||
}
|
||||
|
||||
@@ -223,14 +250,16 @@ function mountWorkspace() {
|
||||
if (view.signal.aborted) return;
|
||||
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
||||
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
||||
await savedViews.load();
|
||||
// settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one.
|
||||
savedViews.load();
|
||||
await savedViews.settled();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
const saved = savedViews.find(route.savedId);
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
mountEditAction(saved, view);
|
||||
mountSavedViewEdit(saved, view);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
@@ -238,7 +267,8 @@ function mountWorkspace() {
|
||||
window.location.replace('#/images');
|
||||
return;
|
||||
}
|
||||
const {description} = renderHeading(main, {title:route.label});
|
||||
const {description, h1} = renderHeading(main, {title:route.label});
|
||||
focusTitle(h1);
|
||||
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
@@ -249,13 +279,19 @@ function mountWorkspace() {
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
|
||||
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
|
||||
function mountEditAction(saved, view) {
|
||||
// /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens
|
||||
// its dialog. Saved views and collections (D137) share it.
|
||||
function mountEditAction(view, open) {
|
||||
// renderPage has just written the heading.
|
||||
const edit = createButton('Edit');
|
||||
pageHeading(main).actions.prepend(edit);
|
||||
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
|
||||
view.listen(edit, 'click', () => open(edit));
|
||||
}
|
||||
|
||||
// A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the
|
||||
// view's plain page.
|
||||
function mountSavedViewEdit(saved, view) {
|
||||
mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved,
|
||||
onRename: async name => {
|
||||
const renamed = await savedViews.rename(saved.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
@@ -268,25 +304,106 @@ function mountWorkspace() {
|
||||
} }));
|
||||
}
|
||||
|
||||
function retitle(name) {
|
||||
/** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */
|
||||
function retitle(name, title = name) {
|
||||
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
|
||||
pageHeading(main).h1.textContent = name;
|
||||
document.getElementById('workspace-breadcrumb').textContent = name;
|
||||
document.title = `${name} · Prompt Manager`;
|
||||
document.getElementById('workspace-breadcrumb').textContent = title;
|
||||
document.title = `${title} · Prompt Manager`;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null) {
|
||||
/**
|
||||
* #331 D137: `#/collection/<id>` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb
|
||||
* and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id
|
||||
* is read once more (another tab may have made it), then shows the deleted-collection page.
|
||||
*/
|
||||
async function openCollectionPage(route, view) {
|
||||
main.replaceChildren();
|
||||
await collections.settled();
|
||||
if (view.signal.aborted) return;
|
||||
if (!collections.find(route.collectionId) && collections.status === 'ready') {
|
||||
// settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one.
|
||||
collections.load();
|
||||
await collections.settled();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
let collection = collections.find(route.collectionId);
|
||||
const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); };
|
||||
if (collection) {
|
||||
renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id,
|
||||
// The listing found the collection gone meanwhile: the list is read again and, once it no longer lists
|
||||
// the collection, the page is shown again (the deleted-collection page). A failed read, or a list that
|
||||
// still holds it, leaves the listing's own error and Retry (images.mjs).
|
||||
onCollectionMissing:async () => {
|
||||
collections.load();
|
||||
await collections.settled();
|
||||
if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain();
|
||||
}});
|
||||
// MC3: the note follows every read of the list for the page's life; none while nothing is missing.
|
||||
const note = element('p', '', 'small');
|
||||
const showNote = () => {
|
||||
const text = missingNote(collections.find(route.collectionId)?.missing);
|
||||
note.textContent = text;
|
||||
if (!text) note.remove();
|
||||
else if (!note.isConnected) pageHeading(main)?.description.after(note);
|
||||
};
|
||||
showNote();
|
||||
view.cleanup(collections.subscribe(showNote));
|
||||
// The dialog's Delete question uses the latest stored count.
|
||||
mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger,
|
||||
collection: collection = {...collection, ...collections.find(collection.id), name:collection.name},
|
||||
onRename: async name => {
|
||||
const renamed = await collections.rename(collection.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
collection = {...collection, ...renamed};
|
||||
retitle(renamed.name, routeTitle(COLLECTION_ROUTE));
|
||||
},
|
||||
onDelete: async () => {
|
||||
await collections.delete(collection.id);
|
||||
window.location.replace('#/images');
|
||||
},
|
||||
onGone: () => collections.settled().then(showAgain) }));
|
||||
return;
|
||||
}
|
||||
const {description, h1} = renderHeading(main, {title:route.label});
|
||||
focusTitle(h1);
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (collections.status === 'ready') {
|
||||
// Deleted, or never existed: say so, with the way back to Images.
|
||||
description.textContent = DELETED_PAGE_TEXT;
|
||||
const link = element('a', OPEN_IMAGES_LINK, 'workspace-link');
|
||||
link.href = '#/images';
|
||||
main.append(link);
|
||||
return;
|
||||
}
|
||||
description.textContent = LIST_FAILED_TEXT;
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', () => {
|
||||
collections.load();
|
||||
openCollectionPage(route, view);
|
||||
});
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
/** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */
|
||||
function focusTitle(h1) {
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) {
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
const { description, h1 } = renderHeading(main, { title: route.label });
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
focusTitle(h1);
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view), activity});
|
||||
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
|
||||
@@ -21,6 +21,7 @@ export class InspectionSourceChangedError extends Error {
|
||||
super(SOURCE_CHANGED);
|
||||
this.name = 'InspectionSourceChangedError';
|
||||
this.refreshRequired = true;
|
||||
this.code = 'source-changed';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter}) {
|
||||
const scope = createScope();
|
||||
let active = null, retryVersion = 0;
|
||||
const dispose = () => scope.dispose();
|
||||
@@ -148,7 +148,8 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
src = url.href;
|
||||
}
|
||||
const node = element(selected.is_video ? 'video' : 'img');
|
||||
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
|
||||
// #430: the page may find the file under a renumbered source (`onMediaError`).
|
||||
mediaLocal.listen(node,'error',() => {if (current()) {session.failed(key);onMediaError?.(selected);}});
|
||||
if (selected.is_video) {
|
||||
node.controls = true;node.playsInline = true;node.preload = 'metadata';
|
||||
const preferences = readViewerPreferences();
|
||||
|
||||
@@ -41,6 +41,8 @@ const FIELDS = Object.freeze({
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
|
||||
// #331 D140: a view saved on a collection page reopens that collection ('' or 1 to 9 digits, no leading zero).
|
||||
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', 'rating'], 'newest'), tags,
|
||||
@@ -54,10 +56,13 @@ export function normalizeViewState(page, state) {
|
||||
const fields = FIELDS[page];
|
||||
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
|
||||
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
|
||||
const normalized = Object.fromEntries(Object.entries(fields).map(([key, field]) => {
|
||||
const value = field.accepts(source[key]) ? source[key] : field.fallback;
|
||||
return [key, Array.isArray(value) ? [...value] : value];
|
||||
}));
|
||||
// Favorites and a collection are never both a view's filter: the collection wins, as the listing does (D140).
|
||||
if (normalized.favorites === true && normalized.collection) normalized.favorites = false;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
|
||||
@@ -96,11 +101,12 @@ export const savedViewHash = id => `#/saved/${id}`;
|
||||
export function imagesFilters(state) {
|
||||
const view = normalizeViewState('images', state);
|
||||
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
|
||||
tags: view.tags, favorites: view.favorites };
|
||||
tags: view.tags, favorites: view.favorites, collection: view.collection };
|
||||
}
|
||||
export function imagesViewState(filters) {
|
||||
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true,
|
||||
collection: filters.collection == null ? '' : String(filters.collection) });
|
||||
}
|
||||
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
@@ -215,14 +221,20 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
|
||||
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
|
||||
* outcome, and the shell navigates after a delete.
|
||||
* D51: an open sidebar item's Edit dialog, shared by saved views and collections (#331 D137) with their own nouns. The
|
||||
* name field (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so
|
||||
* nothing is removed by one press and no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the
|
||||
* store's promise; the toast reports either outcome, and the shell navigates after a delete.
|
||||
*
|
||||
* `nouns`: `title`, `label`, `deleteTitle`, `deleteText(item)`, `renamed`, `renameFailed`, `deleted`, `deleteFailed`.
|
||||
* By default the dialog closes before the rename is sent (saved views). `renameError(error)` instead keeps it open
|
||||
* while the rename runs and maps a failure: `{field: text}` writes the message under the field and keeps the dialog,
|
||||
* `{toast: text, close}` toasts it and closes the dialog only when `close` is true; null is `renameFailed`, kept open.
|
||||
*/
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) {
|
||||
const input = document.createElement('input');
|
||||
input.value = view.name;
|
||||
input.value = item.name;
|
||||
input.maxLength = MAX_NAME_LENGTH;
|
||||
const remove = createButton('Delete…', 'danger');
|
||||
const cancel = createButton('Cancel');
|
||||
@@ -230,28 +242,69 @@ export function openEditViewDialog({ scope, trigger, view, onRename, onDelete })
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
actions.append(remove, cancel, save);
|
||||
const message = document.createElement('p');
|
||||
message.className = 'small';
|
||||
message.setAttribute('role', 'alert');
|
||||
message.hidden = true;
|
||||
const say = text => { message.textContent = text; message.hidden = !text; };
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
save.addEventListener('click', async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
closeWorkspaceDialog();
|
||||
if (name === view.name) return;
|
||||
if (!renameError || name === item.name) closeWorkspaceDialog();
|
||||
if (name === item.name) return;
|
||||
if (!renameError) {
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(nouns.renamed);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(nouns.renameFailed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Kept open: one rename at a time, its outcome shown in place.
|
||||
say('');
|
||||
for (const button of [remove, cancel, save]) button.disabled = true;
|
||||
// Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone.
|
||||
const stillOpen = () => input.isConnected;
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(RENAMED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
|
||||
if (scope.signal.aborted) return;
|
||||
if (stillOpen()) closeWorkspaceDialog();
|
||||
showWorkspaceToast(nouns.renamed);
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
for (const button of [remove, cancel, save]) button.disabled = false;
|
||||
const outcome = renameError(error) ?? { toast: nouns.renameFailed };
|
||||
if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; }
|
||||
if (outcome.field) { say(outcome.field); input.focus(); return; }
|
||||
if (outcome.close) closeWorkspaceDialog();
|
||||
showWorkspaceToast(outcome.toast);
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
|
||||
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, item, nouns, onDelete }));
|
||||
// The message line exists only where a failure keeps the dialog open (the saved views' dialog is unchanged).
|
||||
const nodes = renameError ? [field(nouns.label, input, 'action-input'), message, actions] : [field(nouns.label, input, 'action-input'), actions];
|
||||
openContentDialog({ scope, trigger, title: nouns.title, nodes });
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
/** The saved views' nouns for the shared Edit dialog (D51). */
|
||||
export const SAVED_VIEW_NOUNS = Object.freeze({
|
||||
title: 'Edit saved view', label: 'View name', deleteTitle: 'Delete saved view',
|
||||
deleteText: view => `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`,
|
||||
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
|
||||
});
|
||||
|
||||
/** D51: the open view's Edit dialog. */
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
openEditDialog({ scope, trigger, item: view, nouns: SAVED_VIEW_NOUNS, onRename, onDelete });
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, item, nouns, onDelete }) {
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
|
||||
text.textContent = nouns.deleteText(item);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
const cancel = createButton('Cancel');
|
||||
@@ -262,11 +315,11 @@ function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
closeWorkspaceDialog();
|
||||
try {
|
||||
await onDelete();
|
||||
showWorkspaceToast(DELETED_TOAST);
|
||||
showWorkspaceToast(nouns.deleted);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
|
||||
if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed);
|
||||
}
|
||||
});
|
||||
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
|
||||
openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export const routes = [
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
|
||||
'collection']);
|
||||
|
||||
/**
|
||||
* System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's
|
||||
@@ -45,6 +46,13 @@ export function isSystemSection(id) {
|
||||
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
|
||||
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
|
||||
|
||||
/**
|
||||
* A collection (#331 D137): `#/collection/<id>` is Images filtered to its members. Not a `routes` entry (no Overview
|
||||
* card, no WORKSPACE link); the shell names the page after the collection, the breadcrumb and title stay "Collection"
|
||||
* (concept render a1186–1189).
|
||||
*/
|
||||
export const COLLECTION_ROUTE = Object.freeze({ id: 'collection', label: 'Collection' });
|
||||
|
||||
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
|
||||
export function routeTitle(route) {
|
||||
return route.title || route.label;
|
||||
@@ -58,6 +66,9 @@ export function isHandoff(route) {
|
||||
export function resolveRoute(hash) {
|
||||
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
|
||||
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
|
||||
// A collection id is the routes' 1 to 9 digits without a leading zero.
|
||||
const collection = /^#\/collection\/([1-9]\d{0,8})$/.exec(hash);
|
||||
if (collection) return {...COLLECTION_ROUTE, collectionId:Number(collection[1]), hash};
|
||||
// One job of the Activity page (its id is the registry's uuid4 hex).
|
||||
const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash);
|
||||
if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash};
|
||||
@@ -151,7 +162,7 @@ export function mountRouter(win, onRoute) {
|
||||
const hash = route.hash || `#/${route.id}`;
|
||||
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId &&
|
||||
current?.jobId === route.jobId && current?.section === route.section) return;
|
||||
current?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return;
|
||||
current = route;
|
||||
onRoute(route);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */
|
||||
@media (max-width: 950px) {
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ }
|
||||
/* D136 (#331): the concept hides COLLECTIONS here, which would leave a phone no way to open a collection; its rows
|
||||
wrap like every other sidebar nav (the concept's `.sidebar nav` flex row). Its label stays hidden, as all are. */
|
||||
#collections-nav { display: flex; /* D136 */ }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; }
|
||||
@@ -450,3 +453,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never
|
||||
splits; at 390px the chip may wrap between the category and its origin. */
|
||||
.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }
|
||||
/* D147 (#430): a state that blocks Save shows its reason on its own row above the sticky actions, with Refresh for a
|
||||
changed source; the disabled actions look disabled; a refused capture's empty preview says so. */
|
||||
.tagging-modal .dialog-actions [aria-disabled="true"], .tagging-modal .tag-model-panel [aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D147 */ }
|
||||
.tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ }
|
||||
.tagging-modal .tag-action-notice > span { flex: 1 1 16em; }
|
||||
.tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ }
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<!-- D49: the concept's SAVED VIEWS group without its "PROPOSED" badge (saved views are persisted, #249 D16). -->
|
||||
<div class="sidebar-label">SAVED VIEWS</div>
|
||||
<nav id="saved-nav" aria-label="Saved views"></nav>
|
||||
<!-- D136: the concept's COLLECTIONS group without its "PROPOSED" badge; the label shows once a collection is listed. -->
|
||||
<div class="sidebar-label" hidden>COLLECTIONS</div>
|
||||
<nav id="collections-nav" aria-label="Collections"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
|
||||
|
||||
Reference in New Issue
Block a user