Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc77ef4ab | ||
|
|
86a3a44f75 | ||
|
|
b27842dc69 | ||
|
|
c32d01e8aa | ||
|
|
3fb608bc21 | ||
|
|
a3010e151e | ||
|
|
d2f449efc4 | ||
|
|
1f62e7880a | ||
|
|
e67b48ed52 | ||
|
|
b070d5455f | ||
|
|
aa7741000a | ||
|
|
6e30853ed6 | ||
|
|
e7c45ca946 | ||
|
|
f9f54c867a | ||
|
|
4f82800927 |
@@ -13,7 +13,7 @@ permissions:
|
||||
jobs:
|
||||
viewerjs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
|
||||
+151
-2
@@ -110,6 +110,30 @@ not certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
|
||||
- Follow the boundary's existing native canonical/identity contract;
|
||||
`Path.resolve()` alone does not fold Windows case. Keep lexical aliases
|
||||
distinct from resolved targets. Cover mixed case, drive and UNC spellings,
|
||||
plus foreign-namespace denial with an allowed native control.
|
||||
See Python's [normcase documentation](https://docs.python.org/3.13/library/os.path.html#os.path.normcase).
|
||||
- Close every owned database/model connection, worker and stream handle before
|
||||
unlink or `TemporaryDirectory.cleanup()`. Await worker/stream retirement.
|
||||
A SQLite [`with conn` manages transactions, not connection closure](https://docs.python.org/3.13/library/sqlite3.html#how-to-use-the-connection-context-manager),
|
||||
and [`addCleanup` runs after teardown](https://docs.python.org/3.13/library/unittest.html#unittest.TestCase.addCleanup);
|
||||
use explicit `finally` cleanup when teardown removes files first.
|
||||
- Windows can reject unlink of an open file; Unix can remove its directory entry
|
||||
while the handle remains usable. Preserve the shared behavior assertions and
|
||||
exercise the actual native branch, then prove handle release and successful
|
||||
cleanup. Do not hide failures with blanket skips or retries.
|
||||
See Python's [remove/unlink semantics](https://docs.python.org/3.13/library/os.html#os.remove).
|
||||
- Run native Windows CI early for these changes and include a Windows impact and
|
||||
evidence note in the PR. All twelve OS/Python combinations and every applicable
|
||||
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
|
||||
controls do not establish native filesystem, locking or cleanup success.
|
||||
|
||||
## Required merge checks
|
||||
|
||||
`.github/branch-protection.json` records the required checks: all twelve Python/OS
|
||||
@@ -226,8 +250,8 @@ loading a workflow or queueing generation.
|
||||
|
||||
## Durable execution ledger
|
||||
|
||||
`npm run --prefix tools/vendor test:executions` runs 42 real execution cases
|
||||
(21 each in Classic and Nodes 2.0, retaining all 26 prior execution cases),
|
||||
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
|
||||
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
|
||||
alongside the existing 78 stock host cases and six pack compatibility cases. Run
|
||||
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
|
||||
`PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`:
|
||||
@@ -346,3 +370,128 @@ For a live output check after that manual restart:
|
||||
|
||||
Automated checks use only managed disposable hosts. They never restart or upgrade
|
||||
the personal ComfyUI server.
|
||||
|
||||
## Chronological Library history (#209)
|
||||
|
||||
`#/library/{positive_id}` retains saved negative selection, the full selected
|
||||
negative directly beneath its positive, acknowledged role/pair sending, and one
|
||||
pair-filtered shared gallery. Recorded executions are a separate projection.
|
||||
Selecting an execution, anchor, Raw/Effective mode, or reported occurrence does
|
||||
not select a saved variant or refetch its gallery. A,A,B means three attempts,
|
||||
two frozen contexts, and one exact raw-positive group. Different composition or
|
||||
evidence can produce different frozen contexts despite equal-looking previews.
|
||||
|
||||
The versioned public reads are:
|
||||
|
||||
| Route | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
|
||||
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
|
||||
|
||||
Run and output reads accept the returned `epoch`; current-file routes require
|
||||
the returned epoch, current scope and occurrence coordinates. Run integers are
|
||||
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
|
||||
receipt pages default to 40 with a 1–100 limit. Cursors are opaque, authenticated,
|
||||
scope-bound and capped at 4096 bytes. Run JSON has an 8 MiB UTF-8 limit and receipt
|
||||
pages a 1 MiB limit; exceeding a limit fails explicitly instead of truncating
|
||||
exact evidence. The unchanged capture limits remain 16 anchors/256 occurrences.
|
||||
|
||||
A cursor freezes its upper ordering boundary, not an application-wide snapshot:
|
||||
each page has one SQLite read transaction and a count as of that read. Later
|
||||
pages can observe completed capture, deletion or other current changes. Newer
|
||||
runs require Refresh history. Unknown positive raw text cannot enter an exact
|
||||
Library group but remains readable through its known run UUID. Unknown (`null`),
|
||||
known empty (`""`) and whitespace are distinct. Complete-zero reported outputs
|
||||
means the host reported none; missing capture means unknown, and partial or
|
||||
conflicting capture remains incomplete. Receipts prove reported locations, not
|
||||
historical bytes, newly generated files or context-output ownership. Cached
|
||||
attempts can repeat exactly the same receipts.
|
||||
|
||||
Each nonempty history page also returns an authenticated `resume_cursor`, including
|
||||
an exhausted page with no continuation. Re-entry uses `resume` instead of `cursor`
|
||||
to read from that prior upper boundary, then restores at most the prior number of
|
||||
40-run pages. It retains horizontal/vertical scroll and the visible anchor as
|
||||
in-memory visit intent. The API validates epoch and exact saved-text scope again;
|
||||
DTOs and current candidates are never saved as visit intent. Explicit Refresh
|
||||
loads the newest window/count and revalidates the selected run's exact raw-positive
|
||||
membership, context and receipt. A valid selection outside that window has an
|
||||
explicit label and **View selected run** control.
|
||||
|
||||
Every receipt can be selected, including missing/error candidates. **Retry current
|
||||
availability** rereads its existing bounded output page; a reduced response limit
|
||||
may require several reads within that one already loaded page. Earlier rows,
|
||||
context/mode, exact selection and saved gallery remain. Recovery actions use the
|
||||
shared client's strict `smaller-limit`, `reload-outputs`, `retry-read` and
|
||||
`refresh-history` vocabulary. Active inspection epoch/identity failures immediately
|
||||
reach the owning history recovery, clearing stale recorded actions before reload.
|
||||
|
||||
Restore acceptance depends on merged #223: incompatible online storage
|
||||
replacement must safely refuse without harming another owner's WAL/connection.
|
||||
History fencing does not repair unsafe replacement. For accepted replacement,
|
||||
the read transaction ends before a fresh-connection epoch check; the final
|
||||
epoch/generation validation is the publication linearization point. Browser
|
||||
generations also ignore late success/error callbacks after route/scope changes.
|
||||
Old cursors, epoch and current-scope URLs fail with bounded recovery instructions.
|
||||
Restored numeric IDs never repair an original navigation hint. Saved Library
|
||||
details are revalidated separately, and a changed owner requires explicit reload.
|
||||
|
||||
Current media/thumbnail responses use an already-opened, validated canonical
|
||||
file handle, bounded streaming and `Cache-Control: no-store`, including HEAD and
|
||||
range behavior. Inspection is a current metadata observation with pre/post file
|
||||
facts, canonical reauthorization and a fresh scope/epoch check; it is not pinned
|
||||
historical bytes or an OS filesystem snapshot. A deterministic change during the
|
||||
metadata read is rejected. A later request can legitimately observe replacement
|
||||
bytes at the same path without changing the immutable receipt.
|
||||
|
||||
The focused acceptance command retains all four projects:
|
||||
|
||||
```sh
|
||||
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
|
||||
workspace-history.spec.mjs workspace-visual.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
npm run --prefix tools/vendor test:executions
|
||||
npm run --prefix tools/vendor test:comfyui
|
||||
npm run --prefix tools/vendor test:compat
|
||||
```
|
||||
|
||||
Use the pinned disposable prerequisites above; managed suites run serially.
|
||||
The real execution helpers compare SQLite captures to these public routes for
|
||||
stock SaveImage/PreviewImage batches, multiple nodes, metadata disabled, cached
|
||||
headless repeats, partial failure and interruption. Additional cases verify the
|
||||
actual PM A,A,B group, unknown-positive exclusion, and exact frozen Raw/Effective
|
||||
pair sends through the acknowledged host bridge after Classic/Nodes 2.0 startup,
|
||||
both switches and workflow reload, without queuing or loading a workflow on send.
|
||||
All original 42 execution, 78 stock-host and six compatibility cases remain.
|
||||
|
||||
The browser fixture uses actual APIs/SQLite/files, fixed owned backup snapshots,
|
||||
one-shot bounded publication barriers and fixed filesystem/configuration commands.
|
||||
Reset and cleanup release barriers, restore seams and revoke commands. It never
|
||||
attaches to a personal host. The canonical HTML and its six required assets are
|
||||
served from a fixed test-only allowlist; the original concept index/app/style
|
||||
bytes are hash-pinned and unmodified. At 1600×1100 and 390×844 in both themes,
|
||||
with fixed locale/UTC/reduced motion and ready fonts, tests capture unmasked
|
||||
concept/production PNG pairs and geometry, and pin actual branch padding,
|
||||
surfaces, 2px role lines, 57px rhythm, staggering, colors, selected focus and native
|
||||
dialog behavior. Controls and actions are checked independently of screenshots.
|
||||
Keyboard focus/return, coarse targets, 200% layout, route return and persisted
|
||||
page lifecycle recovery retain their separate behavioral tests.
|
||||
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. Broader Images card
|
||||
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
|
||||
and shared role-block spacing/padding/glyph polish remain outside #209. The
|
||||
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
|
||||
mobile toolbar collision. These are explicit remaining visual differences, not
|
||||
a whole-application or full-gallery/Viewer pixel-parity claim.
|
||||
|
||||
Preserve raw logs, failed traces, paired PNGs, geometry, runtime versions and
|
||||
source hashes before later managed commands clear their output directories.
|
||||
Frontend baseline updates are additive through `coverage:update` only after
|
||||
every previous metric floor and each new file's 80% line requirement pass; rerun
|
||||
the coverage gate using the committed baseline. Python retains its 85% combined
|
||||
statement/branch gate. Deploying the history Python changes requires a maintainer
|
||||
ComfyUI restart; automated acceptance never restarts the personal server.
|
||||
|
||||
@@ -87,6 +87,9 @@ try:
|
||||
routes = config.routes
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
host_app = getattr(config.server_instance, "app", None)
|
||||
if host_app is not None and hasattr(host_app, "on_cleanup"):
|
||||
host_app.on_cleanup.append(api.cleanup_history_reads)
|
||||
|
||||
except Exception as e:
|
||||
# Log to internal logging system without console spam
|
||||
|
||||
@@ -314,6 +314,17 @@ def migrate_execution_history(conn: sqlite3.Connection) -> None:
|
||||
if version == 1:
|
||||
install_context_schema(conn)
|
||||
install_output_schema(conn)
|
||||
# Read projections need reverse content lookup and the complete run order.
|
||||
for table, columns in (
|
||||
("execution_prompt_snapshots", "raw_text_id,snapshot_id"),
|
||||
("execution_contexts", "positive_snapshot_id,context_id"),
|
||||
("execution_run_contexts", "context_id,run_id,anchor_id"),
|
||||
("execution_runs", "observed_dequeue_at,session_id,queue_item_id,run_id"),
|
||||
):
|
||||
conn.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_history_read_{table} "
|
||||
f"ON {table}({columns})"
|
||||
)
|
||||
error = history_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Bounded exact-path saved display within the caller's history snapshot."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
except ImportError:
|
||||
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
|
||||
|
||||
def _display_bytes(value: bytes | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _tags(
|
||||
conn: sqlite3.Connection, owner: int, *, pair: bool
|
||||
) -> tuple[list[str], bool]:
|
||||
table, column = (
|
||||
("prompt_pair_tags", "pair_id") if pair else ("prompt_tags", "prompt_id")
|
||||
)
|
||||
tags = []
|
||||
used = 0
|
||||
cursor = conn.execute(
|
||||
f"SELECT substr(CAST(t.name AS BLOB),1,?) FROM {table} pt "
|
||||
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.{column}=? LIMIT 129",
|
||||
(MAX_LIBRARY_BYTES + 1, owner),
|
||||
)
|
||||
for row in cursor:
|
||||
used += len(row[0])
|
||||
if used > MAX_LIBRARY_BYTES or len(tags) == 128:
|
||||
return [], True
|
||||
tags.append(_display_bytes(row[0]))
|
||||
return tags, False
|
||||
|
||||
|
||||
def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict, dict]:
|
||||
saved = {"prompt_id": row["prompt_id"]}
|
||||
pairs = list(
|
||||
conn.execute(
|
||||
"SELECT pp.id,pp.positive_prompt_id,"
|
||||
"substr(CAST(pp.title AS BLOB),1,?) AS title,"
|
||||
"substr(CAST(pp.category AS BLOB),1,?) AS category "
|
||||
"FROM image_prompt_pairs ip JOIN prompt_pairs pp ON pp.id=ip.pair_id "
|
||||
"WHERE ip.image_id=? LIMIT 2",
|
||||
(MAX_LIBRARY_BYTES + 1, MAX_LIBRARY_BYTES + 1, row["id"]),
|
||||
)
|
||||
)
|
||||
if len(pairs) > 1:
|
||||
return saved, _library_projection({"status": "limited"})
|
||||
pair = pairs[0] if pairs else None
|
||||
title = pair["title"] if pair is not None else None
|
||||
category = pair["category"] if pair is not None else row["category"]
|
||||
tags, limited = _tags(
|
||||
conn,
|
||||
pair["id"] if pair is not None else row["prompt_id"],
|
||||
pair=pair is not None,
|
||||
)
|
||||
limited = limited or any(
|
||||
value is not None and len(value) > MAX_LIBRARY_BYTES
|
||||
for value in (title, category)
|
||||
)
|
||||
if pair is not None:
|
||||
saved["pair"] = {
|
||||
"id": pair["id"],
|
||||
"positive_prompt_id": pair["positive_prompt_id"],
|
||||
}
|
||||
library = _library_projection(
|
||||
{
|
||||
"status": "linked",
|
||||
"positive_id": row["prompt_id"],
|
||||
"pair_id": pair["id"] if pair is not None else None,
|
||||
"title": _display_bytes(title),
|
||||
"category": _display_bytes(category),
|
||||
"tags": tags,
|
||||
"metadata_scope": "pair" if pair is not None else "positive",
|
||||
}
|
||||
)
|
||||
if limited:
|
||||
library = _library_projection({"status": "limited", "tags": []})
|
||||
return saved, library
|
||||
|
||||
|
||||
def read_history_gallery(
|
||||
conn: sqlite3.Connection, paths: tuple[str, ...]
|
||||
) -> dict[str, dict]:
|
||||
"""Read at most two rows/path and two pair associations/unique row; no writes."""
|
||||
if not conn.in_transaction:
|
||||
raise sqlite3.ProgrammingError("Saved history display requires caller snapshot")
|
||||
if (
|
||||
type(paths) is not tuple
|
||||
or len(paths) > 100
|
||||
or any(
|
||||
type(path) is not str
|
||||
or not os.path.isabs(path)
|
||||
or path != os.path.normcase(os.path.normpath(path))
|
||||
or "\0" in path
|
||||
for path in paths
|
||||
)
|
||||
):
|
||||
raise ValueError("Invalid history gallery paths")
|
||||
result = {}
|
||||
for path in dict.fromkeys(paths):
|
||||
rows = list(
|
||||
conn.execute(
|
||||
"SELECT gi.id,gi.prompt_id,"
|
||||
"substr(CAST(p.category AS BLOB),1,?) AS category "
|
||||
"FROM generated_images gi JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=? LIMIT 2",
|
||||
(MAX_LIBRARY_BYTES + 1, path, path),
|
||||
)
|
||||
)
|
||||
match = "none" if not rows else "unique" if len(rows) == 1 else "ambiguous"
|
||||
saved, library = None, _library_projection(
|
||||
{"status": "none" if not rows else "ambiguous"}
|
||||
)
|
||||
if match == "unique":
|
||||
saved, library = _saved_projection(conn, rows[0])
|
||||
result[path] = {
|
||||
"gallery_match": match,
|
||||
"gallery_image_id": rows[0]["id"] if match == "unique" else None,
|
||||
"saved": saved,
|
||||
"library": library,
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Snapshot-owned, bounded projections of immutable execution evidence."""
|
||||
|
||||
import codecs
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterator
|
||||
|
||||
from .execution_contexts import content_digest
|
||||
from .execution_history import HistoryEpochChanged
|
||||
from .execution_outputs import _capture, _REFERENCE_COLUMNS
|
||||
from .library import _ELIGIBLE
|
||||
|
||||
RunKey = tuple[int, str, int, str]
|
||||
|
||||
|
||||
class HistoryCaptureChanged(ValueError):
|
||||
"""A continuation no longer describes the stored output capture header."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowPosition:
|
||||
raw_text_id: str
|
||||
upper: RunKey
|
||||
before: RunKey
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputPosition:
|
||||
capture_fingerprint: str
|
||||
after: tuple[str, int]
|
||||
|
||||
|
||||
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
|
||||
|
||||
def _valid_digest(value: object) -> bool:
|
||||
return type(value) is str and _DIGEST.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _valid_run_key(value: object) -> bool:
|
||||
return (
|
||||
type(value) is tuple
|
||||
and len(value) == 4
|
||||
and all(type(value[i]) is int and 0 <= value[i] <= 2**63 - 1 for i in (0, 2))
|
||||
and all(
|
||||
type(value[i]) is str and _UUID.fullmatch(value[i]) is not None
|
||||
for i in (1, 3)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _valid_coordinate(value: object) -> bool:
|
||||
if type(value) is not tuple or len(value) != 2:
|
||||
return False
|
||||
node, ordinal = value
|
||||
if not (
|
||||
type(node) is str
|
||||
and 1 <= len(node) <= 128
|
||||
and "\0" not in node
|
||||
and type(ordinal) is int
|
||||
and 0 <= ordinal <= 255
|
||||
):
|
||||
return False
|
||||
try:
|
||||
return len(node.encode("utf-8")) <= 512
|
||||
except UnicodeError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_window_position(position: WindowPosition | None) -> None:
|
||||
if position is not None and not (
|
||||
type(position) is WindowPosition
|
||||
and _valid_digest(position.raw_text_id)
|
||||
and _valid_run_key(position.upper)
|
||||
and _valid_run_key(position.before)
|
||||
):
|
||||
raise ValueError("Invalid history window position")
|
||||
|
||||
|
||||
def _validate_output_position(position: OutputPosition | None) -> None:
|
||||
if position is not None and not (
|
||||
type(position) is OutputPosition
|
||||
and _valid_digest(position.capture_fingerprint)
|
||||
and _valid_coordinate(position.after)
|
||||
):
|
||||
raise ValueError("Invalid history output position")
|
||||
|
||||
|
||||
_snapshots: ContextVar[tuple] = ContextVar("history_read_snapshots", default=())
|
||||
_KEY = "(r.observed_dequeue_at,r.session_id,r.queue_item_id,r.run_id)"
|
||||
_ORDER = (
|
||||
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
|
||||
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
|
||||
)
|
||||
_MATCHING = (
|
||||
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
|
||||
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
|
||||
"JOIN execution_run_contexts rc ON rc.context_id=c.context_id "
|
||||
"WHERE s.raw_text_id=?"
|
||||
)
|
||||
|
||||
|
||||
def _limit(limit: int) -> None:
|
||||
if type(limit) is not int or not 1 <= limit <= 100:
|
||||
raise ValueError("History limit must be an integer from 1 to 100")
|
||||
|
||||
|
||||
def _key(row: dict) -> RunKey:
|
||||
return tuple(
|
||||
row[name]
|
||||
for name in ("observed_dequeue_at", "session_id", "queue_item_id", "run_id")
|
||||
)
|
||||
|
||||
|
||||
def _context_capture(conn: sqlite3.Connection, run_id: str) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT state,reason,format_version FROM execution_context_capture "
|
||||
"WHERE run_id=?",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
return (
|
||||
dict(row)
|
||||
if row is not None
|
||||
else {"state": "not_captured", "reason": "missing", "format_version": None}
|
||||
)
|
||||
|
||||
|
||||
def _output_header(conn: sqlite3.Connection, run_id: str) -> tuple[dict, int | None]:
|
||||
row = _capture(conn, run_id)
|
||||
if row is None:
|
||||
return (
|
||||
dict(state="not_captured", reason=None, format_version=None, source=None),
|
||||
None,
|
||||
)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM execution_run_outputs WHERE run_id=?", (run_id,)
|
||||
).fetchone()[0]
|
||||
return dict(row), count
|
||||
|
||||
|
||||
def _list_status(capture: dict) -> str:
|
||||
if capture["state"] == "not_captured":
|
||||
return "not_captured"
|
||||
return "complete" if capture["state"] == "complete" else "incomplete"
|
||||
|
||||
|
||||
def _output_summary(conn: sqlite3.Connection, run_id: str) -> dict:
|
||||
capture, count = _output_header(conn, run_id)
|
||||
return dict(
|
||||
capture, retained_occurrence_count=count, list_status=_list_status(capture)
|
||||
)
|
||||
|
||||
|
||||
def _fingerprint(capture: dict, count: int | None) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
[
|
||||
1,
|
||||
*(capture[k] for k in ("state", "reason", "format_version", "source")),
|
||||
count,
|
||||
],
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _receipt(run_id: str, row: sqlite3.Row) -> dict:
|
||||
return {
|
||||
"occurrence": {
|
||||
"run_id": run_id,
|
||||
"output_node_id": row["output_node_id"],
|
||||
"image_ordinal": row["image_ordinal"],
|
||||
},
|
||||
"reference": {
|
||||
key: row[key]
|
||||
for key in (
|
||||
"folder_type",
|
||||
"subfolder",
|
||||
"filename",
|
||||
"path_style",
|
||||
"root_key",
|
||||
)
|
||||
},
|
||||
"file_identity": "unverified",
|
||||
"newly_generated": "unknown",
|
||||
"context_membership": "unknown",
|
||||
"transient": row["folder_type"] == "temp",
|
||||
}
|
||||
|
||||
|
||||
class HistoryReadOperations:
|
||||
"""Read evidence only inside an explicitly owned, epoch-first snapshot."""
|
||||
|
||||
@contextmanager
|
||||
def recorded_history_snapshot(
|
||||
self, expected_epoch: str | None = None
|
||||
) -> Iterator[str]:
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise sqlite3.ProgrammingError(
|
||||
"History reads require their own transaction"
|
||||
)
|
||||
conn.execute("BEGIN")
|
||||
token = _snapshots.set((*_snapshots.get(), conn))
|
||||
try:
|
||||
epoch = self.get_execution_epoch()
|
||||
if expected_epoch is not None and epoch != expected_epoch:
|
||||
raise HistoryEpochChanged(epoch)
|
||||
yield epoch
|
||||
finally:
|
||||
try:
|
||||
conn.rollback()
|
||||
finally:
|
||||
_snapshots.reset(token)
|
||||
|
||||
def _history_connection(self) -> sqlite3.Connection:
|
||||
conn = self.model.get_connection()
|
||||
if not conn.in_transaction or conn not in _snapshots.get():
|
||||
raise sqlite3.ProgrammingError(
|
||||
"History read requires recorded_history_snapshot"
|
||||
)
|
||||
return conn
|
||||
|
||||
def list_history_window(
|
||||
self,
|
||||
positive_id: int,
|
||||
*,
|
||||
limit: int = 40,
|
||||
after: WindowPosition | None = None,
|
||||
restart: bool = False,
|
||||
) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
_limit(limit)
|
||||
_validate_window_position(after)
|
||||
if type(restart) is not bool or (
|
||||
restart and (after is None or after.before != after.upper)
|
||||
):
|
||||
raise ValueError("Invalid history restart position")
|
||||
saved = conn.execute(
|
||||
"SELECT CASE WHEN typeof(p.text)='text' AND "
|
||||
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
|
||||
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
|
||||
(positive_id,),
|
||||
).fetchone()
|
||||
if saved is None:
|
||||
return None
|
||||
text = saved["text"]
|
||||
raw_id = None if text is None else content_digest("text", (text,))
|
||||
if after is not None and after.raw_text_id != raw_id:
|
||||
raise ValueError("History position text scope changed")
|
||||
group = {
|
||||
"positive_id": positive_id,
|
||||
"raw_text_id": raw_id,
|
||||
"relation": "exact_frozen_raw_text",
|
||||
"scope_status": "uncapturable_text" if text is None else "no_matches",
|
||||
}
|
||||
result = dict(
|
||||
group=group,
|
||||
recorded_run_count=0,
|
||||
items=[],
|
||||
context_summaries=[],
|
||||
has_more=False,
|
||||
next_position=None,
|
||||
resume_position=None,
|
||||
)
|
||||
if (
|
||||
raw_id is None
|
||||
or conn.execute(
|
||||
"SELECT 1 FROM execution_context_texts WHERE text_id=? "
|
||||
"AND text_value=? COLLATE BINARY",
|
||||
(raw_id, text),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
return result
|
||||
where = f"r.run_id IN ({_MATCHING})"
|
||||
params = [raw_id]
|
||||
if after is not None:
|
||||
where += f" AND {_KEY}<=(?,?,?,?)"
|
||||
params.extend(after.upper)
|
||||
count = conn.execute(
|
||||
f"SELECT COUNT(*) FROM execution_runs r WHERE {where}", params
|
||||
).fetchone()[0]
|
||||
result["recorded_run_count"] = count
|
||||
group["scope_status"] = "matched" if count else "no_matches"
|
||||
if after is not None and not restart:
|
||||
where += f" AND {_KEY}<(?,?,?,?)"
|
||||
params.extend(after.before)
|
||||
rows = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
f"SELECT r.* FROM execution_runs r WHERE {where} "
|
||||
f"ORDER BY {_ORDER} LIMIT ?",
|
||||
(*params, limit + 1),
|
||||
)
|
||||
]
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
if not rows:
|
||||
return result
|
||||
upper = after.upper if after is not None else _key(rows[0])
|
||||
result["resume_position"] = WindowPosition(raw_id, upper, upper)
|
||||
if more:
|
||||
result["next_position"] = WindowPosition(raw_id, upper, _key(rows[-1]))
|
||||
result["has_more"] = more
|
||||
rows.reverse()
|
||||
by_run = {row["run_id"]: row for row in rows}
|
||||
for row in rows:
|
||||
row["diagnostics_available"] = any(
|
||||
row[field] is not None
|
||||
for field in ("node_id", "node_type", "exception_type")
|
||||
)
|
||||
for field in ("node_id", "node_type", "exception_type"):
|
||||
del row[field]
|
||||
row["diagnostics_omitted"] = bool(row["diagnostics_omitted"])
|
||||
row["context_capture"] = _context_capture(conn, row["run_id"])
|
||||
row["output_summary"] = _output_summary(conn, row["run_id"])
|
||||
row["matching_anchors"] = []
|
||||
placeholders = ",".join("?" for _ in rows)
|
||||
anchors = conn.execute(
|
||||
"SELECT rc.run_id,rc.anchor_id,rc.context_id,rc.evidence_json,"
|
||||
"c.positive_snapshot_id,c.negative_snapshot_id,"
|
||||
"substr(CAST(t.text_value AS BLOB),1,512) AS prefix,"
|
||||
"length(CAST(t.text_value AS BLOB)) AS byte_length "
|
||||
"FROM execution_run_contexts rc JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots p "
|
||||
"ON p.snapshot_id=c.positive_snapshot_id "
|
||||
"LEFT JOIN execution_prompt_snapshots n "
|
||||
"ON n.snapshot_id=c.negative_snapshot_id "
|
||||
"LEFT JOIN execution_context_texts t ON t.text_id=n.raw_text_id "
|
||||
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id=? "
|
||||
"ORDER BY rc.anchor_id COLLATE BINARY,rc.run_id COLLATE BINARY",
|
||||
(*by_run, raw_id),
|
||||
)
|
||||
summaries = {}
|
||||
evidence_by_context = {}
|
||||
for anchor in anchors:
|
||||
reference = {
|
||||
key: anchor[key]
|
||||
for key in (
|
||||
"anchor_id",
|
||||
"context_id",
|
||||
"positive_snapshot_id",
|
||||
"negative_snapshot_id",
|
||||
)
|
||||
}
|
||||
by_run[anchor["run_id"]]["matching_anchors"].append(reference)
|
||||
context_id = anchor["context_id"]
|
||||
if context_id not in summaries:
|
||||
prefix = b"" if anchor["byte_length"] == 0 else anchor["prefix"]
|
||||
preview = (
|
||||
None
|
||||
if prefix is None
|
||||
else codecs.getincrementaldecoder("utf-8")().decode(
|
||||
prefix, final=False
|
||||
)[:100]
|
||||
)
|
||||
evidence_by_context[context_id] = []
|
||||
summaries[context_id] = {
|
||||
key: anchor[key]
|
||||
for key in (
|
||||
"context_id",
|
||||
"positive_snapshot_id",
|
||||
"negative_snapshot_id",
|
||||
)
|
||||
}
|
||||
summaries[context_id]["negative_raw"] = {
|
||||
"state": (
|
||||
"unknown"
|
||||
if preview is None
|
||||
else "explicit_empty" if not preview else "known"
|
||||
),
|
||||
"preview": preview,
|
||||
"truncated": (
|
||||
False
|
||||
if preview is None
|
||||
else len(preview.encode("utf-8")) < anchor["byte_length"]
|
||||
),
|
||||
"evidence": evidence_by_context[context_id],
|
||||
}
|
||||
negative = json.loads(anchor["evidence_json"])["negative"]
|
||||
evidence = (
|
||||
negative["raw"]
|
||||
if negative is not None
|
||||
else dict(provenance="unknown", source="unknown", reason="missing")
|
||||
)
|
||||
evidence_by_context[context_id].append(
|
||||
{
|
||||
"run_id": anchor["run_id"],
|
||||
"anchor_id": anchor["anchor_id"],
|
||||
**evidence,
|
||||
}
|
||||
)
|
||||
# Context order follows its first chronological matching run, then anchor.
|
||||
ordered = {}
|
||||
for row in rows:
|
||||
for anchor in row["matching_anchors"]:
|
||||
ordered.setdefault(
|
||||
anchor["context_id"], summaries[anchor["context_id"]]
|
||||
)
|
||||
result["items"] = rows
|
||||
result["context_summaries"] = list(ordered.values())
|
||||
return result
|
||||
|
||||
def get_history_run(self, run_id: str) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
run = self.get_execution_run(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
capture = self.get_execution_contexts(run_id)
|
||||
snapshots = {
|
||||
row["anchor_id"]: row
|
||||
for row in conn.execute(
|
||||
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id,"
|
||||
"p.raw_text_id AS positive_raw_text_id "
|
||||
"FROM execution_run_contexts rc JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id LEFT JOIN execution_prompt_snapshots p "
|
||||
"ON p.snapshot_id=c.positive_snapshot_id WHERE rc.run_id=?",
|
||||
(run_id,),
|
||||
)
|
||||
}
|
||||
contexts = capture.pop("contexts")
|
||||
for context in contexts:
|
||||
row = snapshots[context["anchor_id"]]
|
||||
context.update(
|
||||
{
|
||||
key: row[key]
|
||||
for key in (
|
||||
"positive_snapshot_id",
|
||||
"negative_snapshot_id",
|
||||
"positive_raw_text_id",
|
||||
)
|
||||
}
|
||||
)
|
||||
pair = context["pair_id"]
|
||||
context["navigation"] = {
|
||||
"positive_available": context["positive_prompt_id"] is not None
|
||||
and conn.execute(
|
||||
f"SELECT 1 FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
|
||||
(context["positive_prompt_id"],),
|
||||
).fetchone()
|
||||
is not None,
|
||||
"negative_available": context["negative_prompt_id"] is not None,
|
||||
"pair_available": pair is not None
|
||||
and conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=? AND archived=0", (pair,)
|
||||
).fetchone()
|
||||
is not None,
|
||||
}
|
||||
return dict(
|
||||
run=run,
|
||||
context_capture=capture,
|
||||
contexts=contexts,
|
||||
output_summary=_output_summary(conn, run_id),
|
||||
)
|
||||
|
||||
def list_history_outputs(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
limit: int = 40,
|
||||
after: OutputPosition | None = None,
|
||||
) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
_limit(limit)
|
||||
_validate_output_position(after)
|
||||
# Validate header/orphans even when the requested parent does not exist.
|
||||
capture, count = _output_header(conn, run_id)
|
||||
if self.get_execution_run(run_id) is None:
|
||||
return None
|
||||
fingerprint = _fingerprint(capture, count)
|
||||
if after is not None and after.capture_fingerprint != fingerprint:
|
||||
raise HistoryCaptureChanged("History output capture changed")
|
||||
where = "run_id=?"
|
||||
params = [run_id]
|
||||
if after is not None:
|
||||
where += " AND (output_node_id COLLATE BINARY,image_ordinal)>(?,?)"
|
||||
params.extend(after.after)
|
||||
rows = list(
|
||||
conn.execute(
|
||||
f"SELECT {_REFERENCE_COLUMNS} FROM execution_run_outputs WHERE {where} "
|
||||
"ORDER BY output_node_id COLLATE BINARY,image_ordinal LIMIT ?",
|
||||
(*params, limit + 1),
|
||||
)
|
||||
)
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
position = (
|
||||
OutputPosition(
|
||||
fingerprint, (rows[-1]["output_node_id"], rows[-1]["image_ordinal"])
|
||||
)
|
||||
if more
|
||||
else None
|
||||
)
|
||||
return dict(
|
||||
capture=capture,
|
||||
retained_occurrence_count=count,
|
||||
list_status=_list_status(capture),
|
||||
capture_fingerprint=fingerprint,
|
||||
items=[_receipt(run_id, row) for row in rows],
|
||||
has_more=more,
|
||||
next_position=position,
|
||||
)
|
||||
|
||||
def get_history_occurrence(
|
||||
self, run_id: str, node: str, ordinal: int
|
||||
) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
capture, _ = _output_header(conn, run_id)
|
||||
row = conn.execute(
|
||||
f"SELECT {_REFERENCE_COLUMNS} FROM execution_run_outputs "
|
||||
"WHERE run_id=? AND output_node_id=? COLLATE BINARY AND image_ordinal=?",
|
||||
(run_id, node, ordinal),
|
||||
).fetchone()
|
||||
return (
|
||||
dict(capture=capture, receipt=_receipt(run_id, row))
|
||||
if row is not None
|
||||
else None
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from .library import LibraryOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
from .execution_contexts import ExecutionContextOperations
|
||||
from .execution_outputs import ExecutionOutputOperations
|
||||
from .history_reads import HistoryReadOperations
|
||||
|
||||
# Import logging system
|
||||
try:
|
||||
@@ -160,6 +161,7 @@ class PromptDatabase(
|
||||
ExecutionHistoryOperations,
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from PIL import Image
|
||||
from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
@@ -245,6 +246,7 @@ async def _gzip_middleware(request, handler):
|
||||
class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
AdminRoutesMixin,
|
||||
@@ -273,6 +275,8 @@ class PromptManagerAPI(
|
||||
self.logger.info("Initializing PromptManager API")
|
||||
|
||||
self.db = db if db is not None else PromptDatabase()
|
||||
self._history_read_fence = None
|
||||
self._history_reads_closed = False
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
@@ -402,6 +406,7 @@ class PromptManagerAPI(
|
||||
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
self._register_admin_routes(routes)
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""Authenticated chronological windows and bounded frozen run details."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
from functools import wraps
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Callable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .library import _decode, _encode, _scope
|
||||
from .history_current import (
|
||||
CurrentLocationUnavailable,
|
||||
CurrentOutputScope,
|
||||
VIDEO_EXTENSIONS,
|
||||
_OpenedCurrentMedia,
|
||||
_current_media_response,
|
||||
_prepare_current_media,
|
||||
_reauthorize_current_target,
|
||||
candidate_locator,
|
||||
resolve_current_candidate,
|
||||
snapshot_current_output_scope,
|
||||
)
|
||||
from .inspection_service import CurrentFileChanged, inspect_current_file
|
||||
|
||||
try:
|
||||
from ...database.execution_history import HistoryEpochChanged
|
||||
from ...database.history_gallery import read_history_gallery
|
||||
from ...database.history_reads import (
|
||||
HistoryCaptureChanged,
|
||||
OutputPosition,
|
||||
RunKey,
|
||||
WindowPosition,
|
||||
)
|
||||
from ...utils.history_read_fence import HistoryReadFence
|
||||
from ...utils.inspection_response import encode_history_inspection_response
|
||||
except ImportError:
|
||||
from database.execution_history import HistoryEpochChanged
|
||||
from database.history_gallery import read_history_gallery
|
||||
from database.history_reads import (
|
||||
HistoryCaptureChanged,
|
||||
OutputPosition,
|
||||
RunKey,
|
||||
WindowPosition,
|
||||
)
|
||||
from utils.history_read_fence import HistoryReadFence
|
||||
from utils.inspection_response import encode_history_inspection_response
|
||||
|
||||
|
||||
_RUN_MAX_BYTES = 8 * 1024 * 1024
|
||||
_OUTPUTS_MAX_BYTES = 1024 * 1024
|
||||
_UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||
_DECIMAL = re.compile(r"0|[1-9][0-9]{0,18}")
|
||||
_RUN_DECIMALS = (
|
||||
"queue_item_id",
|
||||
"observed_dequeue_at",
|
||||
"observed_callback_at",
|
||||
"executor_started_at",
|
||||
"terminal_event_at",
|
||||
)
|
||||
_MESSAGES = {
|
||||
"invalid-request": "Invalid history request",
|
||||
"invalid-cursor": "Invalid history cursor; refresh recorded history",
|
||||
"not-found": "Recorded history target not found",
|
||||
"history-changed": "Recorded history changed; reload recorded history",
|
||||
"storage-unavailable": "Recorded history is temporarily unavailable",
|
||||
"response-limit": "Recorded history response exceeds the response limit",
|
||||
"capture-changed": "Reported output capture changed; reload reported outputs",
|
||||
"candidate-stale": "Current output locations changed; reload reported outputs",
|
||||
"not-authorized": "Current output media is unavailable or not authorized",
|
||||
}
|
||||
|
||||
|
||||
class HistoryReadError(Exception):
|
||||
"""A bounded public failure without private storage or capture details."""
|
||||
|
||||
def __init__(self, status: int, code: str, recovery: dict | None = None) -> None:
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.recovery = recovery
|
||||
super().__init__(_MESSAGES[code])
|
||||
|
||||
|
||||
def _error_response(error: HistoryReadError) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"history_version": 1,
|
||||
"code": error.code,
|
||||
"error": str(error),
|
||||
"recovery": error.recovery,
|
||||
},
|
||||
status=error.status,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _history_errors(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except HistoryReadError as error:
|
||||
return _error_response(error)
|
||||
except HistoryEpochChanged:
|
||||
return _error_response(
|
||||
HistoryReadError(409, "history-changed", {"action": "refresh-history"})
|
||||
)
|
||||
except ValueError:
|
||||
return _error_response(HistoryReadError(400, "invalid-request"))
|
||||
except (sqlite3.Error, OSError) as error:
|
||||
self.logger.warning("History read unavailable (%s)", type(error).__name__)
|
||||
return _error_response(
|
||||
HistoryReadError(503, "storage-unavailable", {"action": "retry-read"})
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _decimal(value: object, *, minimum: int = 0) -> int:
|
||||
if type(value) is not str or _DECIMAL.fullmatch(value) is None:
|
||||
raise ValueError("Invalid decimal history integer")
|
||||
parsed = int(value)
|
||||
if not minimum <= parsed <= 2**63 - 1:
|
||||
raise ValueError("History integer outside supported range")
|
||||
return parsed
|
||||
|
||||
|
||||
def _uuid(value: object) -> str:
|
||||
if type(value) is not str or _UUID.fullmatch(value) is None:
|
||||
raise ValueError("Invalid history UUID")
|
||||
return value
|
||||
|
||||
|
||||
def _query(request: web.Request) -> None:
|
||||
if any(len(request.query.getall(key)) != 1 for key in request.query):
|
||||
raise ValueError("Duplicate history query parameter")
|
||||
|
||||
|
||||
def _page_limit(request: web.Request) -> int:
|
||||
limit = _decimal(request.query.get("limit", "40"), minimum=1)
|
||||
if limit > 100:
|
||||
raise ValueError("History limit outside supported range")
|
||||
return limit
|
||||
|
||||
|
||||
def _decode_run_key(value: object) -> RunKey:
|
||||
if type(value) is not list or len(value) != 4:
|
||||
raise ValueError("Invalid history run key")
|
||||
return (_decimal(value[0]), _uuid(value[1]), _decimal(value[2]), _uuid(value[3]))
|
||||
|
||||
|
||||
def _wire_run_key(key: RunKey) -> list[str]:
|
||||
return [str(key[0]), key[1], str(key[2]), key[3]]
|
||||
|
||||
|
||||
def _cursor_payload(token: str, kind: str) -> object:
|
||||
"""Use Library authentication, allowing only inline, domain-bound keys."""
|
||||
try:
|
||||
if not 1 <= len(token.encode("ascii")) <= 4096:
|
||||
raise ValueError
|
||||
raw = base64.b64decode(
|
||||
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
|
||||
)
|
||||
wrapper = json.loads(raw[32:])
|
||||
if type(wrapper) is not dict or set(wrapper) != {"scope", "key"}:
|
||||
raise ValueError
|
||||
return _decode(token, _scope({"kind": kind}))
|
||||
except (ValueError, TypeError, binascii.Error, UnicodeError, RecursionError):
|
||||
raise HistoryReadError(400, "invalid-cursor") from None
|
||||
|
||||
|
||||
def _decode_window_cursor(
|
||||
token: str | None, positive_id: int, *, resume: bool = False
|
||||
) -> tuple[str | None, WindowPosition | None]:
|
||||
if token is None:
|
||||
return None, None
|
||||
try:
|
||||
value = _cursor_payload(token, "history-resume" if resume else "history-window")
|
||||
if type(value) is not dict or set(value) != {
|
||||
"version",
|
||||
"epoch",
|
||||
"positive_id",
|
||||
"raw_text_id",
|
||||
"upper",
|
||||
} | (set() if resume else {"direction", "before"}):
|
||||
raise ValueError
|
||||
if (
|
||||
type(value["version"]) is not int
|
||||
or value["version"] != 1
|
||||
or _decimal(value["positive_id"], minimum=1) != positive_id
|
||||
or (not resume and value["direction"] != "older")
|
||||
or type(value["raw_text_id"]) is not str
|
||||
or _DIGEST.fullmatch(value["raw_text_id"]) is None
|
||||
):
|
||||
raise ValueError
|
||||
epoch = _uuid(value["epoch"])
|
||||
upper = _decode_run_key(value["upper"])
|
||||
before = upper if resume else _decode_run_key(value["before"])
|
||||
if before > upper:
|
||||
raise ValueError
|
||||
return epoch, WindowPosition(value["raw_text_id"], upper, before)
|
||||
except ValueError:
|
||||
raise HistoryReadError(400, "invalid-cursor") from None
|
||||
|
||||
|
||||
def _encode_window_cursor(
|
||||
position: WindowPosition | None,
|
||||
epoch: str,
|
||||
positive_id: int,
|
||||
*,
|
||||
resume: bool = False,
|
||||
) -> str | None:
|
||||
if position is None:
|
||||
return None
|
||||
value = {
|
||||
"version": 1,
|
||||
"epoch": epoch,
|
||||
"positive_id": str(positive_id),
|
||||
"raw_text_id": position.raw_text_id,
|
||||
"upper": _wire_run_key(position.upper),
|
||||
}
|
||||
if not resume:
|
||||
value.update(direction="older", before=_wire_run_key(position.before))
|
||||
return _encode(
|
||||
value,
|
||||
_scope({"kind": "history-resume" if resume else "history-window"}),
|
||||
)
|
||||
|
||||
|
||||
def _node(value: object) -> str:
|
||||
if type(value) is not str or not 1 <= len(value) <= 128 or "\0" in value:
|
||||
raise ValueError("Invalid output node")
|
||||
if len(value.encode("utf-8")) > 512:
|
||||
raise ValueError("Invalid output node size")
|
||||
return value
|
||||
|
||||
|
||||
def _decode_output_cursor(
|
||||
token: str | None, run_id: str, epoch: str
|
||||
) -> OutputPosition | None:
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
value = _cursor_payload(token, "history-outputs")
|
||||
if type(value) is not dict or set(value) != {
|
||||
"version",
|
||||
"epoch",
|
||||
"run_id",
|
||||
"capture_fingerprint",
|
||||
"after",
|
||||
}:
|
||||
raise ValueError
|
||||
if (
|
||||
type(value["version"]) is not int
|
||||
or value["version"] != 1
|
||||
or _uuid(value["run_id"]) != run_id
|
||||
or type(value["capture_fingerprint"]) is not str
|
||||
or _DIGEST.fullmatch(value["capture_fingerprint"]) is None
|
||||
or type(value["after"]) is not list
|
||||
or len(value["after"]) != 2
|
||||
or type(value["after"][1]) is not int
|
||||
or not 0 <= value["after"][1] <= 255
|
||||
):
|
||||
raise ValueError
|
||||
node = _node(value["after"][0])
|
||||
if _uuid(value["epoch"]) != epoch:
|
||||
raise HistoryReadError(
|
||||
409, "history-changed", {"action": "refresh-history"}
|
||||
)
|
||||
return OutputPosition(value["capture_fingerprint"], (node, value["after"][1]))
|
||||
except (ValueError, UnicodeError):
|
||||
raise HistoryReadError(400, "invalid-cursor") from None
|
||||
|
||||
|
||||
def _encode_output_cursor(
|
||||
position: OutputPosition | None, run_id: str, epoch: str
|
||||
) -> str | None:
|
||||
if position is None:
|
||||
return None
|
||||
return _encode(
|
||||
{
|
||||
"version": 1,
|
||||
"epoch": epoch,
|
||||
"run_id": run_id,
|
||||
"capture_fingerprint": position.capture_fingerprint,
|
||||
"after": list(position.after),
|
||||
},
|
||||
_scope({"kind": "history-outputs"}),
|
||||
)
|
||||
|
||||
|
||||
def _current_stale() -> HistoryReadError:
|
||||
return HistoryReadError(409, "candidate-stale", {"action": "reload-outputs"})
|
||||
|
||||
|
||||
def _current_unavailable(reason: str) -> HistoryReadError:
|
||||
if reason == "temporary_unavailable":
|
||||
return HistoryReadError(503, "storage-unavailable", {"action": "retry-read"})
|
||||
if reason == "missing":
|
||||
return HistoryReadError(404, "not-found", {"action": "retry-read"})
|
||||
return HistoryReadError(403, "not-authorized", {"action": "retry-read"})
|
||||
|
||||
|
||||
def _check_current(
|
||||
scope: CurrentOutputScope,
|
||||
api: object,
|
||||
fence: HistoryReadFence,
|
||||
epoch: str,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if snapshot_current_output_scope(api).current_scope != scope.current_scope:
|
||||
raise _current_stale()
|
||||
fence.validate(epoch, generation)
|
||||
|
||||
|
||||
def _media_descriptor(receipt: dict, candidate: dict, epoch: str) -> dict:
|
||||
occurrence = receipt["occurrence"]
|
||||
query = urlencode(
|
||||
{
|
||||
"node": occurrence["output_node_id"],
|
||||
"ordinal": occurrence["image_ordinal"],
|
||||
"epoch": epoch,
|
||||
"current_scope": candidate["current_scope"],
|
||||
}
|
||||
)
|
||||
base = f"/prompt_manager/history/runs/{occurrence['run_id']}/output/current"
|
||||
return {
|
||||
"kind": "reported-location",
|
||||
"occurrence": occurrence,
|
||||
"history_epoch": epoch,
|
||||
"current_scope": candidate["current_scope"],
|
||||
"location_key": candidate["location_key"],
|
||||
"reference": receipt["reference"],
|
||||
"filename": receipt["reference"]["filename"],
|
||||
"is_video": receipt["reference"]["filename"]
|
||||
.lower()
|
||||
.endswith(tuple(VIDEO_EXTENSIONS)),
|
||||
"url": f"{base}/media?{query}",
|
||||
"thumbnail_url": f"{base}/thumbnail?{query}",
|
||||
"inspection_url": f"{base}/inspection?{query}",
|
||||
}
|
||||
|
||||
|
||||
def _close_prepared_result(task: asyncio.Task) -> None:
|
||||
try:
|
||||
result = task.result()
|
||||
except (Exception, asyncio.CancelledError):
|
||||
return
|
||||
if not isinstance(result, bytes):
|
||||
result.close()
|
||||
|
||||
|
||||
def _wire_run(run: dict) -> None:
|
||||
run["diagnostics_omitted"] = bool(run["diagnostics_omitted"])
|
||||
for key in _RUN_DECIMALS:
|
||||
if run[key] is not None:
|
||||
run[key] = str(run[key])
|
||||
|
||||
|
||||
def _serialize_history(payload: dict, *, max_bytes: int | None = None) -> bytes:
|
||||
try:
|
||||
encoded = json.dumps(
|
||||
payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
except (ValueError, TypeError, UnicodeError):
|
||||
raise sqlite3.DatabaseError("Invalid recorded history payload") from None
|
||||
if max_bytes is not None and len(encoded) > max_bytes:
|
||||
raise HistoryReadError(422, "response-limit")
|
||||
return encoded
|
||||
|
||||
|
||||
def _success_response(encoded: bytes) -> web.Response:
|
||||
return web.Response(
|
||||
body=encoded,
|
||||
content_type="application/json",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
class HistoryRoutesMixin:
|
||||
def _register_history_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/library/{positive_id}/history")(
|
||||
self.get_library_history
|
||||
)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
|
||||
self.get_history_outputs
|
||||
)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/output/current/media")(
|
||||
self.get_history_current_media
|
||||
)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/output/current/thumbnail")(
|
||||
self.get_history_current_thumbnail
|
||||
)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/output/current/inspection")(
|
||||
self.get_history_current_inspection
|
||||
)
|
||||
|
||||
def _history_fence(self) -> HistoryReadFence:
|
||||
# Called synchronously by the event-loop owner before scheduling work.
|
||||
if self._history_reads_closed:
|
||||
raise sqlite3.OperationalError("History reads are closed")
|
||||
if self._history_read_fence is None:
|
||||
self._history_read_fence = HistoryReadFence(self.db)
|
||||
return self._history_read_fence
|
||||
|
||||
def close_history_reads(self) -> None:
|
||||
self._history_reads_closed = True
|
||||
if self._history_read_fence is not None:
|
||||
self._history_read_fence.close()
|
||||
|
||||
async def cleanup_history_reads(self, app: web.Application) -> None:
|
||||
self.close_history_reads()
|
||||
|
||||
@_history_errors
|
||||
async def get_library_history(self, request: web.Request) -> web.Response:
|
||||
_query(request)
|
||||
if set(request.query) - {"limit", "cursor", "resume"} or (
|
||||
"cursor" in request.query and "resume" in request.query
|
||||
):
|
||||
raise ValueError("Invalid history window query")
|
||||
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
|
||||
limit = _page_limit(request)
|
||||
resuming = "resume" in request.query
|
||||
expected_epoch, position = _decode_window_cursor(
|
||||
request.query.get("resume" if resuming else "cursor"),
|
||||
positive_id,
|
||||
resume=resuming,
|
||||
)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
try:
|
||||
with db.recorded_history_snapshot(expected_epoch) as epoch:
|
||||
try:
|
||||
result = db.list_history_window(
|
||||
positive_id, limit=limit, after=position, restart=resuming
|
||||
)
|
||||
except ValueError:
|
||||
raise HistoryReadError(400, "invalid-cursor") from None
|
||||
finally:
|
||||
# Restore may retire this owner before a queued read opens it.
|
||||
db.model.close()
|
||||
if result is None:
|
||||
fence.validate(epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
result["next_cursor"] = _encode_window_cursor(
|
||||
result.pop("next_position"), epoch, positive_id
|
||||
)
|
||||
result["resume_cursor"] = _encode_window_cursor(
|
||||
result.pop("resume_position"), epoch, positive_id, resume=True
|
||||
)
|
||||
for run in result["items"]:
|
||||
_wire_run(run)
|
||||
encoded = _serialize_history(
|
||||
{
|
||||
"success": True,
|
||||
"history_version": 1,
|
||||
"history_epoch": epoch,
|
||||
**result,
|
||||
}
|
||||
)
|
||||
fence.validate(epoch, generation)
|
||||
return encoded
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_history_run(self, request: web.Request) -> web.Response:
|
||||
_query(request)
|
||||
run_id = _uuid(request.match_info["run_id"])
|
||||
expected_epoch = request.query.get("epoch")
|
||||
if expected_epoch is not None:
|
||||
_uuid(expected_epoch)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
try:
|
||||
with db.recorded_history_snapshot(expected_epoch) as epoch:
|
||||
result = db.get_history_run(run_id)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
fence.validate(epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
_wire_run(result["run"])
|
||||
encoded = _serialize_history(
|
||||
{
|
||||
"success": True,
|
||||
"history_version": 1,
|
||||
"history_epoch": epoch,
|
||||
**result,
|
||||
},
|
||||
max_bytes=_RUN_MAX_BYTES,
|
||||
)
|
||||
fence.validate(epoch, generation)
|
||||
return encoded
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
def _history_gallery(
|
||||
self, conn: sqlite3.Connection, paths: tuple[str, ...]
|
||||
) -> tuple[dict, bool]:
|
||||
try:
|
||||
return read_history_gallery(conn, paths), False
|
||||
except Exception as error:
|
||||
self.logger.warning(
|
||||
"Saved history display unavailable (%s)", type(error).__name__
|
||||
)
|
||||
return {}, True
|
||||
|
||||
@_history_errors
|
||||
async def get_history_outputs(self, request: web.Request) -> web.Response:
|
||||
_query(request)
|
||||
run_id = _uuid(request.match_info["run_id"])
|
||||
expected_epoch = _uuid(request.query.get("epoch"))
|
||||
limit = _page_limit(request)
|
||||
position = _decode_output_cursor(
|
||||
request.query.get("cursor"), run_id, expected_epoch
|
||||
)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
scope = snapshot_current_output_scope(self)
|
||||
try:
|
||||
with db.recorded_history_snapshot(expected_epoch) as epoch:
|
||||
try:
|
||||
result = db.list_history_outputs(
|
||||
run_id, limit=limit, after=position
|
||||
)
|
||||
except HistoryCaptureChanged:
|
||||
raise HistoryReadError(
|
||||
409, "capture-changed", {"action": "reload-outputs"}
|
||||
) from None
|
||||
if result is not None:
|
||||
paths = tuple(
|
||||
dict.fromkeys(
|
||||
path
|
||||
for item in result["items"]
|
||||
if (path := candidate_locator(item["reference"], scope))
|
||||
is not None
|
||||
)
|
||||
)
|
||||
gallery, saved_unavailable = self._history_gallery(
|
||||
db.model.get_connection(), paths
|
||||
)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
resolved = {}
|
||||
for receipt in result["items"]:
|
||||
reference = receipt["reference"]
|
||||
locator = tuple(reference.items())
|
||||
if locator not in resolved:
|
||||
try:
|
||||
if saved_unavailable:
|
||||
raise sqlite3.OperationalError(
|
||||
"Optional saved display unavailable"
|
||||
)
|
||||
resolved[locator] = resolve_current_candidate(
|
||||
reference, scope, gallery
|
||||
).public
|
||||
except Exception:
|
||||
resolved[locator] = {
|
||||
"status": "error",
|
||||
"reason": "temporary_unavailable",
|
||||
"evidence_class": "locator_candidate",
|
||||
}
|
||||
candidate = dict(resolved[locator])
|
||||
if candidate["status"] == "available":
|
||||
candidate["media"] = _media_descriptor(receipt, candidate, epoch)
|
||||
receipt["current_candidate"] = candidate
|
||||
result["next_cursor"] = _encode_output_cursor(
|
||||
result.pop("next_position"), run_id, epoch
|
||||
)
|
||||
encoded = _serialize_history(
|
||||
{
|
||||
"success": True,
|
||||
"history_version": 1,
|
||||
"history_epoch": epoch,
|
||||
**result,
|
||||
}
|
||||
)
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
if len(encoded) > _OUTPUTS_MAX_BYTES:
|
||||
recovery = (
|
||||
{"action": "smaller-limit", "limit": max(1, limit // 2)}
|
||||
if limit > 1
|
||||
else None
|
||||
)
|
||||
raise HistoryReadError(422, "response-limit", recovery)
|
||||
return encoded
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_history_current_media(
|
||||
self, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
return await self._history_current(request, "media")
|
||||
|
||||
@_history_errors
|
||||
async def get_history_current_thumbnail(
|
||||
self, request: web.Request
|
||||
) -> web.StreamResponse:
|
||||
return await self._history_current(request, "thumbnail")
|
||||
|
||||
@_history_errors
|
||||
async def get_history_current_inspection(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
return await self._history_current(request, "inspection")
|
||||
|
||||
async def _history_current(
|
||||
self, request: web.Request, kind: str
|
||||
) -> web.StreamResponse:
|
||||
_query(request)
|
||||
run_id = _uuid(request.match_info["run_id"])
|
||||
node = _node(request.query.get("node"))
|
||||
ordinal = _decimal(request.query.get("ordinal"))
|
||||
if ordinal > 255:
|
||||
raise ValueError("Invalid output ordinal")
|
||||
expected_epoch = _uuid(request.query.get("epoch"))
|
||||
current_scope = request.query.get("current_scope")
|
||||
if current_scope is None or _DIGEST.fullmatch(current_scope) is None:
|
||||
raise ValueError("Invalid current output scope")
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes | _OpenedCurrentMedia:
|
||||
generation = fence.begin()
|
||||
scope = snapshot_current_output_scope(self)
|
||||
try:
|
||||
with db.recorded_history_snapshot(expected_epoch) as epoch:
|
||||
if scope.current_scope != current_scope:
|
||||
raise _current_stale()
|
||||
result = db.get_history_occurrence(run_id, node, ordinal)
|
||||
if result is not None:
|
||||
receipt = result["receipt"]
|
||||
path = candidate_locator(receipt["reference"], scope)
|
||||
gallery, saved_unavailable = self._history_gallery(
|
||||
db.model.get_connection(),
|
||||
(path,) if path is not None else (),
|
||||
)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
resolved = resolve_current_candidate(receipt["reference"], scope, gallery)
|
||||
if resolved.public["status"] != "available":
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise _current_unavailable(resolved.public["reason"])
|
||||
prepared = None
|
||||
try:
|
||||
if kind == "inspection":
|
||||
saved = gallery.get(path, {})
|
||||
_reauthorize_current_target(resolved, scope)
|
||||
observed = inspect_current_file(
|
||||
resolved.canonical_target,
|
||||
source_label=resolved.public["source_label"],
|
||||
saved=saved.get("saved"),
|
||||
library=saved.get(
|
||||
"library",
|
||||
{"status": "unavailable" if saved_unavailable else "none"},
|
||||
),
|
||||
)
|
||||
_reauthorize_current_target(resolved, scope)
|
||||
identity = {
|
||||
**receipt,
|
||||
"history_epoch": epoch,
|
||||
"current_scope": scope.current_scope,
|
||||
"location_key": resolved.public["location_key"],
|
||||
"evidence_class": "locator_candidate",
|
||||
"observation": "current-file",
|
||||
}
|
||||
try:
|
||||
prepared = encode_history_inspection_response(
|
||||
current_output=identity, **observed
|
||||
)
|
||||
except ValueError:
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise HistoryReadError(422, "response-limit") from None
|
||||
else:
|
||||
prepared = _prepare_current_media(
|
||||
resolved, scope, thumbnail=kind == "thumbnail"
|
||||
)
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
return prepared
|
||||
except CurrentFileChanged:
|
||||
if prepared is not None and not isinstance(prepared, bytes):
|
||||
prepared.close()
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise _current_stale() from None
|
||||
except CurrentLocationUnavailable as error:
|
||||
if prepared is not None and not isinstance(prepared, bytes):
|
||||
prepared.close()
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise (
|
||||
_current_stale()
|
||||
if error.stale
|
||||
else _current_unavailable(error.reason)
|
||||
) from None
|
||||
except BaseException as error:
|
||||
if prepared is not None and not isinstance(prepared, bytes):
|
||||
prepared.close()
|
||||
if isinstance(error, Exception) and not isinstance(
|
||||
error, (HistoryReadError, HistoryEpochChanged, sqlite3.Error)
|
||||
):
|
||||
_check_current(scope, self, fence, epoch, generation)
|
||||
raise
|
||||
|
||||
pending = asyncio.create_task(self._run_in_executor(project))
|
||||
try:
|
||||
prepared = await asyncio.shield(pending)
|
||||
except asyncio.CancelledError:
|
||||
pending.add_done_callback(_close_prepared_result)
|
||||
raise
|
||||
except (
|
||||
HistoryReadError,
|
||||
HistoryEpochChanged,
|
||||
sqlite3.Error,
|
||||
OSError,
|
||||
ValueError,
|
||||
):
|
||||
raise
|
||||
except Exception as error:
|
||||
self.logger.warning(
|
||||
"Current history media unavailable (%s)", type(error).__name__
|
||||
)
|
||||
raise _current_unavailable("temporary_unavailable") from None
|
||||
if isinstance(prepared, bytes):
|
||||
return _success_response(prepared)
|
||||
return await _current_media_response(request, prepared)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Native reported locations and already-opened current media, without ownership."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import BinaryIO
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
|
||||
try:
|
||||
from ...utils.execution_outputs import (
|
||||
OutputReference,
|
||||
OutputRoots,
|
||||
snapshot_output_roots,
|
||||
validate_output_reference,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.execution_outputs import (
|
||||
OutputReference,
|
||||
OutputRoots,
|
||||
snapshot_output_roots,
|
||||
validate_output_reference,
|
||||
)
|
||||
|
||||
_SECRET = os.urandom(32)
|
||||
_READ_SIZE = 64 * 1024
|
||||
|
||||
|
||||
def _opaque(value: object) -> str:
|
||||
raw = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
||||
return hmac.new(_SECRET, raw, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentOutputScope:
|
||||
path_style: str
|
||||
lexical_output: str | None
|
||||
lexical_temp: str | None
|
||||
roots: OutputRoots
|
||||
canonical_output: Path | None
|
||||
canonical_temp: Path | None
|
||||
authorized_roots: tuple[Path, ...]
|
||||
current_scope: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedCurrentCandidate:
|
||||
public: dict
|
||||
canonical_target: Path | None = None
|
||||
namespace: Path | None = None
|
||||
gallery_root: Path | None = None
|
||||
|
||||
|
||||
class CurrentLocationUnavailable(Exception):
|
||||
"""A bounded current-file failure without the underlying private path."""
|
||||
|
||||
def __init__(self, reason: str, *, stale: bool = False) -> None:
|
||||
self.reason = reason
|
||||
self.stale = stale
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
def _host_directory(name: str) -> str | None:
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
value = getattr(folder_paths, name)()
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return None
|
||||
return value if type(value) is str else None
|
||||
|
||||
|
||||
def snapshot_current_output_scope(api: object) -> CurrentOutputScope:
|
||||
"""Bind host lexical namespaces independently of current gallery authority."""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
style = "windows" if os.name == "nt" else "posix"
|
||||
output = _host_directory("get_output_directory")
|
||||
temp = _host_directory("get_temp_directory")
|
||||
roots = snapshot_output_roots(style, output, temp)
|
||||
canonical_output = _canonical(output) if roots.output_key is not None else None
|
||||
canonical_temp = _canonical(temp) if roots.temp_key is not None else None
|
||||
authorized = tuple(
|
||||
sorted({_canonical(path) for path in api._get_all_output_dirs()})
|
||||
)
|
||||
policy = []
|
||||
for entry in GalleryConfig.MONITORING_DIRECTORIES:
|
||||
valid, _ = GalleryConfig.validate_gallery_root(entry)
|
||||
policy.append((entry if type(entry) is str else None, valid))
|
||||
current_scope = _opaque(
|
||||
[
|
||||
"prompt-manager-current-output-v1",
|
||||
style,
|
||||
output,
|
||||
temp,
|
||||
roots.output_key,
|
||||
roots.temp_key,
|
||||
str(canonical_output) if canonical_output is not None else None,
|
||||
str(canonical_temp) if canonical_temp is not None else None,
|
||||
[str(path) for path in authorized],
|
||||
sorted(
|
||||
str(_canonical(path))
|
||||
for path in GalleryConfig.allowed_gallery_parents()
|
||||
),
|
||||
sorted(policy, key=lambda item: (item[0] or "", item[1])),
|
||||
sorted(str(_canonical(path)) for path in GalleryConfig.path_anchors()),
|
||||
"gallery-validation-v1",
|
||||
]
|
||||
)
|
||||
return CurrentOutputScope(
|
||||
style,
|
||||
output,
|
||||
temp,
|
||||
roots,
|
||||
canonical_output,
|
||||
canonical_temp,
|
||||
authorized,
|
||||
current_scope,
|
||||
)
|
||||
|
||||
|
||||
def _descriptor_reason(reference: dict, scope: CurrentOutputScope) -> str | None:
|
||||
try:
|
||||
validate_output_reference(OutputReference("current", 0, **reference))
|
||||
except (TypeError, ValueError):
|
||||
return "namespace_mismatch"
|
||||
if reference["path_style"] != scope.path_style:
|
||||
return "foreign_path_style"
|
||||
folder = reference["folder_type"]
|
||||
expected = scope.roots.output_key if folder == "output" else scope.roots.temp_key
|
||||
if reference["root_key"] is None or expected is None:
|
||||
return "root_unavailable"
|
||||
if reference["root_key"] != expected:
|
||||
return "namespace_mismatch"
|
||||
return None
|
||||
|
||||
|
||||
def candidate_locator(reference: dict, scope: CurrentOutputScope) -> str | None:
|
||||
"""Validate the native namespace before constructing a lexical path; no IO."""
|
||||
if _descriptor_reason(reference, scope) is not None:
|
||||
return None
|
||||
root = (
|
||||
scope.lexical_output
|
||||
if reference["folder_type"] == "output"
|
||||
else scope.lexical_temp
|
||||
)
|
||||
return os.path.normcase(
|
||||
os.path.normpath(
|
||||
os.path.join(root, reference["subfolder"], reference["filename"])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _authorized(target: Path, namespace: Path, scope: CurrentOutputScope) -> bool:
|
||||
return target.is_relative_to(namespace) and any(
|
||||
target.is_relative_to(root) for root in scope.authorized_roots
|
||||
)
|
||||
|
||||
|
||||
def _unavailable(reason: str, *, error: bool = False) -> ResolvedCurrentCandidate:
|
||||
return ResolvedCurrentCandidate(
|
||||
{
|
||||
"status": "error" if error else "unavailable",
|
||||
"reason": reason,
|
||||
"evidence_class": "locator_candidate",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def resolve_current_candidate(
|
||||
reference: dict, scope: CurrentOutputScope, gallery: dict
|
||||
) -> ResolvedCurrentCandidate:
|
||||
"""Observe only this exact current locator and its snapshot-owned association."""
|
||||
reason = _descriptor_reason(reference, scope)
|
||||
if reason is not None:
|
||||
return _unavailable(reason)
|
||||
lexical = candidate_locator(reference, scope)
|
||||
namespace = (
|
||||
scope.canonical_output
|
||||
if reference["folder_type"] == "output"
|
||||
else scope.canonical_temp
|
||||
)
|
||||
try:
|
||||
if Path(reference["filename"]).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _unavailable("unsupported_media")
|
||||
if namespace is None or not namespace.is_dir():
|
||||
return _unavailable("root_unavailable")
|
||||
target = _canonical(lexical)
|
||||
if not _authorized(target, namespace, scope):
|
||||
return _unavailable("not_authorized")
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _unavailable("unsupported_media")
|
||||
observed = target.stat()
|
||||
if not stat.S_ISREG(observed.st_mode):
|
||||
return _unavailable("not_regular_file")
|
||||
if not os.access(target, os.R_OK) or not observed.st_mode & 0o444:
|
||||
return _unavailable("unreadable")
|
||||
matching = gallery.get(lexical, {})
|
||||
root = max(
|
||||
(root for root in scope.authorized_roots if target.is_relative_to(root)),
|
||||
key=lambda root: len(root.parts),
|
||||
)
|
||||
return ResolvedCurrentCandidate(
|
||||
{
|
||||
"status": "available",
|
||||
"reason": None,
|
||||
"evidence_class": "locator_candidate",
|
||||
"gallery_match": matching.get("gallery_match", "none"),
|
||||
"gallery_image_id": matching.get("gallery_image_id"),
|
||||
"source_label": _public_path(namespace),
|
||||
"relative_path": Path(
|
||||
reference["subfolder"], reference["filename"]
|
||||
).as_posix(),
|
||||
"current_scope": scope.current_scope,
|
||||
"location_key": _opaque(
|
||||
["current-location-v1", scope.current_scope, str(target)]
|
||||
),
|
||||
},
|
||||
target,
|
||||
namespace,
|
||||
root,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return _unavailable("missing")
|
||||
except PermissionError:
|
||||
return _unavailable("unreadable")
|
||||
except (OSError, ValueError):
|
||||
return _unavailable("temporary_unavailable", error=True)
|
||||
|
||||
|
||||
def _open_current_target(
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope
|
||||
) -> BinaryIO:
|
||||
"""Open only the canonical target and verify authority against the actual handle."""
|
||||
if _canonical(target) != target:
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
if not _authorized(target, namespace, scope):
|
||||
raise CurrentLocationUnavailable("not_authorized")
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
raise CurrentLocationUnavailable("unsupported_media")
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_BINARY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_NONBLOCK", 0)
|
||||
)
|
||||
try:
|
||||
fd = os.open(target, flags)
|
||||
except FileNotFoundError:
|
||||
raise CurrentLocationUnavailable("missing") from None
|
||||
except PermissionError:
|
||||
raise CurrentLocationUnavailable("unreadable") from None
|
||||
except OSError as error:
|
||||
if error.errno in (errno.ELOOP, errno.ENOTDIR):
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True) from None
|
||||
raise CurrentLocationUnavailable("temporary_unavailable") from None
|
||||
try:
|
||||
observed = os.fstat(fd)
|
||||
if not stat.S_ISREG(observed.st_mode):
|
||||
raise CurrentLocationUnavailable("not_regular_file")
|
||||
if not observed.st_mode & 0o444 or not os.access(target, os.R_OK):
|
||||
raise CurrentLocationUnavailable("unreadable")
|
||||
current = os.stat(target, follow_symlinks=False)
|
||||
if (
|
||||
_canonical(target) != target
|
||||
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
|
||||
or not _authorized(target, namespace, scope)
|
||||
):
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
return os.fdopen(fd, "rb")
|
||||
except FileNotFoundError:
|
||||
os.close(fd)
|
||||
raise CurrentLocationUnavailable("missing") from None
|
||||
except PermissionError:
|
||||
os.close(fd)
|
||||
raise CurrentLocationUnavailable("unreadable") from None
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _reauthorize_current_target(
|
||||
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope
|
||||
) -> None:
|
||||
if _canonical(
|
||||
resolved.canonical_target
|
||||
) != resolved.canonical_target or not _authorized(
|
||||
resolved.canonical_target, resolved.namespace, scope
|
||||
):
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OpenedCurrentMedia:
|
||||
handle: BinaryIO
|
||||
size: int
|
||||
content_type: str
|
||||
|
||||
def close(self) -> None:
|
||||
self.handle.close()
|
||||
|
||||
|
||||
def _prepare_current_media(
|
||||
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope, *, thumbnail: bool
|
||||
) -> _OpenedCurrentMedia:
|
||||
target = resolved.canonical_target
|
||||
if thumbnail:
|
||||
relative = target.relative_to(resolved.gallery_root)
|
||||
extension = (
|
||||
".jpg"
|
||||
if target.suffix.lower() in VIDEO_EXTENSIONS
|
||||
else target.suffix.lower()
|
||||
)
|
||||
preview = (
|
||||
resolved.gallery_root
|
||||
/ "thumbnails"
|
||||
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
|
||||
)
|
||||
try:
|
||||
preview.stat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
target = _canonical(preview)
|
||||
handle = _open_current_target(target, resolved.namespace, scope)
|
||||
try:
|
||||
return _OpenedCurrentMedia(
|
||||
handle,
|
||||
os.fstat(handle.fileno()).st_size,
|
||||
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
|
||||
)
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
|
||||
|
||||
async def _current_media_response(
|
||||
request: web.Request, opened: _OpenedCurrentMedia
|
||||
) -> web.StreamResponse:
|
||||
"""Stream a bounded single range from a previously authorized handle."""
|
||||
try:
|
||||
try:
|
||||
selected = request.http_range
|
||||
start, stop = selected.start, selected.stop
|
||||
partial = request.headers.get("Range") is not None
|
||||
if (
|
||||
partial
|
||||
and request.headers["Range"].startswith("bytes=-")
|
||||
and start == 0
|
||||
):
|
||||
raise ValueError("Empty suffix range")
|
||||
if start is not None and start < 0:
|
||||
start = max(0, opened.size + start)
|
||||
start = start or 0
|
||||
stop = min(opened.size, stop) if stop is not None else opened.size
|
||||
if partial and (start >= stop or start >= opened.size):
|
||||
raise ValueError("Unsatisfiable current media range")
|
||||
except ValueError:
|
||||
return web.Response(
|
||||
status=416,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Range": f"bytes */{opened.size}",
|
||||
},
|
||||
)
|
||||
headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": opened.content_type,
|
||||
"Content-Length": str(stop - start),
|
||||
}
|
||||
if partial:
|
||||
headers["Content-Range"] = f"bytes {start}-{stop - 1}/{opened.size}"
|
||||
response = web.StreamResponse(status=206 if partial else 200, headers=headers)
|
||||
await response.prepare(request)
|
||||
if request.method != "HEAD":
|
||||
opened.handle.seek(start)
|
||||
remaining = stop - start
|
||||
while remaining:
|
||||
chunk = await asyncio.get_running_loop().run_in_executor(
|
||||
None, opened.handle.read, min(_READ_SIZE, remaining)
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
await response.write(chunk)
|
||||
await response.write_eof()
|
||||
return response
|
||||
finally:
|
||||
opened.close()
|
||||
+20
-80
@@ -20,41 +20,23 @@ from PIL import Image, UnidentifiedImageError
|
||||
from .prompts import bad_request, parse_page_params, publish_image_paths
|
||||
from .prompts import thumbnail_url_for
|
||||
from .prompts import safe_error_message as _safe_error
|
||||
from .inspection_service import (
|
||||
CurrentFileChanged,
|
||||
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
|
||||
SERVABLE_EXTENSIONS,
|
||||
VIDEO_EXTENSIONS,
|
||||
inspect_current_file,
|
||||
)
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.inspection_response import encode_inspection_response
|
||||
from ...utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.parallel import map_parallel
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
|
||||
# Only these file types are ever served by the image routes, regardless of
|
||||
# what sits inside an allowed directory (defence in depth for the gallery).
|
||||
IMAGE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
}
|
||||
)
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def _public_path(path):
|
||||
@@ -106,11 +88,7 @@ def _media_access_error(path, allowed_dirs):
|
||||
|
||||
# Media types the gallery scans and thumbnails (a superset is never served).
|
||||
GALLERY_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif"})
|
||||
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
|
||||
MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
# What the serve routes hand out: every image type above plus every video type
|
||||
# the gallery lists, so a card never points at a 403.
|
||||
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
# Caps on request-triggered filesystem work.
|
||||
MAX_SCAN_DEPTH = 12
|
||||
@@ -1732,18 +1710,6 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
|
||||
def inspect() -> bytes | None:
|
||||
before = path.stat()
|
||||
try:
|
||||
inspection = read_inspection_file(path)
|
||||
except MetadataReadError:
|
||||
inspection = None
|
||||
try:
|
||||
after = path.stat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns")
|
||||
if any(getattr(before, key) != getattr(after, key) for key in fields):
|
||||
return None
|
||||
saved, unavailable = None, False
|
||||
try:
|
||||
saved = self.db.get_image_prompt_info(
|
||||
@@ -1752,25 +1718,17 @@ class ImageRoutesMixin:
|
||||
except Exception:
|
||||
unavailable = True
|
||||
self.logger.warning("Saved inspection display could not be read")
|
||||
if inspection is None:
|
||||
evidence = {
|
||||
"prompts": {
|
||||
role: {"status": "unresolved", "text": None, "source": None}
|
||||
for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "Media metadata could not be read",
|
||||
},
|
||||
}
|
||||
else:
|
||||
role_saved = (
|
||||
None
|
||||
if saved and saved.get("association_status") == "ambiguous"
|
||||
else saved
|
||||
try:
|
||||
observed = inspect_current_file(
|
||||
path,
|
||||
source_label=_public_path(roots[index]),
|
||||
saved=saved,
|
||||
library=self._inspection_library(
|
||||
saved, unavailable=unavailable
|
||||
),
|
||||
)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
|
||||
except CurrentFileChanged:
|
||||
return None
|
||||
return encode_inspection_response(
|
||||
image={
|
||||
"root_index": index,
|
||||
@@ -1778,25 +1736,7 @@ class ImageRoutesMixin:
|
||||
"filename": path.name,
|
||||
},
|
||||
root_scope=root_scope,
|
||||
file={
|
||||
"observation": "current-file",
|
||||
"source_label": _public_path(roots[index]),
|
||||
"media_type": (
|
||||
"video"
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
else "image"
|
||||
),
|
||||
"extension": path.suffix.lower(),
|
||||
"size": before.st_size,
|
||||
"modified_time": before.st_mtime,
|
||||
"format": None,
|
||||
"width": None,
|
||||
"height": None,
|
||||
},
|
||||
prompts=evidence["prompts"],
|
||||
resolution=evidence["resolution"],
|
||||
inspection=inspection,
|
||||
library=self._inspection_library(saved, unavailable=unavailable),
|
||||
**observed,
|
||||
)
|
||||
|
||||
body = await self._run_in_executor(inspect)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""One bounded current-file inspection worker shared by physical identities."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ...utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
from ...utils.video_metadata import is_video_path
|
||||
except ImportError:
|
||||
from utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_evidence,
|
||||
read_inspection_file,
|
||||
)
|
||||
from utils.video_metadata import is_video_path
|
||||
|
||||
|
||||
# Only these file types are served or inspected within an authorized directory.
|
||||
IMAGE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tiff",
|
||||
".tif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
}
|
||||
)
|
||||
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
|
||||
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class CurrentFileChanged(Exception):
|
||||
"""File facts are invalid or changed across the bounded metadata observation."""
|
||||
|
||||
|
||||
def _current_file_facts(path: Path) -> os.stat_result:
|
||||
if path.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
raise CurrentFileChanged
|
||||
try:
|
||||
observed = path.stat()
|
||||
except (FileNotFoundError, NotADirectoryError, PermissionError):
|
||||
raise CurrentFileChanged from None
|
||||
if (
|
||||
not stat.S_ISREG(observed.st_mode)
|
||||
or not observed.st_mode & 0o444
|
||||
or not os.access(path, os.R_OK)
|
||||
):
|
||||
raise CurrentFileChanged
|
||||
return observed
|
||||
|
||||
|
||||
def inspect_current_file(
|
||||
path: Path, *, source_label: str, saved: dict | None, library: dict
|
||||
) -> dict:
|
||||
before = _current_file_facts(path)
|
||||
try:
|
||||
inspection = read_inspection_file(path)
|
||||
except MetadataReadError:
|
||||
inspection = None
|
||||
after = _current_file_facts(path)
|
||||
fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
|
||||
if any(getattr(before, key) != getattr(after, key) for key in fields):
|
||||
raise CurrentFileChanged
|
||||
if inspection is None:
|
||||
evidence = {
|
||||
"prompts": {
|
||||
role: {"status": "unresolved", "text": None, "source": None}
|
||||
for role in ("positive", "negative")
|
||||
},
|
||||
"resolution": {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": "Media metadata could not be read",
|
||||
},
|
||||
}
|
||||
else:
|
||||
role_saved = (
|
||||
None if saved and saved.get("association_status") == "ambiguous" else saved
|
||||
)
|
||||
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
|
||||
return {
|
||||
"file": {
|
||||
"observation": "current-file",
|
||||
"source_label": source_label,
|
||||
"media_type": "video" if is_video_path(path) else "image",
|
||||
"extension": path.suffix.lower(),
|
||||
"size": before.st_size,
|
||||
"modified_time": before.st_mtime,
|
||||
"format": None,
|
||||
"width": None,
|
||||
"height": None,
|
||||
},
|
||||
**evidence,
|
||||
"inspection": inspection,
|
||||
"library": library,
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
// Content, row counts, and image bytes come from independent synthetic datasets.
|
||||
// Saved choices and actual executions are separate production sections. The concept's
|
||||
// illustrative repeat dialog is compared with Recorded run for native behavior;
|
||||
// frozen evidence and current-file/unknown copy intentionally describe actual data.
|
||||
// Parent acceptance scopes exact comparisons to history geometry/roles/native state.
|
||||
// No content is masked and the immutable concept is never rewritten for comparison.
|
||||
export const canonicalDifferences = {
|
||||
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
|
||||
semantics: [
|
||||
'Saved combinations are distinct from the additional chronological executions',
|
||||
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
remainingApplicationPolish: [
|
||||
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
|
||||
'Shared Viewer stage background and toolbar alignment remain outside #209',
|
||||
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
|
||||
export async function captureCanonicalState(page, testInfo, name, selector) {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
for (const image of document.images) image.loading = 'eager';
|
||||
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
const target = selector ? page.locator(selector).first() : page;
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await target.screenshot({ path, animations: 'disabled' });
|
||||
await testInfo.attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
export async function historyGeometry(page, production) {
|
||||
const selectors = production ? {
|
||||
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
|
||||
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
|
||||
positiveLabel: '.library-positive-lane span',
|
||||
role: '[aria-label="Saved prompt texts"] .prompt-block',
|
||||
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
|
||||
heading: '.library-variants h2', intro: '.library-variants > p.muted',
|
||||
footer: '.library-variant-footer', all: '.library-variants button:last-child',
|
||||
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
|
||||
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
|
||||
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
|
||||
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
|
||||
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
|
||||
} : {
|
||||
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
|
||||
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
|
||||
positiveLabel: '.positive-lane span',
|
||||
prompt: '.surface .prompt-text', gallery: '.history-gallery',
|
||||
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
|
||||
footer: '.timeline-lower', all: '.timeline-lower button',
|
||||
repeat: '.timeline .run-dot',
|
||||
};
|
||||
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
|
||||
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
|
||||
const nodes = [...document.querySelectorAll(selector)];
|
||||
return [key, nodes.map(node => {
|
||||
const style = getComputedStyle(node), before = getComputedStyle(node, '::before');
|
||||
const box = node.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height,
|
||||
padding: style.padding, top: style.top, left: style.left, background: style.backgroundColor, color: style.color,
|
||||
border: style.borderTopWidth, borderColor: style.borderTopColor, radius: style.borderRadius,
|
||||
fontSize: style.fontSize, fontFamily: style.fontFamily, lineHeight: style.lineHeight,
|
||||
fontWeight: style.fontWeight, text: node.textContent, contextId: node.dataset.contextId || node.dataset.contextBranch,
|
||||
runId: node.dataset.runId,
|
||||
marginLeft: style.marginLeft, outline: style.outlineWidth, outlineColor: style.outlineColor,
|
||||
outlineOffset: style.outlineOffset, before: { width: before.width, height: before.height,
|
||||
top: before.top, left: before.left, borderLeft: before.borderLeftWidth,
|
||||
borderBottom: before.borderBottomWidth, radius: before.borderBottomLeftRadius,
|
||||
background: before.backgroundColor, color: before.color, borderColor: before.borderTopColor,
|
||||
borderLeftColor: before.borderLeftColor, borderBottomColor: before.borderBottomColor,
|
||||
fontSize: before.fontSize, fontFamily: before.fontFamily, fontWeight: before.fontWeight,
|
||||
content: before.content } };
|
||||
})];
|
||||
})) }), selectors);
|
||||
}
|
||||
|
||||
export async function nativeDialogState(locator) {
|
||||
return locator.evaluate(node => {
|
||||
const style = getComputedStyle(node), box = node.getBoundingClientRect();
|
||||
return { modal: node.matches(':modal'), focusInside: node.contains(document.activeElement),
|
||||
background: style.backgroundColor, color: style.color, borderColor: style.borderTopColor,
|
||||
border: style.borderTopWidth, width: box.width, height: box.height,
|
||||
viewportWidth: innerWidth, viewportHeight: innerHeight };
|
||||
});
|
||||
}
|
||||
|
||||
export async function exerciseCurrentPairSend(page, roles) {
|
||||
const host = await page.context().newPage();
|
||||
await host.goto('/prompt_manager/workspace#/overview');
|
||||
try {
|
||||
await host.evaluate(async () => {
|
||||
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target = { id: 'canonical-pair', label: 'Canonical destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
|
||||
window.canonicalApplied = [];
|
||||
window.canonicalHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Canonical ComfyUI', adapter: {
|
||||
listTargets: () => [target],
|
||||
preview: (_id, roles) => ({ preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: '', after })),
|
||||
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: '', after: roles.positive } }, snapshot: roles }),
|
||||
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
|
||||
} });
|
||||
});
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
|
||||
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
|
||||
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(() => window.canonicalApplied)).toEqual([roles]);
|
||||
await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
} finally {await host.evaluate(() => window.canonicalHost?.dispose());await host.close();}
|
||||
}
|
||||
|
||||
export async function compareCanonicalHistory(reference, actual, testInfo) {
|
||||
const path = testInfo.outputPath('canonical-history-geometry.json');
|
||||
await writeFile(path, JSON.stringify({ reference, actual, comparisonScope: canonicalDifferences }, null, 2));
|
||||
await testInfo.attach('canonical-history-geometry', { path, contentType: 'application/json' });
|
||||
for (const geometry of [reference, actual]) {
|
||||
expect.soft(geometry.role).toHaveLength(2);
|
||||
expect.soft(geometry.prompt).toHaveLength(2);
|
||||
expect.soft(geometry.heading[0].fontSize).toBe('14px');
|
||||
expect.soft(geometry.intro[0].fontSize).toBe('12px');
|
||||
expect.soft(geometry.footer).toHaveLength(1);
|
||||
expect.soft(geometry.positiveLabel).toHaveLength(1);
|
||||
expect.soft(geometry.positiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
|
||||
expect.soft(geometry.surface[0].padding).toBe('22px');
|
||||
expect.soft(geometry.surface[0].radius).toBe('7px');
|
||||
expect.soft(geometry.timeline[0].padding).toBe('18px 20px');
|
||||
expect.soft(geometry.positive[0].border).toBe('2px');
|
||||
for (const [index, branch] of geometry.branch.entries()) {
|
||||
expect.soft(branch.height).toBe(57);
|
||||
expect.soft(branch.border).toBe('2px');
|
||||
expect.soft(branch.marginLeft).toBe(`${38 + index * 47}px`);
|
||||
expect.soft(branch.before).toMatchObject({ width: '30px', height: '25px', top: '-25px', left: '-30px', borderLeft: '2px', borderBottom: '2px', radius: '22px' });
|
||||
}
|
||||
}
|
||||
expect.soft(actual.recordedPositiveLabel).toHaveLength(1);
|
||||
expect.soft(actual.recordedPositiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
|
||||
expect.soft(actual.recordedHeading[0].fontSize).toBe(reference.heading[0].fontSize);
|
||||
expect.soft(actual.recordedCanvas[0].width).toBeGreaterThanOrEqual(actual.recordedTimeline[0].width - 42);
|
||||
for (const geometry of [reference, actual]) {
|
||||
expect.soft(geometry.runDialog).toMatchObject({ modal: true, focusInside: true, border: '1px' });
|
||||
expect.soft(geometry.runDialog.width).toBeLessThanOrEqual(geometry.runDialog.viewportWidth);
|
||||
expect.soft(geometry.runDialog.height).toBeLessThanOrEqual(geometry.runDialog.viewportHeight);
|
||||
}
|
||||
for (const property of ['background', 'color', 'borderColor']) expect.soft(actual.runDialog[property]).toBe(reference.runDialog[property]);
|
||||
for (const [key, properties] of [
|
||||
['surface', ['padding', 'background', 'border', 'borderColor', 'radius']],
|
||||
['timeline', ['padding', 'background', 'border', 'borderColor', 'radius']],
|
||||
['positive', ['border', 'borderColor']],
|
||||
['positiveLabel', ['color', 'fontSize', 'top', 'left']],
|
||||
['branch', ['height', 'border', 'borderColor', 'fontSize']],
|
||||
['label', ['color', 'fontSize']],
|
||||
['dot', ['background', 'borderColor', 'radius', 'outline', 'outlineColor', 'outlineOffset', 'color', 'fontSize']],
|
||||
]) {
|
||||
for (const property of properties) expect.soft(actual[key][0]?.[property], `${key}.${property}`).toBe(reference[key][0][property]);
|
||||
}
|
||||
for (const property of ['color', 'background']) expect.soft(actual.positive[0].before[property]).toBe(reference.positive[0].before[property]);
|
||||
for (const [index, block] of actual.role.entries()) for (const property of ['border', 'borderColor', 'fontSize']) expect.soft(block[property]).toBe(reference.role[index][property]);
|
||||
for (const [index, prompt] of actual.prompt.entries()) for (const property of ['fontSize', 'lineHeight', 'fontFamily']) expect.soft(prompt[property]).toBe(reference.prompt[index][property]);
|
||||
assertRecordedHistory(reference, actual, { branchCount: 1, anchorCount: 1, repeatCount: 0 });
|
||||
}
|
||||
|
||||
export function assertRecordedHistory(reference, actual, { branchCount, anchorCount, repeatCount }) {
|
||||
expect(branchCount).toBeGreaterThan(0);
|
||||
expect(reference.branch.length).toBeGreaterThanOrEqual(2);
|
||||
expect(reference.dot).toHaveLength(1);
|
||||
expect(reference.repeat.length).toBeGreaterThan(0);
|
||||
expect.soft(actual.recordedBranch, 'chronological branches').toHaveLength(branchCount);
|
||||
expect.soft(actual.recordedLabel, 'chronological labels').toHaveLength(branchCount);
|
||||
expect.soft(actual.recordedAnchor, 'chronological anchors').toHaveLength(anchorCount);
|
||||
expect.soft(actual.recordedRepeat, 'chronological repeats').toHaveLength(repeatCount);
|
||||
expect.soft(actual.recordedMarker).toHaveLength(anchorCount + repeatCount);
|
||||
expect.soft(actual.recordedColumn).toHaveLength(anchorCount + repeatCount);
|
||||
expect.soft(actual.recordedPositive).toHaveLength(1);
|
||||
for (const property of ['border', 'borderColor']) expect.soft(actual.recordedPositive[0][property], `recorded positive ${property}`).toBe(reference.positive[0][property]);
|
||||
for (const property of ['color', 'background', 'width', 'height', 'fontSize']) expect.soft(actual.recordedPositive[0].before[property], `recorded positive glyph ${property}`).toBe(reference.positive[0].before[property]);
|
||||
for (const [index, branch] of actual.recordedBranch.entries()) {
|
||||
for (const property of ['height', 'border', 'borderColor']) expect.soft(branch[property], `recorded branch ${index} ${property}`).toBe(reference.branch[0][property]);
|
||||
for (const property of ['width', 'height', 'top', 'left', 'borderLeft', 'borderBottom', 'radius', 'borderLeftColor', 'borderBottomColor']) expect.soft(branch.before[property], `recorded curve ${index} ${property}`).toBe(reference.branch[0].before[property]);
|
||||
if (index) expect.soft(branch.y - actual.recordedBranch[index - 1].y, 'recorded 57px branch rhythm').toBe(reference.branch[1].y - reference.branch[0].y);
|
||||
}
|
||||
for (const label of actual.recordedLabel) for (const property of ['color', 'fontSize']) expect.soft(label[property], `recorded label ${property}`).toBe(reference.label[0][property]);
|
||||
for (const [index, column] of actual.recordedColumn.entries()) {
|
||||
expect.soft(column.left).toBe(`${38 + index * 64}px`);
|
||||
if (index) expect.soft(column.x - actual.recordedColumn[index - 1].x, '64px chronological columns').toBe(64);
|
||||
}
|
||||
const checkMarker = (marker, canonical, anchor) => {
|
||||
const visual = actual.pointerCoarse ? marker.before : marker;
|
||||
const size = anchor ? 29 : 12;
|
||||
expect.soft(visual.width, 'recorded marker visual width').toBe(actual.pointerCoarse ? `${size}px` : size);
|
||||
expect.soft(visual.height, 'recorded marker visual height').toBe(actual.pointerCoarse ? `${size}px` : size);
|
||||
for (const property of ['background', 'borderColor', 'radius']) expect.soft(visual[property], `recorded marker ${property}`).toBe(canonical[property]);
|
||||
if (actual.pointerCoarse) {
|
||||
expect.soft(marker.width, 'coarse marker hit width').toBeGreaterThanOrEqual(44);
|
||||
expect.soft(marker.height, 'coarse marker hit height').toBeGreaterThanOrEqual(44);
|
||||
expect.soft(visual.borderLeft).toBe('2px');
|
||||
} else expect.soft(marker.border).toBe(canonical.border);
|
||||
if (anchor) {
|
||||
expect.soft(marker.text).toBe('−');
|
||||
if (actual.pointerCoarse) expect.soft(visual.content).toBe('"−"');
|
||||
for (const property of ['color', 'fontSize', 'fontFamily', 'fontWeight']) expect.soft(visual[property], `recorded minus ${property}`).toBe(canonical[property]);
|
||||
const branch = actual.recordedBranch.find(value => value.contextId === marker.contextId);
|
||||
expect(branch, 'each context anchor has its real branch').toBeTruthy();
|
||||
expect.soft(Math.abs(marker.x + marker.width / 2 - branch.x)).toBeLessThanOrEqual(1);
|
||||
expect.soft(Math.abs(marker.y + marker.height / 2 - branch.y)).toBeLessThanOrEqual(1);
|
||||
} else expect.soft(marker.text).toBe('');
|
||||
};
|
||||
for (const marker of actual.recordedAnchor) checkMarker(marker, reference.dot[0], true);
|
||||
for (const marker of actual.recordedRepeat) checkMarker(marker, reference.repeat[0], false);
|
||||
}
|
||||
@@ -16,6 +16,25 @@ from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
from tests.browser.workspace_inspector_fixture import seed_workspace_inspector
|
||||
from tests.browser.workspace_history_fixture import (
|
||||
capture_workspace_history_zero_outputs,
|
||||
register_workspace_history_commands,
|
||||
seed_workspace_history,
|
||||
)
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
"/__test__/canonical/workspace": (
|
||||
"docs/concepts/workspace/index.html",
|
||||
"text/html",
|
||||
),
|
||||
"/workspace/app.js": ("docs/concepts/workspace/app.js", "text/javascript"),
|
||||
"/workspace/style.css": ("docs/concepts/workspace/style.css", "text/css"),
|
||||
"/toolkit/core.js": ("web/js/shared/toolkit/core.js", "text/javascript"),
|
||||
"/toolkit/controls.css": ("web/js/shared/toolkit/controls.css", "text/css"),
|
||||
"/vendor/viewer.js": ("web/lib/viewerjs/viewer.min.js", "text/javascript"),
|
||||
"/vendor/viewer.css": ("web/lib/viewerjs/viewer.min.css", "text/css"),
|
||||
}
|
||||
|
||||
|
||||
class TestModel:
|
||||
@@ -80,6 +99,18 @@ def create_app(root: Path) -> web.Application:
|
||||
autotag.get_autotag_service = lambda: model
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
return web.Response(
|
||||
body=(CANONICAL_ROOT / filename).read_bytes(),
|
||||
content_type=content_type,
|
||||
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||
)
|
||||
|
||||
for path in CANONICAL_ASSETS:
|
||||
routes.get(path)(canonical_asset)
|
||||
|
||||
def seed(
|
||||
extra_images: int = 0,
|
||||
@@ -223,9 +254,50 @@ def create_app(root: Path) -> web.Application:
|
||||
api.db.link_image_to_prompt(video_prompt_id, str(video))
|
||||
api.invalidate_gallery_cache()
|
||||
|
||||
history_fixture = None
|
||||
|
||||
@routes.post("/__test__/history/capture-zero-outputs")
|
||||
async def capture_history_outputs(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"run_id"}
|
||||
or history_fixture is None
|
||||
or data["run_id"] not in history_fixture["runs"].values()
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
captured = await api._run_in_executor(
|
||||
capture_workspace_history_zero_outputs, api, data["run_id"]
|
||||
)
|
||||
return web.json_response({"success": captured})
|
||||
|
||||
@routes.post("/__test__/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
nonlocal history_fixture
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict):
|
||||
raise web.HTTPBadRequest()
|
||||
chronological_history = data.get("chronological_history", False)
|
||||
history_runs = data.get("history_runs", 0)
|
||||
if (
|
||||
not isinstance(chronological_history, bool)
|
||||
or isinstance(history_runs, bool)
|
||||
or not isinstance(history_runs, int)
|
||||
or not 0 <= history_runs <= 1200
|
||||
or (not chronological_history and "history_runs" in data)
|
||||
or (
|
||||
chronological_history
|
||||
and set(data) - {"chronological_history", "history_runs"}
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
if chronological_history:
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_history(api, root, history_runs=history_runs)
|
||||
)
|
||||
history_fixture = manifest
|
||||
return web.json_response({"success": True, **manifest})
|
||||
history_commands.reset()
|
||||
workspace_visual = data.get("workspace_visual", False)
|
||||
full_inspector = data.get("full_inspector", False)
|
||||
extra = data.get("extra_images", 0)
|
||||
@@ -277,15 +349,18 @@ def create_app(root: Path) -> web.Application:
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
if workspace_visual:
|
||||
history_fixture = None
|
||||
await api._run_in_executor(seed_workspace_visual, api, root)
|
||||
return web.json_response({"success": True})
|
||||
if full_inspector:
|
||||
history_fixture = None
|
||||
await api._run_in_executor(
|
||||
lambda: seed_workspace_inspector(
|
||||
api, root, extra_images=extra, include_video=include_video
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
history_fixture = None
|
||||
await api._run_in_executor(
|
||||
seed,
|
||||
extra,
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {readFile,writeFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const recorded=page=>page.getByRole('region',{name:'Recorded executions',exact:true});
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
|
||||
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
|
||||
const exact=async locator=>locator.evaluate(node=>node.textContent);
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);page.historyErrors=[];page.on('pageerror',error=>page.historyErrors.push(error.message));
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});await expect(reset).toBeOK();page.fixture=await reset.json();
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.historyErrors).toEqual([]);});
|
||||
|
||||
const command=async(request,data)=>{const response=await request.post('/__test__/history/commands',{data});await expect(response).toBeOK();return response.json();};
|
||||
const restoreSnapshot=async(request,bytes)=>{const response=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'history.db',mimeType:'application/octet-stream',buffer:bytes}}});await expect(response).toBeOK();expect((await response.json()).success).toBe(true);};
|
||||
const historyRead=async(request,id)=>{const response=await request.get(`/prompt_manager/library/${id}/history`);await expect(response).toBeOK();return response.json();};
|
||||
const frozenContexts=contexts=>contexts.map(({navigation,positive_prompt_id,negative_prompt_id,pair_id,...frozen})=>frozen);
|
||||
const immutableReceipts=items=>items.map(({current_candidate,...receipt})=>receipt);
|
||||
|
||||
async function reviewCapture(page,testInfo,name,target) {
|
||||
await target.scrollIntoViewIfNeeded();await expect(target).toBeVisible();await page.evaluate(()=>document.fonts.ready);
|
||||
const path=testInfo.outputPath(`${name}.png`);await target.screenshot({path,animations:'disabled'});await testInfo.attach(name,{path,contentType:'image/png'});
|
||||
const geometry=await target.evaluate(node=>{
|
||||
const box=node.getBoundingClientRect();
|
||||
return {bounds:{x:box.x,y:box.y,width:box.width,height:box.height},scrollLeft:node.scrollLeft,scrollTop:node.scrollTop,
|
||||
buttons:[...node.querySelectorAll('button')].filter(button=>button.checkVisibility()).map(button=>{const box=button.getBoundingClientRect();return {label:button.getAttribute('aria-label')||button.textContent,disabled:button.disabled,pressed:button.getAttribute('aria-pressed'),width:box.width,height:box.height};})};
|
||||
});
|
||||
const geometryPath=testInfo.outputPath(`${name}-geometry.json`);await writeFile(geometryPath,JSON.stringify(geometry,null,2));
|
||||
await testInfo.attach(`${name}-geometry`,{path:geometryPath,contentType:'application/json'});
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
test('owned history commands validate fixed boundaries and expire after ordinary reset',async({request})=>{
|
||||
for(const data of [{}, {action:'arm',boundary:'arbitrary'}, {action:'replace-file',path:'/tmp/other'}, {action:'release',extra:true}, {action:'restore'}]) expect((await request.post('/__test__/history/commands',{data})).status()).toBe(400);
|
||||
await command(request,{action:'arm',boundary:'window'});await command(request,{action:'release'});
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
expect((await request.post('/__test__/history/commands',{data:{action:'replace-file'}})).status()).toBe(409);
|
||||
expect((await request.get('/__test__/history/snapshot')).status()).toBe(409);
|
||||
});
|
||||
|
||||
for(const boundary of ['window','detail','candidate','inspection','media']) test(`real restore rejects in-flight ${boundary} publication and all old epoch URLs`,async({request,page})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),epoch=window.history_epoch;
|
||||
const base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
const detail=await(await request.get(`${base}?epoch=${epoch}`)).json();
|
||||
const firstWindow=await(await request.get(`/prompt_manager/library/${f.detail_id}/history?limit=1`)).json();expect(firstWindow.next_cursor).toBeTruthy();
|
||||
const outputs=await(await request.get(`${base}/outputs?epoch=${epoch}&limit=1`)).json();
|
||||
const allOutputs=await(await request.get(`${base}/outputs?epoch=${epoch}`)).json();
|
||||
const media=outputs.items[0].current_candidate.media;
|
||||
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
|
||||
const bytes=await snapshot.body();
|
||||
const target={window:`/prompt_manager/library/${f.detail_id}/history?limit=1`,detail:`${base}?epoch=${epoch}`,candidate:`${base}/outputs?epoch=${epoch}`,inspection:media.inspection_url,media:media.url}[boundary];
|
||||
await command(request,{action:'arm',boundary});const pending=request.get(target);
|
||||
try {
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await restoreSnapshot(request,bytes);
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const retired=await pending;expect(retired.status()).toBe(409);expect((await retired.json()).code).toBe('history-changed');
|
||||
for(const url of [`/prompt_manager/library/${f.detail_id}/history?limit=1&cursor=${encodeURIComponent(firstWindow.next_cursor)}`,`${base}?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}`,`${base}/outputs?epoch=${epoch}&cursor=${encodeURIComponent(outputs.next_cursor)}`,media.url,media.thumbnail_url,media.inspection_url]) {
|
||||
const response=await request.get(url);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
|
||||
}
|
||||
const fresh=await historyRead(request,f.detail_id);expect(fresh.history_epoch).not.toBe(epoch);
|
||||
const after=await(await request.get(`${base}?epoch=${fresh.history_epoch}`)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(detail.contexts));
|
||||
for(const context of after.contexts) {expect(context.positive_prompt_id).toBeNull();expect(context.navigation).toEqual({positive_available:false,negative_available:false,pair_available:false});}
|
||||
const freshOutputs=await(await request.get(`${base}/outputs?epoch=${fresh.history_epoch}`)).json();
|
||||
expect(immutableReceipts(freshOutputs.items)).toEqual(immutableReceipts(allOutputs.items));
|
||||
expect(freshOutputs.items).toHaveLength(5);await expect(await request.get(`/prompt_manager/library/${f.detail_id}`)).toBeOK();
|
||||
});
|
||||
|
||||
test('same-path replacement is current content and a during-inspection change is rejected',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
|
||||
const original=await request.get(media.url);expect(original.headers()['cache-control']).toBe('no-store');const bytes=await original.body();
|
||||
await command(request,{action:'arm',boundary:'inspection'});const pending=request.get(media.inspection_url);
|
||||
try {
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await command(request,{action:'replace-file'});
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const changed=await pending;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('candidate-stale');
|
||||
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
|
||||
const current=await request.get(after.items[0].current_candidate.media.url);await expect(current).toBeOK();expect(await current.body()).not.toEqual(bytes);
|
||||
for(const field of ['url','thumbnail_url','inspection_url'])expect((await request.get(after.items[0].current_candidate.media[field])).headers()['cache-control']).toBe('no-store');
|
||||
expect(after.items[0]).toMatchObject({file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown'});
|
||||
});
|
||||
|
||||
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
|
||||
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
|
||||
try {
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await command(request,{action:'reconfigure-root'});
|
||||
} finally {await command(request,{action:'release'});}
|
||||
expect((await pending).status()).toBe(409);
|
||||
for(const field of ['url','thumbnail_url','inspection_url']) {const response=await request.get(media[field]);expect(response.status()).toBe(409);expect((await response.json()).code).toBe('candidate-stale');}
|
||||
const after=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(after.items)).toEqual(immutableReceipts(before.items));
|
||||
expect(after.items[0].current_candidate).toMatchObject({status:'unavailable',reason:'namespace_mismatch'});
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('namespace_mismatch');
|
||||
await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('archive delete merge and prune preserve frozen evidence without repairing original saved hints',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`;
|
||||
const before=await(await request.get(base)).json(),outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${f.saved_pair}`,{data:{archived:true}})).toBeOK();
|
||||
expect(frozenContexts((await(await request.get(base)).json()).contexts)).toEqual(frozenContexts(before.contexts));
|
||||
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
|
||||
for(const pair of details.variants)await expect(await request.delete(`/prompt_manager/pairs/${pair.id}`)).toBeOK();
|
||||
const text=details.positive.text;
|
||||
expect(f.merge_duplicate_id).not.toBe(f.detail_id);
|
||||
const merge=await request.post('/prompt_manager/cleanup');await expect(merge).toBeOK();expect((await merge.json()).duplicates_removed).toBeGreaterThan(0);
|
||||
await expect(await request.delete(`/prompt_manager/delete/${f.detail_id}`)).toBeOK();
|
||||
await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:text,negative_text:'replacement saved negative'}})).toBeOK();
|
||||
const prune=await request.post('/prompt_manager/maintenance',{data:{operations:['prune_orphaned_prompts']}});await expect(prune).toBeOK();expect((await prune.json()).all_successful).toBe(true);
|
||||
const after=await(await request.get(base)).json();expect(frozenContexts(after.contexts)).toEqual(frozenContexts(before.contexts));
|
||||
for(const context of after.contexts) {expect(context.navigation.positive_available).toBe(false);expect(context.positive_prompt_id).toBeNull();}
|
||||
const receipts=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(immutableReceipts(receipts.items)).toEqual(immutableReceipts(outputs.items));
|
||||
});
|
||||
|
||||
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
|
||||
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
|
||||
});
|
||||
|
||||
for(const outcome of ['success','error'])test(`delayed actual old browser ${outcome} cannot repaint after restore and route return`,async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const snapshot=await request.get('/__test__/history/snapshot');const bytes=await snapshot.body();
|
||||
let entered,release;const started=new Promise(resolve=>{entered=resolve;}),held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{
|
||||
if(outcome==='error')await restoreSnapshot(request,bytes);
|
||||
const response=await route.fetch();expect(response.status()).toBe(outcome==='success'?200:409);entered();await held;
|
||||
await route.fulfill({response}).catch(()=>{});
|
||||
});
|
||||
try {
|
||||
await marker(page,f.runs.shared).click();await started;if(outcome==='success')await restoreSnapshot(request,bytes);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');release();
|
||||
await page.unroute(new RegExp(`/history/runs/${f.runs.shared}\\?`));await open(page,f.detail_id);
|
||||
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
await recorded(page).getByRole('button',{name:'Reload recorded history',exact:true}).click();
|
||||
await expect(recorded(page).locator('button[data-run-id]').first()).toBeEnabled();await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page)).toContainText(f.runs.empty);await page.keyboard.press('Escape');await expect(saved(page)).toBeVisible();
|
||||
} finally {release();}
|
||||
});
|
||||
|
||||
test('strict reset seeds real A,A,B positions, two contexts and independently selected saved gallery',async({page,request})=>{
|
||||
for(const data of [{chronological_history:'true'},{chronological_history:true,native_library:true},{chronological_history:true,full_inspector:false},{chronological_history:true,extra_images:0},{chronological_history:true,history_runs:true},{chronological_history:true,unknown:1},{history_runs:1}])
|
||||
expect((await request.post('/__test__/reset',{data})).status()).toBe(400);
|
||||
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
|
||||
await open(page,fixture.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
|
||||
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
|
||||
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
|
||||
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
|
||||
await expect(recorded(page)).toContainText('same positive raw text');
|
||||
await expect(recorded(page).locator('[data-context-branch]')).toHaveCount(2);
|
||||
const buttons=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button');
|
||||
await expect(buttons).toHaveCount(3);
|
||||
expect(await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.runId))).toEqual(fixture.aab);
|
||||
const contexts=await buttons.evaluateAll(nodes=>nodes.map(n=>n.dataset.contextId));expect(contexts[0]).toBe(contexts[1]);expect(contexts[2]).not.toBe(contexts[0]);
|
||||
for(const [index,id] of fixture.aab.entries()) {
|
||||
await marker(page,id).focus();await page.keyboard.press(index===1?' ':'Enter');
|
||||
await expect(dialog(page)).toContainText(id);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await expect(marker(page,id)).toBeFocused();
|
||||
}
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
|
||||
});
|
||||
|
||||
test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode and equal-looking snapshots',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
|
||||
const same=window.context_summaries.filter(c=>c.negative_raw.preview?.startsWith('same visible negative'));
|
||||
expect(same.length).toBe(2);expect(same[0].context_id).not.toBe(same[1].context_id);
|
||||
await expect(recorded(page).locator(`[data-context-branch="${same[0].context_id}"]`)).toHaveCount(1);
|
||||
const shared=window.items.find(run=>run.run_id===f.runs.shared);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page)).toBeVisible();
|
||||
const contexts=dialog(page).getByRole('group',{name:'Recorded anchors',exact:true});
|
||||
await expect(contexts.getByRole('button')).toHaveCount(3);await expect(contexts.locator('[aria-pressed="true"]')).toContainText('z');
|
||||
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
|
||||
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
|
||||
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
|
||||
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
|
||||
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
|
||||
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
|
||||
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
|
||||
});
|
||||
|
||||
test('every ordered receipt remains selected through distinct current locations, Viewer and nested Escape',async({page,request})=>{
|
||||
const f=page.fixture;let outputReads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))outputReads++;});await open(page,f.detail_id);await marker(page,f.runs.shared).click();
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const receipts=dialog(page).locator('[data-receipt]');
|
||||
expect(await receipts.evaluateAll(nodes=>nodes.map(node=>[node.dataset.outputNodeId,Number(node.dataset.imageOrdinal)]))).toEqual([['10',0],['10',1],['2',0],['2',1],['z',0]]);
|
||||
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
|
||||
await expect(dialog(page)).toContainText('Context membership unknown');
|
||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
|
||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
|
||||
await receipts.nth(1).getByRole('button',{name:'View current file',exact:true}).click();await page.keyboard.press('Escape');
|
||||
await expect(receipts.nth(1)).toHaveAttribute('aria-current','true');
|
||||
expect(outputReads).toBe(loadedReads);
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);await expect(marker(page,f.runs.shared)).toBeFocused();
|
||||
const window=await(await request.get(`/prompt_manager/library/${f.detail_id}/history`)).json();
|
||||
const outputs=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}/outputs?epoch=${window.history_epoch}`)).json();expect(outputs.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('inspection first discovers a real restore and immediately retires every surrounding recorded action',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const snapshot=await request.get('/__test__/history/snapshot');await expect(snapshot).toBeOK();
|
||||
await command(request,{action:'arm',boundary:'inspection'});
|
||||
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
|
||||
try {
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await page.evaluate(()=>{
|
||||
window.recordedRecoveryStates=[];
|
||||
const run=document.querySelector('.recorded-run-dialog');let changed=false;
|
||||
const observer=new MutationObserver(()=>{
|
||||
changed ||= run.querySelector('.recorded-run-status').textContent.includes('Recorded evidence changed');
|
||||
if(changed)window.recordedRecoveryStates.push({receipts:run.querySelectorAll('[data-receipt]').length,active:[...run.querySelectorAll('[data-send-role],[data-copy-role],[data-view-receipt]')].filter(node=>!node.disabled&&node.checkVisibility()).length});
|
||||
});observer.observe(run,{subtree:true,childList:true,attributes:true,characterData:true});
|
||||
});
|
||||
await restoreSnapshot(request,await snapshot.body());
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
|
||||
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
if(await viewer.count())await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
|
||||
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
|
||||
await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('revalidated');
|
||||
const states=await page.evaluate(()=>window.recordedRecoveryStates);expect(states.length).toBeGreaterThan(0);expect(states.every(state=>state.receipts===0&&state.active===0)).toBe(true);
|
||||
await expect(dialog(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('active stale inspection echo reaches history ownership without a second metadata implementation',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await page.route(/\/output\/current\/inspection\?/,async route=>{
|
||||
const response=await route.fetch(),body=await response.json();body.history_epoch='33333333-3333-3333-3333-333333333333';await route.fulfill({response,json:body});
|
||||
});
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
|
||||
let writes=0,galleryReads=0,outputReads=0;
|
||||
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await expect(missing).toContainText('missing');
|
||||
await missing.getByRole('button',{name:'Select reported output',exact:true}).click();await expect(missing).toHaveAttribute('aria-current','true');
|
||||
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeHidden();
|
||||
await command(request,{action:'make-missing-available'});
|
||||
const beforeReads=outputReads,beforeGallery=galleryReads;
|
||||
await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
|
||||
await expect(missing).toContainText('Current candidate: available');await expect(missing).toHaveAttribute('aria-current','true');
|
||||
expect(outputReads).toBe(beforeReads+1);expect(galleryReads).toBe(beforeGallery);expect(writes).toBe(0);
|
||||
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
test('marker labels disclose exact observed dequeue and outcome including times outside display range',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const window=await historyRead(request,f.detail_id);
|
||||
for(const run of window.items)for(const anchor of [...new Map(run.matching_anchors.map(anchor=>[anchor.context_id,run.matching_anchors.find(first=>first.context_id===anchor.context_id)])).values()]) {
|
||||
const button=recorded(page).locator(`button[data-run-id="${run.run_id}"][data-context-id="${anchor.context_id}"]`);
|
||||
const label=await button.getAttribute('aria-label');
|
||||
expect(label).toContain(`Observed dequeue ${run.observed_dequeue_at} ms`);expect(label).toContain(`Outcome ${run.outcome}`);expect(label).toContain(`anchor ${anchor.anchor_id}`);
|
||||
expect(await button.getAttribute('title')).toBe(label);
|
||||
if(run.observed_dequeue_at==='9223372036854775807')expect(label).toContain('outside display range');
|
||||
}
|
||||
});
|
||||
|
||||
test('same-epoch refresh retains recorded context mode and exact receipt after fresh candidate reads',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const selected=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await selected.getByRole('button',{name:'Select reported output',exact:true}).click();
|
||||
await page.keyboard.press('Escape');
|
||||
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname.endsWith('/outputs'))reads++;});
|
||||
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();
|
||||
await expect(marker(page,f.runs.shared,'z')).toHaveAttribute('aria-pressed','true');await expect.poll(()=>reads).toBe(1);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await expect(selected).toHaveAttribute('aria-current','true');await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
|
||||
});
|
||||
|
||||
test('route reentry restores older timeline range and both scroll axes after 61 new same-epoch runs',async({page,request})=>{
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const f=await reset.json();
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});await open(page,f.positive_id);
|
||||
await expect(recorded(page)).toContainText('40 loaded positions');await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const oldIds=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun));const oldRun=oldIds[0];
|
||||
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
|
||||
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
|
||||
const scroll=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(scroll.left).toBeGreaterThan(0);expect(scroll.top).toBeGreaterThan(0);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
|
||||
let windowReads=0;page.on('request',request=>{if(/\/library\/\d+\/history$/.test(new URL(request.url()).pathname))windowReads++;});
|
||||
let release,entered;const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
|
||||
await page.route(new RegExp(`/history/runs/${oldRun}\\?`),async route=>{const response=await route.fetch();entered();await held;await route.fulfill({response}).catch(()=>{});});
|
||||
try {
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},f.positive_id);await started;
|
||||
await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','true');
|
||||
} finally {release();}
|
||||
await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');
|
||||
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(oldIds);expect(windowReads).toBe(2);
|
||||
const restored=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(restored.left-scroll.left)).toBeLessThanOrEqual(1);expect(Math.abs(restored.top-scroll.top)).toBeLessThanOrEqual(1);
|
||||
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText('149 distinct recorded runs');await expect(recorded(page)).toContainText('40 loaded positions');
|
||||
await expect(recorded(page).locator('.history-status')).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(marker(page,oldRun)).toHaveCount(0);
|
||||
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
});
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`final fix review states ${width} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(60000);await page.setViewportSize({width,height});await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
|
||||
await marker(page,f.runs.shared,'z').click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
const missing=dialog(page).locator('[data-output-node-id="2"][data-image-ordinal="1"]');await missing.getByRole('button',{name:'Select reported output',exact:true}).click();
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await expect(missing.getByRole('button',{name:'Retry current availability',exact:true})).toBeVisible();
|
||||
await reviewCapture(page,testInfo,`missing-selected-${width}-${theme}`,missing);
|
||||
await reviewCapture(page,testInfo,`missing-dialog-${width}-${theme}`,dialog(page));
|
||||
await command(request,{action:'make-missing-available'});await missing.getByRole('button',{name:'Retry current availability',exact:true}).click();
|
||||
await expect(missing.getByRole('button',{name:'View current file',exact:true})).toBeVisible();await expect(missing).toHaveAttribute('aria-current','true');
|
||||
await reviewCapture(page,testInfo,`available-selected-${width}-${theme}`,missing);await page.keyboard.press('Escape');
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:85}});await expect(reset).toBeOK();const range=await reset.json();await open(page,range.positive_id);
|
||||
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).click();await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const ids=await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun)),oldRun=ids[0];
|
||||
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
|
||||
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
|
||||
const before=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));await reviewCapture(page,testInfo,`range-before-${width}-${theme}`,timeline);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},range.positive_id);await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');
|
||||
const after=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(after.left-before.left)).toBeLessThanOrEqual(1);expect(Math.abs(after.top-before.top)).toBeLessThanOrEqual(1);
|
||||
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(ids);
|
||||
await reviewCapture(page,testInfo,`range-after-${width}-${theme}`,timeline);
|
||||
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).click();await expect(recorded(page)).toContainText(`Selected run ${oldRun} is outside this loaded timeline window`);await expect(recorded(page)).toContainText('149 distinct recorded runs');
|
||||
await reviewCapture(page,testInfo,`outside-window-${width}-${theme}`,recorded(page));
|
||||
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
|
||||
const hashes={};
|
||||
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
|
||||
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
|
||||
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
|
||||
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
|
||||
});
|
||||
|
||||
test('output capture distinguishes unknown, complete zero, partial and conflicts with exact out-of-range time',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
for(const [name,expected] of [['equal_looking','Reported outputs not captured'],['empty','0 reported outputs · complete'],['whitespace','1 retained reported output · incomplete'],['conflicting','First captured context · conflicting evidence']]) {
|
||||
await marker(page,f.runs[name]).click();await expect(dialog(page)).toContainText(expected);
|
||||
if(name==='conflicting') {await expect(dialog(page)).toContainText('Recorded time outside display range');await expect(dialog(page)).toContainText('9223372036854775807');}
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
});
|
||||
|
||||
test('history, run and output failures retry their own reads and route exit ignores late callbacks',async({page})=>{
|
||||
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
|
||||
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
|
||||
await expect(dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true})).toBeVisible();outputFailure=false;await dialog(page).getByRole('button',{name:'Retry reported outputs',exact:true}).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await page.keyboard.press('Escape');let release;const held=new Promise(resolve=>{release=resolve;});await page.route(/\/history\/runs\/[^/]+\?/,async route=>{await held;await route.continue().catch(()=>{});});
|
||||
await marker(page,f.runs.empty).click();await page.evaluate(()=>{location.hash='#/overview';});release();await expect(page.locator('#workspace-main h1')).toHaveText('Overview');await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('more than 1000 real runs retain sparse columns, focused identity and pixel anchors on prepend with moveBefore absent',async({page,request})=>{
|
||||
test.setTimeout(120000);const response=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:1001}});await expect(response).toBeOK();const f=await response.json();
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;Element.prototype.moveBefore=undefined;});await open(page,f.positive_id);
|
||||
const more=recorded(page).getByRole('button',{name:'Load earlier runs',exact:true,includeHidden:true});
|
||||
await expect(recorded(page).locator('[data-history-run]')).toHaveCount(40);
|
||||
const first=recorded(page).getByRole('list',{name:'Recorded run positions',exact:true}).getByRole('button').first();
|
||||
await first.focus();await first.evaluate(node=>{window.retainedHistoryButton=node;window.retainedHistoryRun=node.dataset.runId;window.retainedHistoryX=node.getBoundingClientRect().left;window.retainedHistoryY=node.getBoundingClientRect().top;});
|
||||
await more.evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const retained=await page.evaluate(()=>({same:window.retainedHistoryButton===document.querySelector(`[data-run-id="${window.retainedHistoryRun}"]`),focus:document.activeElement===window.retainedHistoryButton,delta:Math.abs(window.retainedHistoryX-window.retainedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.retainedHistoryY-window.retainedHistoryButton.getBoundingClientRect().top)}));
|
||||
expect(retained.same&&retained.focus).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
|
||||
while(await more.isVisible()) {await more.evaluate(node=>node.click());await expect(more).not.toHaveAttribute('aria-disabled','true');}
|
||||
await expect(recorded(page)).toContainText('1004 loaded positions');
|
||||
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=node.scrollWidth/2;node.dispatchEvent(new Event('scroll'));});
|
||||
const bound=await timeline.evaluate(node=>Math.ceil(node.clientWidth/64)+8);
|
||||
await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound);
|
||||
expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound);
|
||||
expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true);
|
||||
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await open(page,page.fixture.detail_id);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.locator('#workspace-main').evaluate(node=>{node.style.zoom='2';});
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await marker(page,page.fixture.runs.shared).click();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);await page.keyboard.press('Escape');
|
||||
await page.locator('#workspace-main').evaluate(node=>node.style.removeProperty('zoom'));
|
||||
await page.emulateMedia({forcedColors:'active'});await expect(marker(page,page.fixture.runs.shared)).toBeVisible();
|
||||
});
|
||||
|
||||
test('frozen Effective pair uses the shared acknowledged sender and Escape closes only its top dialog',async({page,context})=>{
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'history-pair',label:'History destination',type:'PromptManagerPair',roles:['positive','negative']};window.historyApplied=[];
|
||||
window.historyHost=createBridgeHost({origin:location.origin,namespace:'/',label:'History ComfyUI',adapter:{listTargets:()=>[target],
|
||||
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles}),
|
||||
apply:roles=>{window.historyApplied.push(roles);return{targetId:target.id,values:roles};}}});
|
||||
});
|
||||
try {
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeEnabled();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.historyApplied)).toEqual([{positive,negative:' \n\t '}]);
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);await expect(dialog(page)).toBeVisible();await expect(send).toBeFocused();
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.historyHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
|
||||
await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));await expect(recorded(page)).toBeVisible();
|
||||
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','true');
|
||||
await marker(page,page.fixture.runs.shared).evaluate(node=>node.click());await expect(dialog(page)).toHaveCount(0);release();
|
||||
await expect(recorded(page).locator('button[data-run-id]').first()).toHaveAttribute('aria-disabled','false');expect(checks).toBeGreaterThan(0);await expect(dialog(page)).toHaveCount(0);
|
||||
await marker(page,page.fixture.runs.shared).click();await expect(dialog(page)).toContainText(page.fixture.runs.shared);await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('native connected moves and keyboard navigation preserve marker identity through earlier branch reorder',async({page,request})=>{
|
||||
const result=await request.post('/__test__/reset',{data:{chronological_history:true,history_runs:140}});await expect(result).toBeOK();const f=await result.json();
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;window.historyMoves=0;const move=Element.prototype.moveBefore;window.hasHistoryMove=typeof move==='function';if(move)Element.prototype.moveBefore=function(...args){window.historyMoves++;return move.apply(this,args);};});
|
||||
await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(40);await buttons.first().focus();
|
||||
await buttons.first().evaluate(node=>{window.keyedHistoryButton=node;window.keyedHistoryX=node.getBoundingClientRect().left;window.keyedHistoryY=node.getBoundingClientRect().top;});
|
||||
await recorded(page).getByRole('button',{name:'Load earlier runs',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toContainText('80 loaded positions');
|
||||
const retained=await page.evaluate(()=>({same:window.keyedHistoryButton.isConnected,focused:document.activeElement===window.keyedHistoryButton,delta:Math.abs(window.keyedHistoryX-window.keyedHistoryButton.getBoundingClientRect().left),vertical:Math.abs(window.keyedHistoryY-window.keyedHistoryButton.getBoundingClientRect().top)}));
|
||||
expect(retained.same&&retained.focused).toBe(true);expect(retained.delta).toBeLessThanOrEqual(1);expect(retained.vertical).toBeLessThanOrEqual(1);
|
||||
if(await page.evaluate(()=>window.hasHistoryMove)) expect(await page.evaluate(()=>window.historyMoves)).toBeGreaterThan(0);
|
||||
await page.keyboard.press('End');await expect(recorded(page).locator('[data-history-run]').last()).toHaveAttribute('aria-posinset','80');
|
||||
expect(await page.evaluate(()=>document.activeElement.closest('[data-history-run]')?.getAttribute('aria-posinset'))).toBe('80');
|
||||
});
|
||||
|
||||
test('coarse-pointer markers expose 44px targets while retaining the small repeat visual',async({browser,request})=>{
|
||||
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true});const page=await context.newPage();
|
||||
try {
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);const buttons=recorded(page).locator('button[data-run-id]');await expect(buttons).toHaveCount(3);
|
||||
expect(await buttons.evaluateAll(nodes=>nodes.every(node=>node.getBoundingClientRect().width>=44&&node.getBoundingClientRect().height>=44))).toBe(true);
|
||||
const repeat=marker(page,f.aab[1]);expect(await repeat.evaluate(node=>({width:getComputedStyle(node,'::before').width,height:getComputedStyle(node,'::before').height}))).toEqual({width:'12px',height:'12px'});
|
||||
await repeat.tap();await expect(dialog(page)).toContainText(f.aab[1]);await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).tap();await expect(dialog(page)).toHaveCount(0);
|
||||
} finally {await context.close();}
|
||||
});
|
||||
|
||||
test('coarse recorded toolbar and disclosure expose 44px targets and real touch actions',async({browser,browserName,request},testInfo)=>{
|
||||
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:browserName!=='firefox'});const page=await context.newPage();
|
||||
try {
|
||||
const reset=await request.post('/__test__/reset',{data:{chronological_history:true}});const f=await reset.json();await open(page,f.positive_id);
|
||||
expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);
|
||||
const controls=recorded(page).locator('.history-toolbar button:visible,.history-scope summary');
|
||||
const sizes=await controls.evaluateAll(nodes=>nodes.map(node=>({label:node.textContent,width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
|
||||
await testInfo.attach('coarse-recorded-controls.json',{body:JSON.stringify(sizes,null,2),contentType:'application/json'});
|
||||
for(const control of sizes) {expect.soft(control.width,control.label).toBeGreaterThanOrEqual(44);expect.soft(control.height,control.label).toBeGreaterThanOrEqual(44);}
|
||||
await recorded(page).getByText('Recorded history scope',{exact:true}).tap();await expect(recorded(page).locator('.history-scope')).toHaveAttribute('open','');
|
||||
await recorded(page).getByRole('button',{name:'Refresh history',exact:true}).tap();await expect(recorded(page).locator('button[data-run-id]')).toHaveCount(3);
|
||||
} finally {await context.close();}
|
||||
});
|
||||
|
||||
test('capture recovery reloads only reported outputs while retaining frozen run and saved gallery',async({page})=>{
|
||||
const f=page.fixture;let failures=2,outputReads=0,runReads=0,galleryReads=0;
|
||||
page.on('request',request=>{const path=new URL(request.url()).pathname;if(path===`/prompt_manager/history/runs/${f.runs.shared}`)runReads++;if(path===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>{
|
||||
outputReads++;if(failures-->0)return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
|
||||
await route.continue();
|
||||
});
|
||||
await open(page,f.detail_id);await marker(page,f.runs.shared).click();
|
||||
const reload=dialog(page).getByRole('button',{name:'Reload reported outputs',exact:true});await expect(reload).toBeVisible();expect(outputReads).toBe(2);
|
||||
const runCount=runReads,galleryCount=galleryReads;await reload.click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
expect(runReads).toBe(runCount);expect(galleryReads).toBe(galleryCount);expect(outputReads).toBe(3);await expect(dialog(page)).toContainText(f.runs.shared);
|
||||
});
|
||||
|
||||
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
const retry=page.getByRole('button',{name:'Retry saved Library details',exact:true});await expect(retry).toBeVisible();await expect(page.locator('.library-parent-status')).toContainText('could not be revalidated');
|
||||
parentFailure=false;await retry.click();await expect(page.locator('.library-parent-status')).toContainText('revalidated');await expect(retry).toBeHidden();
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();expect(galleryReads).toBe(0);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
|
||||
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
|
||||
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
await expect(await request.delete(`/prompt_manager/images/${f.detail_image_id}`)).toBeOK();
|
||||
for(const variant of details.variants) await expect(await request.delete(`/prompt_manager/pairs/${variant.id}`)).toBeOK();
|
||||
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
|
||||
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
|
||||
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
|
||||
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
|
||||
});
|
||||
|
||||
test('a bounded saved-parent prefix leaves previously loaded variants unverified without claiming deletion',async({page,request})=>{
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
|
||||
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('not observed in this bounded revalidation page');await expect(page.locator('.library-parent-status')).not.toContainText(/deleted|removed/i);
|
||||
await expect(choices.last()).toHaveAttribute('aria-pressed','true');expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
|
||||
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled();
|
||||
});
|
||||
|
||||
async function retainSavedSelection(page,request,fixture) {
|
||||
await open(page,fixture.detail_id);
|
||||
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
|
||||
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
|
||||
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
|
||||
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
|
||||
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
|
||||
return async()=>{
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
|
||||
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
|
||||
};
|
||||
}
|
||||
|
||||
for(const change of ['mode','anchor']) test(`initial receipt read restarts after a held ${change} cancellation`,async({page,request})=>{
|
||||
const f=page.fixture;const retained=await retainSavedSelection(page,request,f);let reads=0,release,entered;
|
||||
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
|
||||
let aborted=0;page.on('requestfailed',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.shared}/outputs`)aborted++;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;if(reads===1){entered();await held;}await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await marker(page,f.runs.shared).click();await started;
|
||||
if(change==='mode')await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
else await dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /}).click();
|
||||
release();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(2);
|
||||
await expect.poll(()=>aborted).toBeGreaterThan(0);
|
||||
if(change==='mode')await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
else await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /})).toHaveAttribute('aria-pressed','true');
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {release();}
|
||||
});
|
||||
|
||||
test('same-run reopen loads page one after cancellation and a held fresh detail read',async({page,request})=>{
|
||||
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseOutputs,enteredOutputs,releaseDetail,enteredDetail;
|
||||
const outputsHeld=new Promise(resolve=>{releaseOutputs=resolve;}),outputsStarted=new Promise(resolve=>{enteredOutputs=resolve;});
|
||||
const detailHeld=new Promise(resolve=>{releaseDetail=resolve;}),detailStarted=new Promise(resolve=>{enteredDetail=resolve;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;enteredOutputs();await outputsHeld;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await marker(page,f.runs.shared).click();await outputsStarted;await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{enteredDetail();await detailHeld;await route.continue().catch(()=>{});});
|
||||
await marker(page,f.runs.shared).click();await detailStarted;await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(dialog(page)).toContainText('Loading recorded run');
|
||||
releaseDetail();releaseOutputs();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(3);
|
||||
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');await page.keyboard.press('Escape');await retained();
|
||||
} finally {releaseDetail();releaseOutputs();}
|
||||
});
|
||||
|
||||
test('late output capture replaces the earlier uncaptured detail summary with real complete-zero evidence',async({page,request})=>{
|
||||
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let release,entered;
|
||||
const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
|
||||
for(const data of [{}, {run_id:f.runs.equal_looking,extra:true}, {run_id:[]}, {run_id:'not-a-run'}])expect((await request.post('/__test__/history/capture-zero-outputs',{data})).status()).toBe(400);
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.equal_looking}/outputs\\?`),async route=>{entered();await held;await route.continue().catch(()=>{});});
|
||||
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}`);
|
||||
try {
|
||||
await marker(page,f.runs.equal_looking).click();await started;const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('not_captured');
|
||||
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.equal_looking}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
|
||||
const pageRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}/outputs`);release();const outputs=await(await pageRead).json();
|
||||
expect(outputs.capture).toMatchObject({state:'complete',reason:'none_reported'});expect(outputs.retained_occurrence_count).toBe(0);expect(outputs.list_status).toBe('complete');
|
||||
await expect(dialog(page).locator('.recorded-output-status')).toHaveText('0 reported outputs · complete · All retained receipts loaded.');
|
||||
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · none_reported');
|
||||
await expect(dialog(page).getByText('Context capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · captured');
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {release();}
|
||||
});
|
||||
|
||||
test('capture-only recovery uses the changed real output capture without re-reading run or saved gallery',async({page,request})=>{
|
||||
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let outputsReads=0,runReads=0;
|
||||
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`)runReads++;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.whitespace}/outputs\\?`),async route=>{
|
||||
outputsReads++;
|
||||
if(outputsReads===1) {
|
||||
const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.whitespace}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true);
|
||||
return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'reload-outputs'}}});
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`);
|
||||
const outputsRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}/outputs`&&response.status()===200);
|
||||
await marker(page,f.runs.whitespace).click();const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('partial');
|
||||
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
|
||||
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
|
||||
});
|
||||
|
||||
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
|
||||
const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseNext,enteredNext;
|
||||
const held=new Promise(resolve=>{releaseNext=resolve;}),started=new Promise(resolve=>{enteredNext=resolve;});
|
||||
await page.route(new RegExp(`/history/runs/${f.runs.paged}/outputs\\?`),async route=>{
|
||||
reads++;if(new URL(route.request().url()).searchParams.has('cursor')){enteredNext();await held;}await route.continue().catch(()=>{});
|
||||
});
|
||||
try {
|
||||
await marker(page,f.runs.paged).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);expect(reads).toBe(1);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const more=dialog(page).getByRole('button',{name:'Load more reported outputs',exact:true});await expect(more).toBeEnabled();expect(reads).toBe(1);await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);
|
||||
await more.click();await started;expect(reads).toBe(2);releaseNext();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(41);await expect(more).toBeHidden();
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {releaseNext();}
|
||||
});
|
||||
@@ -427,3 +427,17 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
|
||||
} finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();}
|
||||
});
|
||||
|
||||
test('saved choices retain curved staggered lanes and the full negative immediately follows its positive',async({page,request})=>{
|
||||
await page.goto(route);await openHistory(page);await selectVariant(page,1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
|
||||
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
|
||||
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
|
||||
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
|
||||
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
const selected=details.variants[1].negative_text;
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
@@ -43,6 +46,112 @@ test.beforeEach(async ({ page, request, browserName }) => {
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
|
||||
|
||||
test('canonical history assets are the exact immutable approved source', async ({ request }) => {
|
||||
const assets = [
|
||||
['/__test__/canonical/workspace', 'docs/concepts/workspace/index.html', 'd84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03'],
|
||||
['/workspace/app.js', 'docs/concepts/workspace/app.js', 'e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e'],
|
||||
['/workspace/style.css', 'docs/concepts/workspace/style.css', '2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8'],
|
||||
];
|
||||
for (const [url, source, sha256] of assets) {
|
||||
const response = await request.get(url);await expect(response).toBeOK();
|
||||
const bytes = await response.body();
|
||||
expect(bytes).toEqual(await readFile(new URL(`../../${source}`, import.meta.url)));
|
||||
expect(createHash('sha256').update(bytes).digest('hex')).toBe(sha256);
|
||||
expect(response.headers()['cache-control']).toBe('no-store');
|
||||
expect(response.headers()['x-content-type-options']).toBe('nosniff');
|
||||
}
|
||||
for (const url of ['/workspace/missing.js', '/__test__/canonical/server.py', '/toolkit/missing.js', '/vendor/missing.js']) {
|
||||
expect((await request.get(url)).status()).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
|
||||
test(`canonical chronological history ${width} ${theme}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto('/__test__/canonical/workspace#prompt/alpine');
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await expect(page.locator('.timeline')).toBeVisible();
|
||||
const referenceDot = page.getByRole('button', { name: 'Select negative Quality baseline', exact: true });
|
||||
await referenceDot.focus();await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('button', { name: 'Select negative Quality baseline', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const reference = await historyGeometry(page, false);
|
||||
for (const [name, selector] of [['saved', '.surface'], ['branches', '.timeline-panel'], ['gallery', '.history-gallery']]) {
|
||||
await captureCanonicalState(page, testInfo, `concept-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Show repeat run for Quality baseline', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Repeat run', exact: true })).toBeVisible();
|
||||
expect(await page.locator('#modal').evaluate(node => node.matches(':modal'))).toBe(true);
|
||||
reference.runDialog = await nativeDialogState(page.locator('#modal'));
|
||||
await captureCanonicalState(page, testInfo, `concept-run-${width}-${theme}`, '#modal');
|
||||
await page.keyboard.press('Escape');await expect(page.locator('#modal')).not.toBeVisible();
|
||||
await page.locator('.history-gallery .image-open').first().click();
|
||||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-dialog')).toHaveAccessibleName('Alpine observatory · alpine_00001.png');
|
||||
await captureCanonicalState(page, testInfo, `concept-viewer-${width}-${theme}`, '#viewer-dialog');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const reset = await request.post('/__test__/reset', { data: { chronological_history: true } });await expect(reset).toBeOK();const fixture = await reset.json();
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
|
||||
const actual = await historyGeometry(page, true);
|
||||
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
|
||||
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
|
||||
await run.focus();await page.keyboard.press('Enter');
|
||||
const runDialog = page.getByRole('dialog', { name: 'Recorded run', exact: true });
|
||||
await expect(runDialog.locator('[data-receipt]')).toHaveCount(2);
|
||||
expect(await runDialog.evaluate(node => node.matches(':modal') && node.contains(document.activeElement))).toBe(true);
|
||||
actual.runDialog = await nativeDialogState(runDialog);
|
||||
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
|
||||
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
|
||||
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
|
||||
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
await compareCanonicalHistory(reference, actual, testInfo);
|
||||
|
||||
const history = await request.get(`/prompt_manager/library/${fixture.positive_id}/history`);await expect(history).toBeOK();
|
||||
const window = await history.json();expect(window.items.map(item => item.run_id)).toEqual(fixture.aab);
|
||||
expect(window.context_summaries).toHaveLength(2);
|
||||
const contextIds = [...new Set(window.items.flatMap(item => item.matching_anchors.map(anchor => anchor.context_id)))];
|
||||
expect(contextIds).toHaveLength(2);
|
||||
await page.goto(`${workspace}#/library/${fixture.positive_id}`);
|
||||
await expect(page.locator('.history-run-marker')).toHaveCount(3);
|
||||
await page.locator('.history-timeline').evaluate(node => { node.scrollLeft = 0;node.scrollTop = 0; });
|
||||
const anchor = page.locator('.history-context-anchor').first();await anchor.focus();await expect(anchor).toBeFocused();
|
||||
const multiple = await historyGeometry(page, true);
|
||||
expect(multiple.recordedBranch.map(branch => branch.contextId)).toEqual(contextIds);
|
||||
expect(multiple.recordedMarker.map(marker => marker.runId)).toEqual(fixture.aab);
|
||||
await captureCanonicalState(page, testInfo, `production-chronology-multiple-${width}-${theme}`, '.recorded-history');
|
||||
const geometryPath = testInfo.outputPath('canonical-recorded-geometry.json');
|
||||
await writeFile(geometryPath, JSON.stringify({ reference, actual: multiple, expected: { contextIds, runIds: fixture.aab, branchCount: 2, anchorCount: 2, repeatCount: 1 }, differences: 'Two real frozen contexts / three runs versus three illustrative saved variants; chronological columns retain 64px run spacing, with the same role colors, curves and 57px row rhythm.' }, null, 2));
|
||||
await testInfo.attach('canonical-recorded-geometry', { path: geometryPath, contentType: 'application/json' });
|
||||
assertRecordedHistory(reference, multiple, { branchCount: 2, anchorCount: 2, repeatCount: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
test('visual fixture exposes real stable media and library records', async ({ request }) => {
|
||||
expect((await request.post('/__test__/reset', { data: { workspace_visual: 'true' } })).status()).toBe(400);
|
||||
for (const option of ['extra_images', 'include_video', 'prompt_inspector', 'native_library', 'extra_prompts', 'extra_variants']) {
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Real disposable ledger commands and physical files for history workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_visual_fixture import _write_art, seed_workspace_visual
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
PromptSnapshot,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
)
|
||||
from utils.execution_history_events import make_finish, make_start
|
||||
from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
|
||||
HISTORY_POSITIVE = (
|
||||
"Chronology A,A,B\nExact positive 猫 😀 " + "fine light " * 35
|
||||
).rstrip()
|
||||
DETAIL_POSITIVE = "Frozen evidence\n" + "unbroken-" * 90
|
||||
LONG_NEGATIVE = "same visible negative\n" + "exact detail 猫 😀 " * 70
|
||||
SESSION = "69f04366-6384-4182-b84b-117597479127"
|
||||
|
||||
|
||||
class _HistoryCommands:
|
||||
"""One disposable fixture lifetime and one bounded publication barrier."""
|
||||
|
||||
def __init__(self, api: PromptManagerAPI, root: Path) -> None:
|
||||
self.api = api
|
||||
self.root = root
|
||||
self.manifest = None
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.restore_seam = None
|
||||
import folder_paths
|
||||
|
||||
self.original_output = folder_paths.get_output_directory
|
||||
|
||||
def reset(self, manifest: dict | None = None) -> None:
|
||||
self.disarm()
|
||||
import folder_paths
|
||||
|
||||
folder_paths.get_output_directory = self.original_output
|
||||
self.manifest = manifest
|
||||
|
||||
def disarm(self) -> None:
|
||||
self.release.set()
|
||||
if self.restore_seam:
|
||||
module, name, original = self.restore_seam
|
||||
setattr(module, name, original)
|
||||
self.restore_seam = None
|
||||
|
||||
def arm(self, boundary: str) -> None:
|
||||
from py.api import history
|
||||
from py.api import inspection_service
|
||||
|
||||
self.disarm()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
name = {
|
||||
"window": "_serialize_history",
|
||||
"detail": "_serialize_history",
|
||||
"candidate": "resolve_current_candidate",
|
||||
"inspection": "read_inspection_file",
|
||||
"media": "_prepare_current_media",
|
||||
}[boundary]
|
||||
module = inspection_service if boundary == "inspection" else history
|
||||
original = getattr(module, name)
|
||||
entered, release = self.entered, self.release
|
||||
|
||||
def held(*args: object, **kwargs: object) -> object:
|
||||
value = original(*args, **kwargs)
|
||||
setattr(module, name, original)
|
||||
entered.set()
|
||||
try:
|
||||
if not release.wait(10):
|
||||
raise TimeoutError("Owned history publication barrier timed out")
|
||||
return value
|
||||
except BaseException:
|
||||
if boundary == "media":
|
||||
value.close()
|
||||
raise
|
||||
|
||||
self.restore_seam = module, name, original
|
||||
setattr(module, name, held)
|
||||
|
||||
|
||||
def register_workspace_history_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> _HistoryCommands:
|
||||
"""Register fixed commands; all files and namespaces belong to this server."""
|
||||
state = _HistoryCommands(api, root)
|
||||
api._workspace_history_commands = state
|
||||
|
||||
@routes.post("/__test__/history/commands")
|
||||
async def command(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
actions = {
|
||||
"release",
|
||||
"replace-file",
|
||||
"reconfigure-root",
|
||||
"make-missing-available",
|
||||
"append-history-runs",
|
||||
}
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or type(data.get("action")) is not str
|
||||
or ("boundary" in data and type(data["boundary"]) is not str)
|
||||
or not (
|
||||
(set(data) == {"action"} and data["action"] in actions)
|
||||
or (
|
||||
set(data) == {"action", "boundary"}
|
||||
and data["action"] == "arm"
|
||||
and data["boundary"]
|
||||
in {"window", "detail", "candidate", "inspection", "media"}
|
||||
)
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
if state.manifest is None:
|
||||
raise web.HTTPConflict()
|
||||
action = data["action"]
|
||||
if action == "arm":
|
||||
state.arm(data["boundary"])
|
||||
elif action == "release":
|
||||
state.disarm()
|
||||
elif action == "replace-file":
|
||||
replacement = root / "output" / "history" / "replacement.png"
|
||||
with Image.new("RGB", (160, 100), (225, 30, 40)) as image:
|
||||
image.save(replacement)
|
||||
replacement.replace(root / "output" / "history" / "current.png")
|
||||
elif action == "make-missing-available":
|
||||
target = root / "output" / "history" / "missing.png"
|
||||
with Image.new("RGB", (160, 100), (30, 150, 70)) as image:
|
||||
image.save(target)
|
||||
elif action == "append-history-runs":
|
||||
session = str(uuid5(UUID(SESSION), "fixed-late-history"))
|
||||
context = RecipeContext(
|
||||
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
|
||||
)
|
||||
for number in range(61):
|
||||
job = str(uuid5(UUID(session), str(number)))
|
||||
start = make_start(
|
||||
session,
|
||||
((0, job, {}, {}), number),
|
||||
api.db.get_execution_epoch(),
|
||||
1_790_000_000_000 + number,
|
||||
)
|
||||
api.db.write_execution_start(start)
|
||||
api.db.write_execution_finish(
|
||||
make_finish(
|
||||
session,
|
||||
number,
|
||||
start.storage_epoch,
|
||||
SimpleNamespace(
|
||||
messages=[("execution_success", {"prompt_id": job})]
|
||||
),
|
||||
1_790_000_000_010 + number,
|
||||
)
|
||||
)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
start.storage_epoch,
|
||||
ContextBundle((context,), "complete", "captured"),
|
||||
)
|
||||
elif action == "reconfigure-root":
|
||||
import folder_paths
|
||||
|
||||
moved = root / "owned-reconfigured-output"
|
||||
moved.mkdir(exist_ok=True)
|
||||
folder_paths.get_output_directory = lambda: str(moved)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/history/checkpoint")
|
||||
async def checkpoint(request: web.Request) -> web.Response:
|
||||
if state.manifest is None:
|
||||
raise web.HTTPConflict()
|
||||
return web.json_response({"entered": state.entered.is_set()})
|
||||
|
||||
@routes.get("/__test__/history/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if state.manifest is None or request.query:
|
||||
raise web.HTTPConflict()
|
||||
path = root / "owned-history-snapshot.db"
|
||||
created = await api._run_in_executor(api.db.model.backup_database, str(path))
|
||||
if not created:
|
||||
raise web.HTTPInternalServerError()
|
||||
return web.Response(
|
||||
body=path.read_bytes(), content_type="application/octet-stream"
|
||||
)
|
||||
|
||||
@routes.get("/__test__/history/reused-snapshot")
|
||||
async def reused_snapshot(request: web.Request) -> web.Response:
|
||||
if state.manifest is None or request.query:
|
||||
raise web.HTTPConflict()
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
path = root / "owned-history-reused.db"
|
||||
|
||||
def create() -> bytes:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
Path(str(path) + suffix).unlink(missing_ok=True)
|
||||
database = PromptDatabase(str(path))
|
||||
try:
|
||||
target = state.manifest["detail_id"]
|
||||
with database.model.get_connection() as connection:
|
||||
text = "Restored owner with reused numeric ID"
|
||||
connection.execute(
|
||||
"INSERT INTO prompts(id,text,hash) VALUES(?,?,?)",
|
||||
(target, text, generate_prompt_hash(text)),
|
||||
)
|
||||
database.save_prompt_pair(text, "Restored saved negative")
|
||||
finally:
|
||||
database.close_all()
|
||||
return path.read_bytes()
|
||||
|
||||
return web.Response(
|
||||
body=await api._run_in_executor(create),
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
async def cleanup(application: web.Application) -> None:
|
||||
state.reset()
|
||||
|
||||
api_app = __import__("server").PromptServer.instance.app
|
||||
api_app.on_cleanup.append(cleanup)
|
||||
return state
|
||||
|
||||
|
||||
def _known(value: str, source: str = "runtime_return") -> TextEvidence:
|
||||
provenance = "inferred" if source.startswith("graph_") else "recorded"
|
||||
return TextEvidence(value, provenance, source, None)
|
||||
|
||||
|
||||
def _snapshot(raw: str, suffix: str = "") -> PromptSnapshot:
|
||||
return PromptSnapshot(_known(raw), _known(raw + suffix), _known(""), _known(suffix))
|
||||
|
||||
|
||||
def capture_workspace_history_zero_outputs(api: PromptManagerAPI, run_id: str) -> bool:
|
||||
"""Apply one late host capture through the normal immutable storage command."""
|
||||
run = api.db.get_execution_run(run_id)
|
||||
finish = make_finish(
|
||||
run["session_id"],
|
||||
run["queue_item_id"],
|
||||
run["storage_epoch"],
|
||||
SimpleNamespace(
|
||||
messages=[
|
||||
(
|
||||
"execution_success",
|
||||
{"prompt_id": run["job_id"], "timestamp": run["terminal_event_at"]},
|
||||
)
|
||||
]
|
||||
),
|
||||
run["observed_callback_at"],
|
||||
)
|
||||
return api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((), "complete", "none_reported")
|
||||
)
|
||||
|
||||
|
||||
def seed_workspace_history(
|
||||
api: PromptManagerAPI, root: Path, *, history_runs: int = 0
|
||||
) -> dict:
|
||||
"""Write actual captures; reset removes only this server's disposable rows."""
|
||||
api._workspace_history_commands.reset()
|
||||
seed_workspace_visual(api, root)
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"execution_run_outputs",
|
||||
"execution_output_capture",
|
||||
"execution_run_contexts",
|
||||
"execution_context_capture",
|
||||
"execution_runs",
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
epoch = api.db.get_execution_epoch()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
folder = root / "output" / "history"
|
||||
folder.mkdir(exist_ok=True)
|
||||
for index, name in enumerate(("current.png", "other.png")):
|
||||
with Image.new("RGB", (160, 100), (50, 80 + index * 50, 110)) as image:
|
||||
image.save(folder / name)
|
||||
|
||||
def reference(
|
||||
node: str, ordinal: int, name: str, kind: str = "output"
|
||||
) -> OutputReference:
|
||||
return OutputReference(
|
||||
node,
|
||||
ordinal,
|
||||
kind,
|
||||
"history",
|
||||
name,
|
||||
"posix",
|
||||
roots.output_key if kind == "output" else roots.temp_key,
|
||||
)
|
||||
|
||||
item = 0
|
||||
|
||||
def run(
|
||||
contexts: tuple[RecipeContext, ...],
|
||||
outputs: OutputBundle | None = None,
|
||||
*,
|
||||
observed: int | None = None,
|
||||
conflicting: bool = False,
|
||||
) -> str:
|
||||
nonlocal item
|
||||
job = str(uuid5(UUID(SESSION), f"job-{item}"))
|
||||
start = make_start(
|
||||
SESSION,
|
||||
((item, job, {}, {}), item),
|
||||
epoch,
|
||||
1_780_000_000_000 + item if observed is None else observed,
|
||||
)
|
||||
api.db.write_execution_start(start)
|
||||
messages = [("execution_success", {"prompt_id": job, "timestamp": item + 1})]
|
||||
finish = make_finish(
|
||||
SESSION, item, epoch, SimpleNamespace(messages=messages), item + 10
|
||||
)
|
||||
api.db.write_execution_finish(finish)
|
||||
partial = any(
|
||||
getattr(side, field).value is None
|
||||
for context in contexts
|
||||
for side in (context.positive, context.negative)
|
||||
if side is not None
|
||||
for field in ("raw", "effective", "prepend", "append")
|
||||
)
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
contexts,
|
||||
"partial" if partial else "complete",
|
||||
"partial" if partial else "captured",
|
||||
),
|
||||
)
|
||||
if outputs is not None:
|
||||
api.db.write_execution_outputs_for_finish(finish, outputs)
|
||||
if conflicting:
|
||||
api.db.write_execution_contexts(
|
||||
start.run_id,
|
||||
epoch,
|
||||
ContextBundle(
|
||||
(replace(contexts[0], negative=_snapshot("later")),),
|
||||
"complete",
|
||||
"captured",
|
||||
),
|
||||
)
|
||||
api.db.write_execution_outputs_for_finish(
|
||||
finish, OutputBundle((), "complete", "none_reported")
|
||||
)
|
||||
item += 1
|
||||
return start.run_id
|
||||
|
||||
pair_a = api.db.save_prompt_pair(
|
||||
HISTORY_POSITIVE, "A negative", title="Saved A", category="History"
|
||||
)
|
||||
api.db.save_prompt_pair(HISTORY_POSITIVE, "B negative", title="Saved B")
|
||||
positive_id = api.db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
image = api.db.link_image_to_prompt(positive_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(pair_a, image, {"source": "history browser fixture"})
|
||||
a = RecipeContext(
|
||||
"10", _snapshot(HISTORY_POSITIVE), _snapshot("A negative"), "inferred"
|
||||
)
|
||||
b = replace(a, negative=_snapshot("B negative"))
|
||||
aab = [run((context,)) for context in (a, a, b)]
|
||||
for number in range(history_runs):
|
||||
context = (
|
||||
a
|
||||
if number % 3 == 0
|
||||
else (
|
||||
b
|
||||
if number % 3 == 1
|
||||
else replace(b, negative=_snapshot(f"Distinct context {number:04}"))
|
||||
)
|
||||
)
|
||||
run((context,))
|
||||
|
||||
saved_pair = api.db.save_prompt_pair(
|
||||
DETAIL_POSITIVE, LONG_NEGATIVE, title="Saved exact negative"
|
||||
)
|
||||
detail_id = api.db.get_prompt_pair(saved_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(DETAIL_POSITIVE, "", title="Saved empty")
|
||||
current = api.db.link_image_to_prompt(detail_id, str(folder / "current.png"))
|
||||
api.db.link_image_to_pair(saved_pair, current, {"source": "fixture"})
|
||||
x = RecipeContext(
|
||||
"10",
|
||||
_snapshot(DETAIL_POSITIVE, " + composition A"),
|
||||
_snapshot(LONG_NEGATIVE, " A"),
|
||||
"inferred",
|
||||
)
|
||||
y = replace(x, negative=_snapshot(LONG_NEGATIVE, " B"))
|
||||
receipts = OutputBundle(
|
||||
(
|
||||
reference("10", 0, "current.png"),
|
||||
reference("10", 1, "current.png"),
|
||||
reference("2", 0, "other.png"),
|
||||
reference("2", 1, "missing.png"),
|
||||
reference("z", 0, "transient.png", "temp"),
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
)
|
||||
detail_runs = {
|
||||
"shared": run(
|
||||
(x, replace(x, anchor_id="2"), replace(y, anchor_id="z")), receipts
|
||||
),
|
||||
"equal_looking": run((y,)),
|
||||
"empty": run(
|
||||
(replace(x, negative=_snapshot("")),),
|
||||
OutputBundle((), "complete", "none_reported"),
|
||||
),
|
||||
"unknown": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
TextEvidence(None, "unknown", "unknown", "missing"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
"whitespace": run(
|
||||
(
|
||||
replace(
|
||||
x,
|
||||
negative=PromptSnapshot(
|
||||
_known(" \n\t ", "graph_literal"),
|
||||
_known(" \n\t ", "host_cache_output"),
|
||||
_known("", "graph_literal"),
|
||||
_known("", "graph_semantics"),
|
||||
),
|
||||
),
|
||||
),
|
||||
OutputBundle(
|
||||
(reference("1", 0, "current.png"),), "partial", "limit_reached"
|
||||
),
|
||||
),
|
||||
"conflicting": run(
|
||||
(x,), receipts, conflicting=True, observed=9_223_372_036_854_775_807
|
||||
),
|
||||
"paged": run(
|
||||
(x,),
|
||||
OutputBundle(
|
||||
tuple(
|
||||
reference("paged", ordinal, "current.png") for ordinal in range(41)
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
),
|
||||
),
|
||||
}
|
||||
canonical_positive = (
|
||||
"Layered sandstone arches, warm afternoon light, precise silhouettes."
|
||||
)
|
||||
canonical_negative = "blurred edges, watermark"
|
||||
canonical_pair = api.db.save_prompt_pair(
|
||||
canonical_positive, canonical_negative, title="Quality baseline"
|
||||
)
|
||||
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
|
||||
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
|
||||
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
|
||||
path = folder / name
|
||||
_write_art(path, number, canonical_positive, canonical_negative)
|
||||
with Image.open(path) as source:
|
||||
graph = json.loads(source.info["prompt"])
|
||||
graph["3"]["inputs"].update(
|
||||
seed=42001 + number,
|
||||
steps=28,
|
||||
cfg=6.5,
|
||||
sampler_name="euler",
|
||||
scheduler="normal",
|
||||
)
|
||||
metadata = PngInfo()
|
||||
metadata.add_text("prompt", json.dumps(graph))
|
||||
source.save(path, pnginfo=metadata)
|
||||
stamp = 1_760_000_000 - number * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
|
||||
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
|
||||
canonical_run = run(
|
||||
(
|
||||
RecipeContext(
|
||||
"10",
|
||||
_snapshot(canonical_positive, " + frozen composition"),
|
||||
_snapshot(canonical_negative),
|
||||
"inferred",
|
||||
),
|
||||
),
|
||||
OutputBundle(
|
||||
(
|
||||
reference("10", 0, "canonical.png"),
|
||||
reference("10", 1, "canonical-alt.png"),
|
||||
),
|
||||
"complete",
|
||||
"reported",
|
||||
),
|
||||
)
|
||||
merge_duplicate_id = api.db.save_prompt(DETAIL_POSITIVE)
|
||||
api.invalidate_gallery_cache()
|
||||
manifest = {
|
||||
"positive_id": positive_id,
|
||||
"detail_id": detail_id,
|
||||
"aab": aab,
|
||||
"runs": detail_runs,
|
||||
"saved_pair": saved_pair,
|
||||
"detail_image_id": current,
|
||||
"canonical_id": canonical_id,
|
||||
"canonical_pair": canonical_pair,
|
||||
"canonical_run": canonical_run,
|
||||
"canonical_positive": canonical_positive,
|
||||
"canonical_negative": canonical_negative,
|
||||
"merge_duplicate_id": merge_duplicate_id,
|
||||
}
|
||||
api._workspace_history_commands.reset(manifest)
|
||||
return manifest
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { test, readExecutionRuns } from './execution-fixture.mjs';
|
||||
import { installHostHooks } from './host-fixture.mjs';
|
||||
|
||||
const sensitive = ['PM_SECRET_PROMPT_209', 'PM_SECRET_TOKEN_209', 'PM_SECRET_TRACE_209'];
|
||||
const cached = value => ({ '1': { class_type: 'PMHistoryCached', inputs: { value } } });
|
||||
@@ -194,9 +195,85 @@ async function capture(execution, id, outcome = 'succeeded') {
|
||||
result = await readExecutionRuns(execution.databasePath, id);
|
||||
return result.captures[run.run_id].state;
|
||||
}, { timeout: 10000, message: 'Background writer persisted context finalization' }).not.toBe('not_captured');
|
||||
const response = await execution.request.get(`/prompt_manager/history/runs/${run.run_id}`);
|
||||
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
|
||||
const detail = await response.json();
|
||||
const fields = contexts => contexts.map(({ anchor_id, context_id, positive, negative, evidence }) => ({ anchor_id, context_id, positive, negative, evidence }));
|
||||
expect(fields(detail.contexts)).toEqual(fields(result.captures[run.run_id].contexts));
|
||||
expect(detail.run).toMatchObject({ run_id: run.run_id, outcome, queue_item_id: String(run.queue_item_id) });
|
||||
return result.captures[run.run_id];
|
||||
}
|
||||
|
||||
test.describe('public chronological history with the actual acknowledged host bridge', () => {
|
||||
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
|
||||
installHostHooks(test, ['PromptManagerPair']);
|
||||
|
||||
test('three actual attempts expose two contexts and send exact frozen roles through both renderer switches and reload', async ({ execution, page, context }, testInfo) => {
|
||||
test.setTimeout(120000);
|
||||
const positive = `public chronology ${testInfo.project.name} ${randomUUID()}\n猫`, attempts = [];
|
||||
for (const negative of ['negative A', 'negative A', 'negative B']) {
|
||||
const graph = recipeGraph({ text: positive, negative, prepend: ' pre ', append: ' post ' });
|
||||
graph['60'] = { class_type: 'PMHistoryImages', inputs: { value: 128, batch: 2, latent: ['40', 0] } };
|
||||
graph['70'] = { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: 'chronology/public' } };
|
||||
graph['71'] = { class_type: 'PreviewImage', inputs: { images: ['60', 0] } };
|
||||
const id = await submit(execution.request, graph);
|
||||
await history(execution.request, id);const frozen = await capture(execution, id);
|
||||
const persisted = await readExecutionRuns(execution.databasePath, id);attempts.push({ row: persisted.rows[0], context: frozen.contexts[0] });
|
||||
}
|
||||
const positiveId = attempts[0].context.positive_prompt_id;
|
||||
const response = await execution.request.get(`/prompt_manager/library/${positiveId}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(3);expect(window.items.map(item => item.run_id)).toEqual(attempts.map(item => item.row.run_id));
|
||||
expect(window.context_summaries).toHaveLength(2);expect(new Set(attempts.map(item => item.context.positive.raw))).toEqual(new Set([positive]));
|
||||
expect(attempts.map(item => item.context.evidence.positive.effective.source)).toEqual(['runtime_return', 'host_cache_output', 'runtime_return']);
|
||||
const workspace = await context.newPage();const errors = [];workspace.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
const destination = await page.evaluate(async () => {const { app } = await import('/scripts/app.js');const node = LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
|
||||
for (const renderer of [testInfo.project.metadata.initialRenderer, !testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer => {const { app } = await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);await app.loadGraphData(app.graph.serialize());}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await workspace.goto(`/prompt_manager/workspace#/library/${positiveId}`);
|
||||
const recorded = workspace.getByRole('region', { name: 'Recorded executions', exact: true });await expect(recorded.locator('[data-context-branch]')).toHaveCount(2);
|
||||
await recorded.locator(`button[data-run-id="${attempts[0].row.run_id}"]`).click();
|
||||
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
|
||||
await expect(run.locator('[data-receipt]')).toHaveCount(4);
|
||||
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
|
||||
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
const hosts = sender.getByRole('combobox', { name: 'ComfyUI window', exact: true });await expect(hosts.locator('option')).toHaveCount(2);await hosts.selectOption({ index: 1 });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ index: 1 });await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
|
||||
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
const applied = await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);return { positive: node.widgets.find(w => w.name === 'text').value, negative: node.widgets.find(w => w.name === 'negative_text').value, effects: window.chronologyEffects };}, destination);
|
||||
expect(applied).toEqual({ positive: frozen.positive[mode.toLowerCase()], negative: frozen.negative[mode.toLowerCase()], effects: { queue: 0, load: 0 } });
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
} finally {await page.evaluate(async () => {const { app } = await import('/scripts/app.js');Object.assign(app, window.chronologyOriginals);});}
|
||||
}
|
||||
await workspace.keyboard.press('Escape');await expect(recorded).toBeVisible();
|
||||
}
|
||||
const versions = await (await execution.request.get('/system_stats')).json();await testInfo.attach('chronology-runtime.json', { body: JSON.stringify(versions.system, null, 2), contentType: 'application/json' });
|
||||
expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
});
|
||||
|
||||
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
|
||||
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
|
||||
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
|
||||
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
|
||||
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
|
||||
});
|
||||
|
||||
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
|
||||
const captures = [];
|
||||
const positiveSnapshotIds = [];
|
||||
|
||||
@@ -33,6 +33,24 @@ async function persisted(execution, id, outcomes) {
|
||||
}, { timeout: 15000, message: 'Exact attempts and final output evidence reach SQLite' }).toEqual({
|
||||
outcomes, states: outcomes.map(() => 'complete'),
|
||||
});
|
||||
for (const row of result.rows) {
|
||||
const detailResponse = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}`);
|
||||
expect(detailResponse.ok()).toBe(true);
|
||||
const detail = await detailResponse.json();
|
||||
expect(detail.run).toMatchObject({ run_id: row.run_id, outcome: row.outcome, queue_item_id: String(row.queue_item_id) });
|
||||
const response = await execution.request.get(`/prompt_manager/history/runs/${row.run_id}/outputs?epoch=${detail.history_epoch}`);
|
||||
expect(response.ok()).toBe(true);expect(response.headers()['cache-control']).toBe('no-store');
|
||||
const publicOutputs = await response.json(), captured = result.outputs[row.run_id];
|
||||
expect(publicOutputs.capture).toMatchObject({ state: captured.state, reason: captured.reason, source: captured.source });
|
||||
expect(publicOutputs.retained_occurrence_count).toBe(captured.references.length);
|
||||
expect(publicOutputs.items.map(item => ({ ...item.occurrence, ...item.reference })).map(({ run_id, ...reference }) => reference)).toEqual(
|
||||
captured.references.map(({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key }) =>
|
||||
({ output_node_id, image_ordinal, folder_type, subfolder, filename, path_style, root_key })));
|
||||
for (const item of publicOutputs.items) {
|
||||
expect(item).toMatchObject({ file_identity: 'unverified', newly_generated: 'unknown', context_membership: 'unknown' });
|
||||
expect(item.current_candidate.evidence_class).toBe('locator_candidate');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,3 +222,34 @@ test("latestOnly: errors from the current call propagate, errors from superseded
|
||||
rejectFirst(new Error("old failure"));
|
||||
assert.equal(await first, STALE);
|
||||
});
|
||||
|
||||
test("ApiError exposes only validated history recovery actions on all error paths", async () => {
|
||||
const permitted = [null, { action: "refresh-history" }, { action: "reload-outputs" },
|
||||
{ action: "retry-read" }, { action: "smaller-limit", limit: 1 },
|
||||
{ action: "smaller-limit", limit: 100 }];
|
||||
const denied = [undefined, false, "refresh-history", [], {}, { action: "unknown" },
|
||||
{ action: "refresh-history", private: "secret" }, { action: "retry-read", limit: 1 },
|
||||
{ action: "smaller-limit" }, { action: "smaller-limit", limit: 0 },
|
||||
{ action: "smaller-limit", limit: 101 }, { action: "smaller-limit", limit: true },
|
||||
{ action: "smaller-limit", limit: "1" }, { action: "smaller-limit", limit: 1.5 },
|
||||
{ action: "smaller-limit", limit: NaN }, { action: "smaller-limit", limit: Infinity },
|
||||
{ action: "smaller-limit", limit: 10, url: "/private" },
|
||||
Object.assign(Object.create({ action: "retry-read" }), { private: "secret" })];
|
||||
assert.equal(new ApiError(409, "old caller").recovery, null);
|
||||
for (const recovery of permitted.concat(denied)) {
|
||||
for (const status of [409, 200]) {
|
||||
const api = createApiClient(async () => jsonResponse(status, {
|
||||
success: false, error: "bounded", code: "history-changed", recovery,
|
||||
}));
|
||||
await assert.rejects(api.get("/x"), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.equal(error.code, "history-changed");
|
||||
assert.deepEqual(error.recovery, permitted.includes(recovery) ? recovery : null);
|
||||
if (recovery && permitted.includes(recovery)) {
|
||||
assert.notEqual(error.recovery, recovery, "server objects must be copied");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,3 +50,23 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
|
||||
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
|
||||
assert.deepEqual(sent[1],{negative:' \n\t '});
|
||||
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
@@ -48,3 +48,9 @@ test('metadata export preserves representation formats and omits private carrier
|
||||
assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET'));
|
||||
assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ');
|
||||
});
|
||||
|
||||
test('reported location metadata export carries exact occurrence without assigning historical ownership',async()=>{
|
||||
const {inspectionDownloads}=await load(),source=record();
|
||||
source.identity={kind:'reported-location',key:'b'.repeat(64),path:'folder/a.png',occurrence:{run_id:'22222222-2222-2222-2222-222222222222',output_node_id:'𐀀',image_ordinal:255},history_epoch:'11111111-1111-1111-1111-111111111111',current_scope:'c'.repeat(64),reference:{folder_type:'temp',subfolder:'folder',filename:'a.png',path_style:'posix',root_key:null}};
|
||||
const value=JSON.parse(inspectionDownloads(source).metadata.bodyText);assert.deepEqual(value.identity,source.identity);assert.equal(value.identity.root_index,undefined);assert.equal(value.context_id,undefined);assert.equal(value.file_identity,undefined);assert.deepEqual(value.prompts,source.prompts);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/history-state.mjs');
|
||||
|
||||
test('chronological columns keep the visible runs, three-column overscan and distant focus in ascending order', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function', 'history must expose a bounded column window');
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 640, 128, 999), [7,8,9,10,11,12,13,14,999]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 0, 64), [0,1,2,3]);
|
||||
assert.deepEqual(visibleHistoryColumns(1001, 64000, 390, 0), [0,997,998,999,1000]);
|
||||
assert.deepEqual(visibleHistoryColumns(3, 100, 1), [0,1,2]);
|
||||
assert.deepEqual(visibleHistoryColumns(0, 0, 500), []);
|
||||
});
|
||||
|
||||
test('column geometry handles fractional edges, invalid scroll and focus without scanning all loaded runs', async () => {
|
||||
const {visibleHistoryColumns} = await source;
|
||||
assert.equal(typeof visibleHistoryColumns, 'function');
|
||||
assert.deepEqual(visibleHistoryColumns(50000, 640.5, 64, 0), [0,7,8,9,10,11,12,13,14]);
|
||||
assert.deepEqual(visibleHistoryColumns(121, NaN, Infinity), [0,1,2,3]);
|
||||
assert.equal(visibleHistoryColumns(120, 900, 1).length, 120);
|
||||
assert.ok(visibleHistoryColumns(10000000, 40000, 390).length <= 14);
|
||||
for(const count of [-1, 1.1, NaN, '4']) assert.throws(()=>visibleHistoryColumns(count,0,100), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createInspectionController,originalMediaDownload} from '../../web/js/workspace/inspection-state.mjs';
|
||||
import {createViewerSession} from '../../web/js/workspace/viewer-state.mjs';
|
||||
const require=createRequire(import.meta.url),{extractGenerationFacts}=require('../../web/js/comfy-metadata.js');
|
||||
const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND') throw e;return {};});
|
||||
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
|
||||
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
|
||||
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
|
||||
|
||||
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
|
||||
|
||||
test('occurrence URLs reject endpoint query origin credentials fragments and identity ambiguities',()=>{const a=adapter(),target=media();for(const patch of [{url:target.url.replace('/media?','/thumbnail?')},{url:target.url+'&node=1'},{url:target.url+'&ordinal=0'},{url:target.url+'&epoch='+epoch},{url:target.url+'¤t_scope='+scope},{url:target.url+'&root=0'},{url:target.url+'#x'},{url:'https://attacker.test'+target.url},{url:'http://user:pass@localhost'+target.url},{url:target.url.replace('ordinal=0','ordinal=00')},{url:target.url.replace('node=1','node=2')},{inspection_url:target.url},{thumbnail_url:target.url},{history_epoch:'bad'},{current_scope:'A'.repeat(64)},{location_key:''},{occurrence:{...target.occurrence,image_ordinal:256}},{reference:{...target.reference,subfolder:'../bad'}}]) assert.throws(()=>a.validate({...target,...patch}),TypeError);const cached={...target,url:target.url+'&_pm_viewer=1'};assert.equal(a.originalDownload(cached).url,cached.url);});
|
||||
|
||||
test('proxy URLs retain exact configured prefix and reject unprefixed alternatives',()=>{const a=adapter(),previous=globalThis.window;globalThis.window={location:{href:'https://host.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:value=>value.startsWith('/prompt_manager/')?'/proxy'+value:value}};try {const target=media();assert.equal(a.originalDownload(target).url,'/proxy'+target.url);assert.throws(()=>a.originalDownload({...target,url:'https://host.test'+target.url}),TypeError);assert.throws(()=>a.originalDownload({...target,url:'https://host.test.attacker.test/proxy'+target.url}),TypeError);}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}});
|
||||
|
||||
test('four repeated receipt coordinates form one loaded Viewer location retaining selected representative',()=>{assert.equal(typeof m.distinctCurrentMedia,'function');const values=[media('1',0),media('1',1),media('2',0),media('2',1)],receipts=values.map(receipt);assert.equal(new Set(receipts.map(r=>m.occurrenceKey(r.occurrence))).size,4);for(const chosen of [values[0],values[3]]) {const items=m.distinctCurrentMedia(receipts,chosen.occurrence);assert.equal(items.length,1);assert.deepEqual(items[0].occurrence,chosen.occurrence);}assert.deepEqual(m.distinctCurrentMedia(receipts,null)[0].occurrence,values[0].occurrence);const unavailable={...receipts[0],current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};assert.deepEqual(m.distinctCurrentMedia([unavailable],null),[]);});
|
||||
|
||||
test('shared occurrence inspection keeps current facts and exports exact unverified evidence',async()=>{const a=adapter(),target=media('2',1),data=envelope(target);const record=a.toRecord(data,target,extractGenerationFacts);assert.deepEqual(record.identity,{kind:'reported-location',key:location,path:'folder/a #%.png',occurrence:target.occurrence,history_epoch:epoch,current_scope:scope,reference:target.reference});assert.equal(record.prompts.positive.text,'current metadata only');assert.equal(record.generation.seed.status,'not-recorded');assert.equal(record.identity.root_index,undefined);assert.deepEqual(record.download,{url:target.url,filename:target.filename});const {inspectionDownloads}=await import('../../web/js/workspace/file-inspector.mjs');const exported=JSON.parse(inspectionDownloads(record).metadata.bodyText);assert.deepEqual(exported.identity.occurrence,{run_id:run,output_node_id:'2',image_ordinal:1});assert.equal(exported.identity.history_epoch,epoch);assert.equal(exported.prompts.positive.text,'current metadata only');});
|
||||
|
||||
test('identity scope epoch or occurrence echo mismatches require refresh but malformed metadata is retryable',()=>{const a=adapter();for(const mutate of [p=>p.history_epoch='33333333-3333-3333-3333-333333333333',p=>p.current_output.current_scope='d'.repeat(64),p=>p.current_output.occurrence={...p.current_output.occurrence,image_ordinal:1},p=>p.current_output.reference={...p.current_output.reference,filename:'other.png'},p=>p.current_output.location_key='d'.repeat(64)]){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e.refreshRequired===true && typeof e.code==='string');}for(const mutate of [p=>p.success=false,p=>p.inspection_version=2,p=>p.metadata.parameters='fake',p=>p.current_output.file_identity='verified',p=>p.current_output.observation='historical-file']){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e instanceof TypeError && !e.refreshRequired);}});
|
||||
|
||||
test('shared inspection controller distinguishes recovery codes and obsolete receipt errors',async()=>{const a=adapter();for(const [status,code,refresh] of [[409,'history-changed',true],[409,'candidate-stale',true],[409,'capture-changed',true],[400,'invalid-request',false],[400,'invalid-cursor',false]]){const calls=[];const c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const p=c.select(media());assert.equal(calls[0].url,media().inspection_url);calls[0].reject({status,code,recovery:{action:'reload-outputs'}});await p;assert.equal(c.state.refreshRequired,refresh);assert.equal(c.state.code,code);const retry=c.retry();assert.equal(calls.length,refresh?1:2);if(!refresh){calls[1].resolve(envelope());await retry;}c.dispose();}const calls=[],c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const first=c.select(media('1',0)),second=c.select(media('1',1));assert.equal(calls.length,2,'same location needs exact occurrence selection');assert.equal(calls[0].signal.aborted,true);calls[1].resolve(envelope(media('1',1)));await second;calls[0].reject({status:409,code:'history-changed'});await first;assert.equal(c.state.record.identity.occurrence.image_ordinal,1);c.dispose();});
|
||||
|
||||
test('Viewer freezes selected occurrence and location entries through navigation and late timers',()=>{const a=adapter(),first=media('1',1),second=media('2',0,'d'.repeat(64)),items=[first,second],seen=[];const view=createViewerSession({adapter:a,items,initialKey:location,onChange:(state,item)=>seen.push([state,item])});first.occurrence.image_ordinal=9;first.reference.filename='mutated';items.reverse();assert.equal(seen[0][1].occurrence.image_ordinal,1);assert.equal(seen[0][1].reference.filename,'a #%.png');view.navigate(1);assert.equal(view.state.key,'d'.repeat(64));view.navigate(-1);assert.equal(seen.at(-1)[1].occurrence.image_ordinal,1);view.dispose();const count=seen.length;view.navigate(1);view.ready(location);view.play();assert.equal(seen.length,count);});
|
||||
|
||||
test('malformed occurrence echoes invalidate actions instead of producing retryable current facts',()=>{const a=adapter();for(const occurrence of [{run_id:run,output_node_id:'1',image_ordinal:'0'},{run_id:'bad',output_node_id:'1',image_ordinal:0},{}]){const data=envelope();data.current_output.occurrence=occurrence;assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),error=>error.refreshRequired===true && error.code==='candidate-stale');}});
|
||||
|
||||
test('stale inspection echoes use the canonical recovery vocabulary and identity status',()=>{
|
||||
for(const [code,action] of [['history-changed','refresh-history'],['candidate-stale','reload-outputs']]) {
|
||||
const data=envelope();
|
||||
if(code==='history-changed')data.history_epoch='33333333-3333-3333-3333-333333333333';
|
||||
else data.current_output.current_scope='d'.repeat(64);
|
||||
assert.throws(()=>adapter().toRecord(data,media(),extractGenerationFacts),error=>{
|
||||
assert.equal(error.code,code);assert.equal(error.status,409);assert.deepEqual(error.recovery,{action});return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createApiClient} = createRequire(import.meta.url)('../../web/js/api-client.js');
|
||||
const {extractGenerationFacts} = createRequire(import.meta.url)('../../web/js/comfy-metadata.js');
|
||||
import {createInspectionController} from '../../web/js/workspace/inspection-state.mjs';
|
||||
import {reportedLocationAdapter} from '../../web/js/workspace/history-media.mjs';
|
||||
const m = await import('../../web/js/workspace/history-state.mjs').catch(e => {if(e.code !== 'ERR_MODULE_NOT_FOUND') throw e; return {};});
|
||||
const epoch='11111111-1111-1111-1111-111111111111', session='22222222-2222-2222-2222-222222222222';
|
||||
const id=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`;
|
||||
const digest=n=>String(n).repeat(64), fields=['raw','effective','prepend','append'];
|
||||
const proof=(provenance='recorded')=>({provenance,source:provenance==='recorded'?'runtime_return':'unknown',reason:provenance==='recorded'?null:'missing'});
|
||||
function context(anchor='1',branch='a') {return {anchor_id:anchor,context_id:digest(branch),positive_raw_text_id:digest('e'),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d'),positive:{raw:'positive\n猫',effective:null,prepend:'',append:''},negative:{raw:'',effective:'negative',prepend:'',append:''},evidence:{format_version:1,role_provenance:'inferred',positive:Object.fromEntries(fields.map(f=>[f,proof(f==='effective'?'unknown':'recorded')])),negative:Object.fromEntries(fields.map(f=>[f,proof()]))},positive_prompt_id:7,negative_prompt_id:8,pair_id:9,reference_epoch:epoch,navigation:{positive_available:true,negative_available:true,pair_available:true}};}
|
||||
const capture={state:'complete',reason:'captured',format_version:1};
|
||||
const output={state:'complete',reason:'reported',format_version:1,source:'host_final_history',retained_occurrence_count:4,list_status:'complete'};
|
||||
function run(n,branch='a') {return {run_id:id(n),session_id:session,storage_epoch:epoch,job_id:id(99),record_version:1,queue_item_id:String(n),observed_dequeue_at:String(9007199254740990n+BigInt(n)),observed_callback_at:null,executor_started_at:null,terminal_event_at:null,outcome:'observed',reason:null,diagnostics_omitted:false,diagnostics_available:false,context_capture:capture,output_summary:output,matching_anchors:[{anchor_id:'1',context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d')}]};}
|
||||
function windowPage(items=[run(1),run(2),run(3,'b')],more=false) {const summaries=new Map();for(const r of items) {const a=r.matching_anchors[0];if(!summaries.has(a.context_id)) summaries.set(a.context_id,{context_id:a.context_id,positive_snapshot_id:a.positive_snapshot_id,negative_snapshot_id:a.negative_snapshot_id,negative_raw:{state:'explicit_empty',preview:'',truncated:false,evidence:[]}});summaries.get(a.context_id).negative_raw.evidence.push({run_id:r.run_id,anchor_id:'1',...proof()});}return {success:true,history_version:1,history_epoch:epoch,group:{positive_id:7,raw_text_id:digest('e'),relation:'exact_frozen_raw_text',scope_status:'matched'},recorded_run_count:3,resume_cursor:items.length?'resume-token':null,items,context_summaries:[...summaries.values()],has_more:more,next_cursor:more?'window-token':null};}
|
||||
const detail=(n=1)=>({success:true,history_version:1,history_epoch:epoch,run:run(n),contexts:[context(),context('2','b')],context_capture:capture,output_summary:output});
|
||||
function receipt(node='1',ordinal=0) {return {occurrence:{run_id:id(1),output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'image.png',path_style:'posix',root_key:digest('a')},file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};}
|
||||
const outputs=(items=[receipt('1',0),receipt('1',1)],more=true)=>({success:true,history_version:1,history_epoch:epoch,capture:{state:'complete',reason:'reported',format_version:1,source:'host_final_history'},capture_fingerprint:digest('f'),retained_occurrence_count:4,list_status:'complete',items,has_more:more,next_cursor:more?'outputs-token':null});
|
||||
function setup(restore) {const requests=[],changes=[];assert.equal(typeof m.createHistoryController,'function','history controller is exported');const c=m.createHistoryController({positiveId:7,restore,onChange:s=>changes.push(s),api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({url,signal,resolve,reject}))}});return {c,requests,changes};}
|
||||
async function loaded(restore) {const s=setup(restore),p=s.c.load();s.requests.at(-1).resolve(windowPage());await p;return s;}
|
||||
async function selected() {const s=await loaded(),p=s.c.selectRun(id(1));s.requests.at(-1).resolve(detail());await p;return s;}
|
||||
|
||||
test('chronology keeps A A B as three runs and two branches with exact decimal ordering',async()=>{const {c,requests}=await loaded();assert.deepEqual(c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);assert.equal(c.state.window.context_summaries.length,2);assert.equal(c.state.window.items[2].observed_dequeue_at,'9007199254740993');assert.equal(requests[0].url,'/prompt_manager/library/7/history?limit=40');assert.equal(c.state.window.recorded_run_count,3);c.dispose();});
|
||||
|
||||
test('earlier windows prepend full identities once and refresh replaces bounded count',async()=>{const s=setup();let p=s.c.load();s.requests[0].resolve(windowPage([run(2),run(3,'b')],true));await p;p=s.c.loadEarlier();assert.match(s.requests[1].url,/cursor=window-token/);s.requests[1].resolve(windowPage([run(1),run(2)],false));await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);p=s.c.refresh();s.requests[2].resolve({...windowPage([run(3)]),recorded_run_count:1});await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(3)]);assert.equal(s.c.state.window.recorded_run_count,1);s.c.dispose();});
|
||||
|
||||
test('malformed windows never enable actions',async()=>{for(const mutate of [p=>p.items[0].queue_item_id='01',p=>p.items[0].observed_dequeue_at='9223372036854775808',p=>p.items[0].run_id='bad',p=>p.history_epoch='bad',p=>p.group.positive_id=8,p=>p.context_summaries[0].negative_raw.preview='x'.repeat(101),p=>p.has_more=true,p=>p.next_cursor='',p=>p.items[0].matching_anchors[0].context_id='x',p=>p.items[0].observed_callback_at=1,p=>p.recorded_run_count=-1]) {const {c,requests}=setup();const p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
||||
|
||||
test('frozen roles retain explicit empty and unknown values with their own evidence',()=>{assert.equal(typeof m.frozenPromptRoles,'function');const ctx=context(),raw=m.frozenPromptRoles(ctx,'raw'),effective=m.frozenPromptRoles(ctx,'effective');assert.equal(raw.negative.status,'resolved');assert.equal(raw.negative.text,'');assert.deepEqual(raw.negative.evidence,proof());assert.equal(effective.positive.status,'unresolved');assert.equal(effective.positive.text,null);assert.deepEqual(effective.positive.evidence,proof('unknown'));assert.equal(effective.negative.text,'negative');assert.equal(raw.positive.text,'positive\n猫');assert.throws(()=>m.frozenPromptRoles(ctx,'wrong'),TypeError);});
|
||||
|
||||
test('detail validates exact roles evidence IDs and epoch before selection',async()=>{for(const mutate of [p=>p.history_epoch=id(4),p=>p.run.run_id=id(2),p=>p.contexts[0].negative_snapshot_id=null,p=>p.contexts[0].positive.raw=42,p=>p.contexts[0].evidence.positive.raw.source='unknown',p=>p.contexts[0].navigation.pair_available=1,p=>p.contexts[0].positive_prompt_id=0,p=>p.contexts.push(p.contexts[0])]) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error || c.state.refreshRequired);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
|
||||
|
||||
test('dependent selections abort late run output success and errors including disposal',async()=>{const {c,requests,changes}=await loaded();const p=c.selectRun(id(1)),q=c.selectRun(id(2));assert.equal(requests[1].signal.aborted,true);requests[2].resolve(detail(2));await q;requests[1].reject({status:409,code:'history-changed'});await p;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.refreshRequired,null);const r=c.selectRun(id(1));requests[3].resolve(detail());await r;for(const change of [()=>c.selectContext('2'),()=>c.setMode('effective')]) {const a=c.loadOutputs();const request=requests.at(-1);change();assert.equal(request.signal.aborted,true);request.resolve(outputs());await a;assert.equal(c.state.outputs.items.length,0);}const a=c.loadOutputs(),request=requests.at(-1);c.dispose();const count=changes.length;request.reject({status:409,code:'history-changed'});await a;assert.equal(changes.length,count);});
|
||||
|
||||
test('output pagination preserves binary order and exact repeated receipt selection on retry',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:1});assert.equal(c.state.selection.receipt.image_ordinal,1);p=c.loadOutputs();const url=requests.at(-1).url;requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');p=c.retryOutputs();assert.equal(requests.at(-1).url,url);requests.at(-1).resolve(outputs([receipt('\ue000'),receipt('𐀀')],false));await p;assert.deepEqual(c.state.outputs.items.map(r=>r.occurrence.output_node_id),['1','1','\ue000','𐀀']);assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();});
|
||||
|
||||
test('receipt selection invalidates pending responses without losing loaded coordinates',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();const pending=requests.at(-1);c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(pending.signal.aborted,true);pending.reject({status:409,code:'capture-changed'});await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.selection.receipt.image_ordinal,0);assert.equal(c.state.refreshRequired,null);c.dispose();});
|
||||
|
||||
test('response-limit retries only the failed cursor at the permitted smaller limit',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:20}});await p;assert.equal(c.state.outputs.items.length,2);p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=20/);assert.match(requests.at(-1).url,/cursor=outputs-token/);requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;assert.equal(c.state.outputs.items.length,4);c.dispose();});
|
||||
|
||||
test('real ApiClient response recovery retries the same failed output cursor at its smaller limit',async()=>{
|
||||
const calls=[];let reads=0;
|
||||
const api=createApiClient(async(url,init)=>{
|
||||
calls.push({url,method:init.method});
|
||||
const path=new URL(url,'http://localhost');
|
||||
if(path.pathname.endsWith('/history'))return Response.json(windowPage());
|
||||
if(!path.pathname.endsWith('/outputs'))return Response.json(detail());
|
||||
reads++;
|
||||
if(reads===1)return Response.json(outputs());
|
||||
if(reads===2)return Response.json({success:false,history_version:1,code:'response-limit',error:'Reported outputs exceed the response budget',recovery:{action:'smaller-limit',limit:20}},{status:422});
|
||||
return Response.json(outputs([receipt('2',0),receipt('2',1)],false));
|
||||
});
|
||||
const c=m.createHistoryController({api,positiveId:7});
|
||||
try {
|
||||
await c.load();await c.selectRun(id(1));await c.loadOutputs();
|
||||
c.selectReceipt(receipt('1',1).occurrence);const retained=c.state.outputs.items;
|
||||
await c.loadOutputs();
|
||||
assert.deepEqual(c.state.outputs.error.recovery,{action:'smaller-limit',limit:20});
|
||||
assert.equal(c.state.outputs.items,retained);assert.equal(c.state.selection.receipt.image_ordinal,1);
|
||||
await c.retryOutputs();
|
||||
const pages=calls.filter(call=>call.url.includes('/outputs?')).map(call=>new URL(call.url,'http://localhost'));
|
||||
assert.deepEqual(pages.map(url=>url.searchParams.get('limit')),['40','40','20']);
|
||||
assert.deepEqual(pages.map(url=>url.searchParams.get('cursor')),[null,'outputs-token','outputs-token']);
|
||||
assert.deepEqual(c.state.outputs.items.map(item=>[item.occurrence.output_node_id,item.occurrence.image_ordinal]),[['1',0],['1',1],['2',0],['2',1]]);
|
||||
assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(c.state.outputs.has_more,false);
|
||||
assert.ok(calls.every(call=>call.method==='GET'));
|
||||
} finally {c.dispose();}
|
||||
});
|
||||
|
||||
test('history change clears every actionable object and revalidates parent independently',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'history-changed',recovery:{action:'refresh-history'}});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);assert.equal(c.state.run.detail,null);assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.actionsEnabled,false);assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.parent.detail.positive.id,7);assert.equal(c.state.refreshLabel,'Reload recorded history');c.dispose();});
|
||||
|
||||
test('capture and candidate changes clear candidates and reload output page one',async()=>{for(const code of ['capture-changed','candidate-stale']) {const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:409,code});await p;assert.equal(c.state.outputs.items.length,0);assert.equal(c.state.actionsEnabled,false);assert.doesNotMatch(requests.at(-1).url,/cursor=/);requests.at(-1).resolve({...outputs([],false),retained_occurrence_count:0});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.outputs.error,null);c.dispose();}});
|
||||
|
||||
test('reentry validates epoch before actions while restore accepts only harmless intent',async()=>{const {c,requests}=await loaded({search:'猫',scrollTop:120,mode:'effective',state:{window:windowPage()},pairId:9});assert.equal(c.state.intent.search,'猫');assert.equal(c.state.selection.mode,'effective');const p=c.revalidate();assert.equal(c.state.actionsEnabled,false);requests.at(-1).resolve({...windowPage(),history_epoch:id(4)});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();});
|
||||
|
||||
test('invalid caller identifiers cannot request history or select nonexistent contexts and receipts',async()=>{assert.equal(typeof m.createHistoryController,'function');for(const positiveId of [0,'7',NaN,9007199254740992]) assert.throws(()=>m.createHistoryController({positiveId,api:{}}),TypeError);const {c,requests}=await selected();await assert.rejects(c.selectRun('bad'),TypeError);assert.throws(()=>c.selectContext('missing'),TypeError);assert.throws(()=>c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0}),TypeError);assert.equal(requests.length,2);c.dispose();});
|
||||
|
||||
test('malformed output coordinates capture fingerprints evidence and continuations fail closed',async()=>{for(const mutate of [p=>p.items[0].occurrence.image_ordinal=256,p=>p.items[0].occurrence.run_id=id(2),p=>p.capture_fingerprint='bad',p=>p.has_more=false,p=>p.history_epoch=id(4),p=>p.items[0].file_identity='verified',p=>p.items[0].reference.root_key='bad',p=>p.items[0].current_candidate.url='/fake',p=>p.items[0].occurrence.output_node_id='\ud800']) {const {c,requests}=await selected();const p=c.loadOutputs(),data=outputs();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.outputs.error || c.state.refreshRequired);assert.equal(c.state.outputs.items.length,0);c.dispose();}});
|
||||
|
||||
test('restored identifiers select only matching freshly validated run and context',async()=>{const {c,requests}=setup({runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputs:outputs(),receipt:receipt().occurrence});assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.items.length,0);let p=c.load();requests[0].resolve(windowPage());await p;assert.equal(requests.length,2);assert.equal(c.state.run.loading,true);requests[1].resolve(detail());await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.selection.receipt,null);c.dispose();});
|
||||
|
||||
test('new run selection invalidates a pending reentry epoch check and its late error',async()=>{const {c,requests}=await selected();const validation=c.revalidate(),old=requests.at(-1),next=c.selectRun(id(2));assert.equal(old.signal.aborted,true);requests.at(-1).resolve(detail(2));await next;old.reject({status:409,code:'history-changed'});await validation;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.run.detail.run.run_id,id(2));assert.equal(c.state.refreshRequired,null);assert.equal(c.state.validating,false);c.dispose();});
|
||||
|
||||
test('explicit detail limit errors stay bounded read failures and retry only the detail',async()=>{const {c,requests}=await loaded();let p=c.selectRun(id(1),{anchorId:'2',contextId:digest('b')});requests.at(-1).reject({status:422,code:'response-limit'});await p;assert.equal(c.state.run.error.code,'response-limit');assert.equal(c.state.window.items.length,3);assert.equal(c.state.refreshRequired,null);p=c.retryRun();assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?epoch=/);requests.at(-1).resolve(detail());await p;assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.run.error,null);assert.equal(requests.length,3);c.dispose();});
|
||||
|
||||
test('repeated stale output responses stop automatic recovery and retain explicit reload state',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'candidate-stale'});await p;assert.equal(requests.length,4);requests.at(-1).reject({status:409,code:'candidate-stale'});await new Promise(resolve=>setImmediate(resolve));assert.equal(requests.length,4,'recovery must not poll indefinitely');assert.equal(c.state.refreshRequired,'candidate-stale');assert.equal(c.state.outputs.error.code,'candidate-stale');c.dispose();});
|
||||
|
||||
test('known and unknown frozen evidence cannot contradict their field or declared role provenance',async()=>{for(const mutate of [p=>p.contexts[0].evidence.role_provenance='recorded',p=>p.contexts[0].evidence.negative.raw.reason='missing',p=>p.contexts[0].evidence.positive.effective.reason=null,p=>p.run.outcome='conflicting',p=>p.run.reason='terminal_error']) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);c.dispose();}});
|
||||
|
||||
test('contradictory page counts and unrelated summary evidence are rejected before showing branches',async()=>{for(const mutate of [p=>{p.items=[];p.has_more=true;p.next_cursor='more';},p=>p.context_summaries[0].negative_raw.evidence[0].run_id=id(88),p=>p.context_summaries[0].positive_snapshot_id=digest('f')]){const {c,requests}=setup(),p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);c.dispose();}const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0)],false));await p;assert.ok(c.state.outputs.error);assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');c.dispose();});
|
||||
|
||||
function availableReceipt(currentScope,node='1',ordinal=0) {
|
||||
const r=receipt(node,ordinal),locationKey=currentScope==='a'?digest('b'):digest('9');
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:digest(currentScope)}),base=`/prompt_manager/history/runs/${id(1)}/output/current`;
|
||||
const media={kind:'reported-location',occurrence:r.occurrence,reference:r.reference,history_epoch:epoch,current_scope:digest(currentScope),location_key:locationKey,filename:'image.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
|
||||
return {...r,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:digest(currentScope),location_key:locationKey,relative_path:'image.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media}};
|
||||
}
|
||||
|
||||
test('every revalidation emission clears old available candidates and selected receipt before enabling actions',async()=>{
|
||||
const {c,requests,changes}=await selected();let p=c.loadOutputs();
|
||||
requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
||||
c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(c.state.actionsEnabled,true);
|
||||
const first=changes.length,validation=c.revalidate();assert.equal(c.state.validating,true);assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
const pending=requests.at(-1);assert.match(pending.url,/\/outputs\?/);assert.equal(c.state.outputs.loading,true);
|
||||
const assertSafe=state=>{if(state.actionsEnabled){assert.equal(state.selection.receipt,null,'an enabled emission cannot retain the pre-validation receipt');assert.ok(state.outputs.items.every(r=>r.current_candidate.media.current_scope!==digest('a')),'an enabled emission cannot expose old current scope');}};
|
||||
try {
|
||||
changes.slice(first).forEach(assertSafe);
|
||||
pending.resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await validation;
|
||||
changes.slice(first).forEach(assertSafe);assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));
|
||||
assert.equal(c.state.selection.receipt,null);assert.equal(c.state.validating,false);assert.equal(c.state.actionsEnabled,true);
|
||||
} finally {c.dispose();}
|
||||
});
|
||||
|
||||
test('pending mode-independent detail keeps the current mode and derives its exact effective roles without another GET',async()=>{
|
||||
const {c,requests}=await loaded(),pending=c.selectRun(id(1)),read=requests.at(-1);
|
||||
c.setMode('effective');assert.equal(read.signal.aborted,false);assert.equal(requests.length,2);
|
||||
read.resolve(detail());await pending;
|
||||
assert.equal(c.state.selection.mode,'effective');const ctx=c.state.run.detail.contexts.find(ctx=>ctx.anchor_id===c.state.selection.anchor_id);
|
||||
const roles=m.frozenPromptRoles(ctx,c.state.selection.mode);assert.equal(roles.negative.text,'negative');assert.equal(roles.positive.text,null);assert.equal(roles.positive.status,'unresolved');assert.deepEqual(roles.positive.evidence,proof('unknown'));
|
||||
assert.equal(requests.length,2);assert.equal(c.state.run.error,null);c.dispose();
|
||||
});
|
||||
|
||||
test('active inspection history failure clears every emitted owning state for HTTP and stale-echo errors',async()=>{
|
||||
for(const transport of ['http','echo']) {
|
||||
const {c,requests,changes}=await selected();let pending=c.loadOutputs();
|
||||
const item=availableReceipt('a');requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
||||
const target=item.current_candidate.media,client=createApiClient(async()=>transport==='http'
|
||||
? Response.json({success:false,code:'history-changed',error:'Recorded history changed',recovery:{action:'refresh-history'}},{status:409})
|
||||
: Response.json({success:true,history_version:1,inspection_version:1,history_epoch:id(8)}));
|
||||
const inspection=createInspectionController({api:client,adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
||||
const remove=inspection.subscribe(state=>c.inspectionFailed?.(target,state)),first=changes.length;
|
||||
try {
|
||||
await inspection.select(target);
|
||||
assert.equal(c.state.refreshRequired,'history-changed');
|
||||
for(const state of changes.slice(first)) {
|
||||
assert.equal(state.actionsEnabled,false);assert.deepEqual(state.window.items,[]);assert.equal(state.window.history_epoch,null);
|
||||
assert.equal(state.run.detail,null);assert.equal(state.selection.run_id,null);assert.equal(state.selection.anchor_id,null);assert.equal(state.selection.context_id,null);assert.equal(state.selection.receipt,null);
|
||||
assert.equal(state.window.next_cursor,null);assert.equal(state.outputs.next_cursor,null);assert.deepEqual(state.outputs.items,[]);
|
||||
}
|
||||
assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.parent.detail.positive.text,'saved');assert.equal(c.state.actionsEnabled,false);
|
||||
} finally {remove();inspection.dispose();c.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('candidate and capture inspection failures keep their narrower recovery and obsolete targets cannot invalidate selection',async()=>{
|
||||
for(const code of ['candidate-stale','capture-changed']) {
|
||||
const {c,requests}=await selected();let pending=c.loadOutputs();const item=availableReceipt('a');
|
||||
requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
|
||||
c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code});
|
||||
assert.equal(c.state.refreshRequired,code);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.run.detail.run.run_id,id(1));assert.equal(c.state.selection.receipt,null);assert.deepEqual(c.state.outputs.items,[]);
|
||||
assert.match(requests.at(-1).url,/\/outputs\?/);requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
||||
c.selectReceipt(item.occurrence);c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code:'history-changed'});
|
||||
assert.equal(c.state.refreshRequired,null,'old candidate scope cannot invalidate the fresh receipt');assert.equal(c.state.selection.run_id,id(1));
|
||||
c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('targeted unavailable receipt retry replaces only its bounded page and keeps all exact visit selections',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
||||
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;
|
||||
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt('2',1).occurrence);
|
||||
const earlier=c.state.outputs.items.slice(0,2),readCount=requests.length;
|
||||
p=c.retryReceipt?.(receipt('2',1).occurrence);
|
||||
assert.equal(requests.length,readCount+1,'retry must read the successful page that owns this occurrence');
|
||||
assert.match(requests.at(-1).url,/limit=40&cursor=outputs-token/);
|
||||
requests.at(-1).resolve(outputs([receipt('2',0),availableReceipt('9','2',1)],false));await p;
|
||||
assert.deepEqual(c.state.outputs.items.slice(0,2),earlier);assert.equal(c.state.outputs.items.length,4);
|
||||
assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
||||
assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.outputs.next_cursor,null);
|
||||
const before=requests.length;await c.loadOutputs();assert.equal(requests.length,before,'availability retry cannot invent another page');c.dispose();
|
||||
});
|
||||
|
||||
test('targeted retry retains the receipt on read failure and suppresses late availability after a newer selection',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt(receipt().occurrence);
|
||||
p=c.retryReceipt?.(receipt().occurrence);assert.equal(requests.length,4);
|
||||
requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
||||
p=c.retryOutputs();const pending=requests.at(-1);c.selectReceipt(receipt('1',1).occurrence);assert.equal(pending.signal.aborted,true);
|
||||
pending.resolve(outputs([availableReceipt('9'),receipt('1',1)]));await p;
|
||||
assert.equal(c.state.outputs.items[0].current_candidate.status,'unavailable');assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();
|
||||
});
|
||||
|
||||
test('targeted page retry honors a smaller budget and seeks only within that already loaded receipt page',async()=>{
|
||||
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
|
||||
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;c.selectReceipt(receipt('2',1).occurrence);
|
||||
p=c.retryReceipt?.(receipt('2',1).occurrence);assert.equal(requests.length,5);
|
||||
requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:1}});await p;
|
||||
p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=1&cursor=outputs-token/);
|
||||
requests.at(-1).resolve({...outputs([receipt('2',0)],true),next_cursor:'target-next'});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.match(requests.at(-1).url,/limit=1&cursor=target-next/);requests.at(-1).resolve(outputs([availableReceipt('9','2',1)],false));await p;
|
||||
assert.equal(c.state.outputs.items.length,4);assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(requests.length,7);c.dispose();
|
||||
});
|
||||
|
||||
test('same-epoch refresh restores exact selected context mode and receipt only from fresh reads',async()=>{
|
||||
const {c,requests,changes}=await selected();let p=c.loadOutputs();requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
|
||||
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt().occurrence);const first=changes.length;
|
||||
p=c.refresh();requests.at(-1).resolve(windowPage());await p;
|
||||
assert.match(requests.at(-1).url,/\/history\/runs\//,'refresh must revalidate the selected run');assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.match(requests.at(-1).url,/\/outputs\?/);assert.equal(c.state.actionsEnabled,false);
|
||||
assert.ok(changes.slice(first).every(state=>!state.actionsEnabled),'old objects must never be briefly enabled');
|
||||
requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
|
||||
assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
});
|
||||
|
||||
test('fresh exact group membership keeps an older selected run outside a refreshed newest prefix',async()=>{
|
||||
for(const member of [true,false]) {
|
||||
const {c,requests}=await selected();c.selectContext('2');c.setMode('effective');
|
||||
const p=c.refresh();requests.at(-1).resolve({...windowPage([run(60),run(61)]),recorded_run_count:61});await p;
|
||||
assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?/);
|
||||
const fresh=detail();if(!member)fresh.contexts.forEach(context=>context.positive_raw_text_id=digest('f'));
|
||||
requests.at(-1).resolve(fresh);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.deepEqual(c.state.window.items.map(run=>run.run_id),[id(60),id(61)]);
|
||||
if(member) {assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.actionsEnabled,true);}
|
||||
else {assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);assert.ok(c.state.run.error);assert.equal(c.state.selection.run_id,null,'an incompatible saved selection must be discarded');}
|
||||
c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('reentry restores exactly the prior bounded timeline pages before enabling the older selection',async()=>{
|
||||
const restored={historyEpoch:epoch,resumeCursor:'old-upper-resume',loadedCount:80,runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputCount:4,receipt:receipt('2',1).occurrence,scrollLeft:731,scrollTop:95};
|
||||
const {c,requests,changes}=setup(restored),p=c.load();assert.match(requests.at(-1).url,/resume=old-upper-resume/);
|
||||
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/cursor=window-token/);
|
||||
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(1+i))),recorded_run_count:80});await p;
|
||||
assert.equal(c.state.window.items.length,80);assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/\/runs\//);
|
||||
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
|
||||
requests.at(-1).resolve(outputs([receipt('1',0),receipt('1',1),receipt('2',0),receipt('2',1)],false));await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,4);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
|
||||
assert.equal(c.state.intent.scrollLeft,731);assert.equal(c.state.intent.scrollTop,95);assert.equal(c.state.actionsEnabled,true);
|
||||
assert.ok(changes.slice(0,-1).every(state=>!state.actionsEnabled));c.dispose();
|
||||
});
|
||||
|
||||
test('refresh epoch change clears old selection before independent parent revalidation',async()=>{
|
||||
const {c,requests}=await selected();let p=c.refresh();requests.at(-1).resolve({...windowPage(),history_epoch:id(8)});await p;
|
||||
assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.selection.run_id,null);assert.equal(c.state.window.resume_cursor,null);assert.equal(c.state.run.detail,null);assert.deepEqual(c.state.outputs.items,[]);assert.equal(c.state.actionsEnabled,false);
|
||||
requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();
|
||||
});
|
||||
|
||||
test('obsolete inspection errors after another exact receipt and disposal cannot reach the owning history',async()=>{
|
||||
const {c,requests,changes}=await selected();const first=availableReceipt('a'),second=availableReceipt('9','2',0);let p=c.loadOutputs();
|
||||
requests.at(-1).resolve({...outputs([first,second],false),retained_occurrence_count:2});await p;
|
||||
const pending=[];let target=first.current_candidate.media;
|
||||
const inspection=createInspectionController({api:{get:()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))},adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
|
||||
const remove=inspection.subscribe(state=>c.inspectionFailed(target,state));
|
||||
c.selectReceipt(first.occurrence);p=inspection.select(target);
|
||||
target=second.current_candidate.media;c.selectReceipt(second.occurrence);const next=inspection.select(target);
|
||||
pending[1].reject({status:503,code:'storage-unavailable'});await next;const before=changes.length;
|
||||
pending[0].reject({status:409,code:'history-changed'});await p;
|
||||
assert.equal(changes.length,before);assert.equal(c.state.refreshRequired,null);assert.deepEqual(c.state.selection.receipt,second.occurrence);
|
||||
p=inspection.retry();inspection.dispose();c.dispose();const count=changes.length;pending[2].reject({status:409,code:'history-changed'});await p;
|
||||
assert.equal(changes.length,count);remove();
|
||||
});
|
||||
|
||||
test('malformed resume and raw membership scalars cannot enable restored actions',async()=>{
|
||||
for(const mutate of [page=>page.resume_cursor='',page=>page.resume_cursor='x'.repeat(4097),page=>page.resume_cursor=42,page=>page.resume_cursor=null]) {
|
||||
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load(),page=windowPage();mutate(page);requests.at(-1).resolve(page);await p;
|
||||
assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);assert.equal(c.state.run.detail,null);assert.equal(requests.length,1);c.dispose();
|
||||
}
|
||||
for(const value of [undefined,null,'bad',42]) {
|
||||
const {c,requests}=await loaded(),p=c.selectRun(id(1)),fresh=detail();fresh.contexts[0].positive_raw_text_id=value;requests.at(-1).resolve(fresh);await p;
|
||||
assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('a resumed page success or error cannot replace a later run choice',async()=>{
|
||||
for(const outcome of ['success','error']) {
|
||||
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load();
|
||||
requests[0].resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
|
||||
const old=requests[1],next=c.selectRun(id(41));assert.equal(old.signal.aborted,true);requests[2].resolve(detail(41));await next;
|
||||
if(outcome==='success')old.resolve({...windowPage(Array.from({length:40},(_,i)=>run(i+1))),recorded_run_count:80});else old.reject({status:409,code:'history-changed'});
|
||||
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
}
|
||||
});
|
||||
@@ -324,3 +324,20 @@ test('invalid attempted selections abort pending reads and suppress late results
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit rooted adapter preserves the strict default identity and download contract', () => {
|
||||
assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported');
|
||||
const a = module.rootedFileAdapter, entry = image();
|
||||
assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA);
|
||||
assert.equal(a.inspectionPath(entry), module.inspectPath(entry));
|
||||
assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry));
|
||||
assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts));
|
||||
assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError);
|
||||
});
|
||||
|
||||
test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => {
|
||||
const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts});
|
||||
const selected={...image(),ignoredCallback(){}};
|
||||
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
|
||||
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
|
||||
});
|
||||
|
||||
@@ -32,3 +32,129 @@ test('preferences handle denied malformed storage without mutation', () => {
|
||||
raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null});
|
||||
assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults);
|
||||
});
|
||||
|
||||
function mountedDom() {
|
||||
class Node {
|
||||
constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};}
|
||||
get textContent(){return this._text+this.children.map(c=>c.textContent).join('');}
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
getAttribute(key){return this.attributes[key]??null;}
|
||||
removeAttribute(key){delete this.attributes[key];}
|
||||
addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);}
|
||||
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
|
||||
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
|
||||
focus(){this.focused=true;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.fire('close');}
|
||||
contains(node){return node===this||this.children.some(c=>c.contains(node));}
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
|
||||
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loaded=true;}
|
||||
play(){this.played=true;return Promise.resolve();}
|
||||
}
|
||||
const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))];
|
||||
const oldDocument=globalThis.document,oldWindow=globalThis.window;
|
||||
const container=new Node('main');container.root=true;
|
||||
const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag);
|
||||
doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true;
|
||||
const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true});
|
||||
globalThis.document=doc;globalThis.window=win;
|
||||
return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}};
|
||||
}
|
||||
function occurrenceMedia(node,ordinal,key) {
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;
|
||||
return {kind:'reported-location',location_key:key.repeat(64),history_epoch:epoch,current_scope:scope,occurrence:{run_id:run,output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},filename:'a.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
|
||||
}
|
||||
function borrowedController() {
|
||||
const listeners=new Set();let disposed=0;
|
||||
return {get state(){return {loading:false,error:false,record:null};},subscribe(fn){listeners.add(fn);fn(this.state);return ()=>listeners.delete(fn);},publish(state){for(const fn of listeners)fn(state);},retry(){},dispose(){disposed++;},get disposed(){return disposed;},get listeners(){return listeners.size;}};
|
||||
}
|
||||
|
||||
test('occurrence Viewer retains exact caller selection and synchronously closes without disposing borrowed controller',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');assert.ok(adapter);
|
||||
const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),selections=[],closes=[],items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const trigger=f.doc.activeElement;
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onSelect:entry=>selections.push(entry.occurrence),onClose(...args){assert.equal(f.walk(f.container,n=>n.tagName==='dialog').length,0);assert.equal(controller.listeners,0);assert.equal(trigger.focused,true);closes.push(args);}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64),trigger});assert.deepEqual(selections,[{run_id:items[0].occurrence.run_id,output_node_id:'1',image_ordinal:1}]);
|
||||
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
|
||||
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
|
||||
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('slideshow stops for detail reading nested send close and parent disposal with late media callbacks ignored',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];let closes=0,sends=0;
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onClose:()=>closes++,sender:{open(){sends++;}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
|
||||
let media=f.walk(f.container,n=>n.tagName==='img')[0];media.fire('load');find('Play slideshow').fire('click');assert.ok(find('Pause slideshow'));
|
||||
f.walk(f.container,n=>n.tagName==='aside')[0].fire('pointerdown');assert.ok(find('Play slideshow'),'explicit detail reading ends slideshow intent');
|
||||
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
|
||||
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
|
||||
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
|
||||
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
function currentInspectionEnvelope(target) {
|
||||
return {success:true,inspection_version:1,history_version:1,history_epoch:target.history_epoch,
|
||||
current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:target.history_epoch,current_scope:target.current_scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},
|
||||
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
|
||||
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
|
||||
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
|
||||
}
|
||||
|
||||
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
|
||||
const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');
|
||||
const {createInspectionController}=await import('../../web/js/workspace/inspection-state.mjs');
|
||||
const {extractGenerationFacts}=(await import('../../web/js/comfy-metadata.js')).default;
|
||||
const f=mountedDom(),parentScope=createScope(),calls=[],reads=[],selected=[],timers=new Map();let serial=0,closed=0,sent=0;
|
||||
const oldSetTimeout=globalThis.setTimeout,oldClearTimeout=globalThis.clearTimeout;
|
||||
globalThis.setTimeout=(callback,delay)=>{assert.equal(delay,3500);timers.set(++serial,callback);return serial;};
|
||||
globalThis.clearTimeout=token=>timers.delete(token);
|
||||
const controller=createInspectionController({adapter,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});
|
||||
const items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d'),occurrenceMedia('3',0,'e')];
|
||||
const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,
|
||||
onSelect(entry){selected.push(entry.occurrence);reads.push(controller.select(entry));},onClose(){closed++;},sender:{open(){sent++;}}});
|
||||
const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0];
|
||||
const image=()=>f.walk(f.container,n=>n.tagName==='img')[0];
|
||||
const advance=()=>{assert.equal(timers.size,1);const [token,callback]=timers.entries().next().value;timers.delete(token);callback();};
|
||||
try {
|
||||
view.open({items,selectedKey:items[0].location_key});assert.equal(controller.state.loading,true);
|
||||
calls[0].resolve(currentInspectionEnvelope(items[0]));await reads[0];image().fire('load');find('Play slideshow').fire('click');
|
||||
advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'),'automatic next-file inspection preserves intent');
|
||||
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2']);assert.equal(timers.size,0,'readiness still gates the next advance');
|
||||
image().fire('load');advance();assert.equal(controller.state.loading,true);assert.ok(find('Pause slideshow'));
|
||||
assert.deepEqual(selected.map(o=>o.output_node_id),['1','2','3']);assert.equal(calls[1].signal.aborted,true);
|
||||
image().fire('load');assert.equal(timers.size,1);calls[1].reject(new Error('late superseded inspection'));await reads[1];assert.ok(find('Pause slideshow'));
|
||||
calls[2].resolve(currentInspectionEnvelope(items[2]));await reads[2];assert.ok(find('Pause slideshow'));assert.equal(timers.size,1);
|
||||
f.walk(f.container,n=>n.tagName==='aside')[0].fire('keydown');assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.dataset.sendRole==='pair')[0].fire('click');assert.equal(sent,1);assert.ok(find('Play slideshow'));assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');const retry=controller.retry();calls.at(-1).reject(new Error('inspection read failed'));await retry;
|
||||
assert.ok(find('Play slideshow'),'inspection errors stop intent');assert.equal(timers.size,0);
|
||||
const retryButton=f.walk(f.container,n=>n.textContent==='Retry prompt details')[0];assert.ok(retryButton);
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
|
||||
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
|
||||
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
|
||||
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
|
||||
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
|
||||
@@ -108,3 +108,12 @@ test('slideshow cannot start on video one entry or media error', () => {
|
||||
}
|
||||
const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose();
|
||||
});
|
||||
|
||||
test('explicit occurrence session retains video pause and timer invalidation with location keys',async()=>{
|
||||
const history=await import('../../web/js/workspace/history-media.mjs');
|
||||
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
|
||||
const items=['c','d'].map((key,index)=>{const query=new URLSearchParams({node:String(index),ordinal:'0',epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;return {kind:'reported-location',occurrence:{run_id:run,output_node_id:String(index),image_ordinal:0},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},history_epoch:epoch,current_scope:scope,location_key:key.repeat(64),filename:'a.png',is_video:index===1,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};});
|
||||
const timers=[],pending=new Set();let callbacks=0;
|
||||
const view=session({adapter:history.reportedLocationAdapter,items,initialKey:'c'.repeat(64),onChange:()=>callbacks++,setTimer:fn=>{timers.push(fn);pending.add(fn);return fn;},clearTimer:fn=>pending.delete(fn)});
|
||||
view.play();view.ready('c'.repeat(64));assert.equal(pending.size,1);timers[0]();assert.equal(view.state.key,'d'.repeat(64));assert.equal(view.state.phase,'paused-video');view.stop();view.navigate(-1);view.ready('c'.repeat(64));assert.equal(view.state.requested,false);view.dispose();const count=callbacks;timers[0]();view.ready('d'.repeat(64));assert.equal(callbacks,count);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2097,7 +2097,7 @@ class TestRootScopeServing(ImageAPITestCase):
|
||||
side_effect=AssertionError("saved lookup"),
|
||||
),
|
||||
patch(
|
||||
"py.api.images.read_inspection_file",
|
||||
"py.api.inspection_service.read_inspection_file",
|
||||
side_effect=AssertionError("metadata read"),
|
||||
),
|
||||
patch(
|
||||
|
||||
@@ -347,7 +347,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
with patch(
|
||||
"py.api.images.read_inspection_file",
|
||||
"py.api.inspection_service.read_inspection_file",
|
||||
side_effect=RuntimeError(str(self.comfy_dir)),
|
||||
):
|
||||
response, result = await self.inspect("image.jpg")
|
||||
@@ -1044,7 +1044,7 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
self.api, "_file_response", side_effect=AssertionError("file response")
|
||||
),
|
||||
patch(
|
||||
"py.api.images.read_inspection_file",
|
||||
"py.api.inspection_service.read_inspection_file",
|
||||
side_effect=AssertionError("metadata read"),
|
||||
),
|
||||
):
|
||||
|
||||
@@ -22,6 +22,41 @@ class HistoryRestoreTests(unittest.TestCase):
|
||||
finish = history_tests.HistoryDatabaseTests.finish
|
||||
count = history_tests.HistoryDatabaseTests.count
|
||||
|
||||
def test_restore_installs_optional_history_read_indexes_without_mutating_source(
|
||||
self,
|
||||
):
|
||||
from database.execution_history_schema import history_schema_error
|
||||
|
||||
db = self.database()
|
||||
start = self.start(db)
|
||||
db.write_execution_start(start)
|
||||
conn = db.model.get_connection()
|
||||
tables = (
|
||||
"execution_prompt_snapshots",
|
||||
"execution_contexts",
|
||||
"execution_run_contexts",
|
||||
"execution_runs",
|
||||
)
|
||||
with conn:
|
||||
for table in tables:
|
||||
conn.execute(f"DROP INDEX idx_history_read_{table}")
|
||||
self.assertIsNone(history_schema_error(conn, allow_missing=False))
|
||||
source = str(Path(self.temp.name) / "without-read-indexes.db")
|
||||
self.assertTrue(db.model.backup_database(source))
|
||||
original = Path(source).read_bytes()
|
||||
db.model.restore_from_file(source)
|
||||
conn = db.model.get_connection()
|
||||
for table in tables:
|
||||
indexes = {r[1]: r for r in conn.execute(f"PRAGMA index_list({table})")}
|
||||
self.assertIn(f"idx_history_read_{table}", indexes)
|
||||
self.assertFalse(indexes[f"idx_history_read_{table}"][2])
|
||||
self.assertIsNone(history_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(Path(source).read_bytes(), original)
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
run = db.get_history_run(start.run_id)
|
||||
self.assertNotEqual(epoch, run["run"]["storage_epoch"])
|
||||
self.assertEqual(run["run"]["storage_epoch"], start.storage_epoch)
|
||||
|
||||
def test_output_operations_restore_preserves_evidence_and_rejects_stale_finish(
|
||||
self,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Current locations require a native namespace and independent authorization."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_images import make_png
|
||||
from database.operations import PromptDatabase
|
||||
from py.config import GalleryConfig
|
||||
from utils.execution_outputs import snapshot_output_roots
|
||||
|
||||
|
||||
class CurrentLocationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name).resolve()
|
||||
self.output = self.base / "output"
|
||||
self.temp = self.base / "temp"
|
||||
self.output.mkdir()
|
||||
self.temp.mkdir()
|
||||
self.roots = [self.output]
|
||||
self.host_output = str(self.output)
|
||||
self.host_temp = str(self.temp)
|
||||
self.stack = ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.stack.enter_context(
|
||||
patch(
|
||||
"folder_paths.get_output_directory",
|
||||
lambda: self.host_output,
|
||||
create=True,
|
||||
)
|
||||
)
|
||||
self.stack.enter_context(
|
||||
patch(
|
||||
"folder_paths.get_temp_directory", lambda: self.host_temp, create=True
|
||||
)
|
||||
)
|
||||
self.stack.enter_context(
|
||||
patch.object(
|
||||
GalleryConfig, "allowed_gallery_parents", lambda: [str(self.base)]
|
||||
)
|
||||
)
|
||||
self.stack.enter_context(
|
||||
patch.object(GalleryConfig, "path_anchors", lambda: [str(self.base)])
|
||||
)
|
||||
self.api = SimpleNamespace(_get_all_output_dirs=lambda: list(self.roots))
|
||||
|
||||
def module(self, name="py.api.history_current"):
|
||||
self.assertIsNotNone(
|
||||
importlib.util.find_spec(name), f"missing current-location adapter {name}"
|
||||
)
|
||||
return importlib.import_module(name)
|
||||
|
||||
def reference(self, **overrides):
|
||||
result = {
|
||||
"folder_type": "output",
|
||||
"subfolder": "",
|
||||
"filename": "image.png",
|
||||
"path_style": "windows" if os.name == "nt" else "posix",
|
||||
"root_key": snapshot_output_roots(
|
||||
"windows" if os.name == "nt" else "posix",
|
||||
self.host_output,
|
||||
self.host_temp,
|
||||
).output_key,
|
||||
}
|
||||
result.update(overrides)
|
||||
return result
|
||||
|
||||
def resolve(self, reference=None, gallery=None):
|
||||
mod = self.module()
|
||||
scope = mod.snapshot_current_output_scope(self.api)
|
||||
return mod.resolve_current_candidate(
|
||||
reference or self.reference(), scope, gallery or {}
|
||||
).public
|
||||
|
||||
def test_lexical_alias_is_not_replaced_by_canonical_root_key(self):
|
||||
mod = self.module()
|
||||
path = make_png(self.output / "image.png")
|
||||
alias = self.base / "alias"
|
||||
alias.symlink_to(self.output, target_is_directory=True)
|
||||
self.host_output = str(alias)
|
||||
reference = self.reference()
|
||||
scope = mod.snapshot_current_output_scope(self.api)
|
||||
with patch.object(
|
||||
Path, "stat", side_effect=AssertionError("locator performed IO")
|
||||
):
|
||||
self.assertEqual(
|
||||
mod.candidate_locator(reference, scope),
|
||||
os.path.normcase(str(alias / path.name)),
|
||||
)
|
||||
candidate = mod.resolve_current_candidate(reference, scope, {}).public
|
||||
self.assertEqual(candidate["status"], "available")
|
||||
self.assertEqual(candidate["gallery_match"], "none")
|
||||
self.assertRegex(candidate["location_key"], r"^[0-9a-f]{64}$")
|
||||
wrong = self.reference(
|
||||
root_key=snapshot_output_roots(
|
||||
"windows" if os.name == "nt" else "posix", str(self.output), None
|
||||
).output_key
|
||||
)
|
||||
self.assertEqual(
|
||||
mod.resolve_current_candidate(wrong, scope, {}).public["reason"],
|
||||
"namespace_mismatch",
|
||||
)
|
||||
|
||||
def test_null_foreign_relocated_and_unsafe_descriptors_have_allowed_control(self):
|
||||
make_png(self.output / "image.png")
|
||||
self.assertEqual(self.resolve()["status"], "available")
|
||||
for changes, reason in (
|
||||
({"root_key": None}, "root_unavailable"),
|
||||
({"root_key": "a" * 64}, "namespace_mismatch"),
|
||||
(
|
||||
{
|
||||
"path_style": "posix" if os.name == "nt" else "windows",
|
||||
"subfolder": "sub",
|
||||
"root_key": "b" * 64,
|
||||
},
|
||||
"foreign_path_style",
|
||||
),
|
||||
({"subfolder": "../outside"}, "namespace_mismatch"),
|
||||
({"filename": "/absolute.png"}, "namespace_mismatch"),
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
candidate = self.resolve(self.reference(**changes))
|
||||
self.assertEqual(
|
||||
(candidate["status"], candidate["reason"]), ("unavailable", reason)
|
||||
)
|
||||
self.assertNotIn("media", candidate)
|
||||
|
||||
def test_temp_requires_current_gallery_authorization(self):
|
||||
make_png(self.temp / "image.png")
|
||||
reference = self.reference(
|
||||
folder_type="temp",
|
||||
root_key=snapshot_output_roots(
|
||||
"windows" if os.name == "nt" else "posix",
|
||||
self.host_output,
|
||||
self.host_temp,
|
||||
).temp_key,
|
||||
)
|
||||
self.assertEqual(self.resolve(reference)["reason"], "not_authorized")
|
||||
self.roots.append(self.temp)
|
||||
self.assertEqual(self.resolve(reference)["status"], "available")
|
||||
|
||||
def test_supported_regular_readable_control_and_distinct_failures(self):
|
||||
path = make_png(self.output / "image.png")
|
||||
self.assertEqual(self.resolve()["status"], "available")
|
||||
path.unlink()
|
||||
self.assertEqual(self.resolve()["reason"], "missing")
|
||||
path.mkdir()
|
||||
self.assertEqual(self.resolve()["reason"], "not_regular_file")
|
||||
path.rmdir()
|
||||
path.write_bytes(b"media")
|
||||
with patch("os.access", return_value=False):
|
||||
self.assertEqual(self.resolve()["reason"], "unreadable")
|
||||
(self.output / "notes.txt").write_text("private")
|
||||
self.assertEqual(
|
||||
self.resolve(self.reference(filename="notes.txt"))["reason"],
|
||||
"unsupported_media",
|
||||
)
|
||||
|
||||
def test_symlink_escape_never_authorizes_another_namespace(self):
|
||||
allowed = make_png(self.output / "image.png")
|
||||
outside = make_png(self.temp / "outside.png")
|
||||
self.roots.append(self.temp)
|
||||
self.assertEqual(self.resolve()["status"], "available")
|
||||
allowed.unlink()
|
||||
allowed.symlink_to(outside)
|
||||
self.assertEqual(self.resolve()["reason"], "not_authorized")
|
||||
|
||||
def test_scope_tracks_configuration_targets_and_policy_but_not_bytes(self):
|
||||
mod = self.module()
|
||||
path = make_png(self.output / "image.png")
|
||||
first = mod.snapshot_current_output_scope(self.api)
|
||||
location = mod.resolve_current_candidate(self.reference(), first, {}).public[
|
||||
"location_key"
|
||||
]
|
||||
path.write_bytes(b"replacement")
|
||||
same = mod.snapshot_current_output_scope(self.api)
|
||||
self.assertEqual(same.current_scope, first.current_scope)
|
||||
self.assertEqual(
|
||||
mod.resolve_current_candidate(self.reference(), same, {}).public[
|
||||
"location_key"
|
||||
],
|
||||
location,
|
||||
)
|
||||
self.roots.append(self.temp)
|
||||
changed = mod.snapshot_current_output_scope(self.api)
|
||||
self.assertNotEqual(changed.current_scope, first.current_scope)
|
||||
self.roots.reverse()
|
||||
self.assertEqual(
|
||||
mod.snapshot_current_output_scope(self.api).current_scope,
|
||||
changed.current_scope,
|
||||
)
|
||||
with patch.object(
|
||||
GalleryConfig, "allowed_gallery_parents", lambda: [str(self.output)]
|
||||
):
|
||||
self.assertNotEqual(
|
||||
mod.snapshot_current_output_scope(self.api).current_scope,
|
||||
changed.current_scope,
|
||||
)
|
||||
alias = self.base / "alias"
|
||||
alias.symlink_to(self.output, target_is_directory=True)
|
||||
self.host_output = str(alias)
|
||||
before = mod.snapshot_current_output_scope(self.api)
|
||||
alias.unlink()
|
||||
alias.symlink_to(self.temp, target_is_directory=True)
|
||||
self.assertNotEqual(
|
||||
mod.snapshot_current_output_scope(self.api).current_scope,
|
||||
before.current_scope,
|
||||
)
|
||||
|
||||
def test_gallery_projection_is_snapshot_owned_exact_and_ambiguous(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
path = make_png(self.output / "image.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
prompt = db.save_prompt("first", prompt_hash="first-hash")
|
||||
other = db.save_prompt("second", prompt_hash="second-hash")
|
||||
image = db.link_image_to_prompt(prompt, str(path))
|
||||
db.link_image_to_prompt(other, str(self.temp / path.name))
|
||||
conn = db.model.get_connection()
|
||||
with self.assertRaises(Exception):
|
||||
mod.read_history_gallery(conn, (locator,))
|
||||
with db.recorded_history_snapshot():
|
||||
projected = mod.read_history_gallery(conn, (locator,))
|
||||
self.assertTrue(conn.in_transaction)
|
||||
self.assertEqual(projected[locator]["gallery_match"], "unique")
|
||||
self.assertEqual(projected[locator]["gallery_image_id"], image)
|
||||
db.link_image_to_prompt(other, str(path))
|
||||
with db.recorded_history_snapshot():
|
||||
projected = mod.read_history_gallery(conn, (locator,))
|
||||
self.assertEqual(projected[locator]["gallery_match"], "ambiguous")
|
||||
self.assertIsNone(projected[locator]["gallery_image_id"])
|
||||
self.assertIsNone(projected[locator]["saved"])
|
||||
self.assertEqual(projected[locator]["library"]["status"], "ambiguous")
|
||||
with self.assertRaises(ValueError):
|
||||
mod.read_history_gallery(
|
||||
conn, tuple(locator + str(i) for i in range(101))
|
||||
)
|
||||
|
||||
def test_gallery_display_is_bounded_without_loading_workflow_or_prompt(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
path = make_png(self.output / "image.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
prompt = db.save_prompt(
|
||||
"secret prompt" * 10000, prompt_hash="large-hash", category="猫" * 12000
|
||||
)
|
||||
image = db.link_image_to_prompt(prompt, str(path))
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET workflow_data=?,prompt_metadata=? "
|
||||
"WHERE id=?",
|
||||
("private" * 100000, "private" * 100000, image),
|
||||
)
|
||||
statements = []
|
||||
conn.set_trace_callback(statements.append)
|
||||
self.addCleanup(conn.set_trace_callback, None)
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
display = result[locator]["library"]
|
||||
self.assertEqual(display["status"], "limited")
|
||||
self.assertEqual(display["tags"], [])
|
||||
self.assertFalse(
|
||||
any(
|
||||
"workflow_data" in sql or "prompt_metadata" in sql or "p.text" in sql
|
||||
for sql in statements
|
||||
)
|
||||
)
|
||||
self.assertFalse(any(sql.startswith("COMMIT") for sql in statements))
|
||||
|
||||
def test_gallery_queries_stop_after_two_owners_and_two_pair_associations(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
path = make_png(self.output / "image.png")
|
||||
unique = make_png(self.output / "unique.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
unique_locator = os.path.normcase(str(unique))
|
||||
prompt = db.save_prompt("owner", prompt_hash="owner")
|
||||
image = db.link_image_to_prompt(prompt, str(unique))
|
||||
with db.model.get_connection() as conn:
|
||||
for i in range(12):
|
||||
owner = conn.execute(
|
||||
"INSERT INTO prompts(text,hash) VALUES(?,?)",
|
||||
(str(i), str(i)),
|
||||
).lastrowid
|
||||
pair = conn.execute(
|
||||
"INSERT INTO prompt_pairs"
|
||||
"(positive_prompt_id,negative_prompt_id,title) "
|
||||
"VALUES(?,?,?)",
|
||||
(prompt, owner, str(i)),
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
|
||||
"VALUES(?,?,'{}')",
|
||||
(image, pair),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images"
|
||||
"(prompt_id,image_path,filename,file_path) "
|
||||
"VALUES(?,?,'image.png',?)",
|
||||
(owner, str(path), locator),
|
||||
)
|
||||
fetched = {"owners": 0, "pairs": 0}
|
||||
|
||||
def row_factory(cursor, values):
|
||||
names = {column[0] for column in cursor.description}
|
||||
if names == {"id", "prompt_id", "category"}:
|
||||
fetched["owners"] += 1
|
||||
elif names == {"id", "positive_prompt_id", "title", "category"}:
|
||||
fetched["pairs"] += 1
|
||||
return sqlite3.Row(cursor, values)
|
||||
|
||||
conn.row_factory = row_factory
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator, unique_locator))
|
||||
self.assertEqual(fetched, {"owners": 3, "pairs": 2})
|
||||
self.assertEqual(result[locator]["gallery_match"], "ambiguous")
|
||||
self.assertEqual(result[unique_locator]["gallery_match"], "unique")
|
||||
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
|
||||
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
|
||||
|
||||
def test_host_root_failure_and_removal_do_not_search_another_root(self):
|
||||
make_png(self.output / "image.png")
|
||||
make_png(self.temp / "image.png")
|
||||
self.roots.append(self.temp)
|
||||
reference = self.reference()
|
||||
self.assertEqual(self.resolve(reference)["status"], "available")
|
||||
with patch("folder_paths.get_output_directory", side_effect=OSError("private")):
|
||||
self.assertEqual(self.resolve(reference)["reason"], "root_unavailable")
|
||||
(self.output / "image.png").unlink()
|
||||
self.output.rmdir()
|
||||
self.assertEqual(self.resolve(reference)["reason"], "root_unavailable")
|
||||
|
||||
def test_unusable_lexical_namespace_changes_still_change_scope(self):
|
||||
mod = self.module()
|
||||
make_png(self.output / "image.png")
|
||||
reference = self.reference()
|
||||
self.assertEqual(self.resolve(reference)["status"], "available")
|
||||
self.host_output = "relative-one"
|
||||
first = mod.snapshot_current_output_scope(self.api)
|
||||
self.assertEqual(
|
||||
mod.resolve_current_candidate(reference, first, {}).public["reason"],
|
||||
"root_unavailable",
|
||||
)
|
||||
self.host_output = "relative-two"
|
||||
self.assertNotEqual(
|
||||
mod.snapshot_current_output_scope(self.api).current_scope,
|
||||
first.current_scope,
|
||||
)
|
||||
|
||||
def test_duplicate_owner_lookup_does_not_sort_the_entire_saved_population(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
path = os.path.normcase(str(self.output / "image.png"))
|
||||
with db.model.get_connection() as conn:
|
||||
for item in range(512):
|
||||
owner = conn.execute(
|
||||
"INSERT INTO prompts(text) VALUES(?)", (str(item),)
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images"
|
||||
"(prompt_id,image_path,filename,file_path) "
|
||||
"VALUES(?,?,'image.png',?)",
|
||||
(owner, path, path),
|
||||
)
|
||||
steps = 0
|
||||
|
||||
def bounded():
|
||||
nonlocal steps
|
||||
steps += 1
|
||||
return int(steps > 20)
|
||||
|
||||
conn.set_progress_handler(bounded, 100)
|
||||
try:
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (path,))
|
||||
except sqlite3.OperationalError as error:
|
||||
self.fail(f"Two-owner lookup exceeded bounded query work: {error}")
|
||||
finally:
|
||||
conn.set_progress_handler(None, 0)
|
||||
self.assertEqual(result[path]["gallery_match"], "ambiguous")
|
||||
|
||||
def test_two_same_pair_links_and_later_different_pair_withhold_pair_choice(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
path = make_png(self.output / "image.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
pair = db.save_prompt_pair("owner", "negative one")
|
||||
other_pair = db.save_prompt_pair("owner", "negative two")
|
||||
owner = db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
image = db.link_image_to_prompt(owner, str(path))
|
||||
with db.model.get_connection() as conn:
|
||||
for pair_id, provenance in (
|
||||
(pair, "{}"),
|
||||
(pair, '{"source":"second"}'),
|
||||
(other_pair, "{}"),
|
||||
):
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id,pair_id,provenance) "
|
||||
"VALUES(?,?,?)",
|
||||
(image, pair_id, provenance),
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))[locator]
|
||||
self.assertEqual(result["gallery_match"], "unique")
|
||||
self.assertEqual(result["gallery_image_id"], image)
|
||||
self.assertEqual(result["library"]["status"], "limited")
|
||||
self.assertIsNone(result["library"].get("pair_id"))
|
||||
self.assertIsNone(result["saved"].get("pair"))
|
||||
|
||||
def test_inspection_rejects_unsupported_suffix_before_parser(self):
|
||||
service = self.module("py.api.inspection_service")
|
||||
path = make_png(self.output / "image.png")
|
||||
observed = service.inspect_current_file(
|
||||
path, source_label="output", saved=None, library={"status": "none"}
|
||||
)
|
||||
self.assertEqual(observed["file"]["media_type"], "image")
|
||||
self.assertEqual(observed["inspection"].format, "PNG")
|
||||
unsupported = self.output / "image.txt"
|
||||
unsupported.write_bytes(path.read_bytes())
|
||||
read = service.read_inspection_file
|
||||
reads = []
|
||||
|
||||
def traced(target):
|
||||
reads.append(target)
|
||||
return read(target)
|
||||
|
||||
with patch.object(service, "read_inspection_file", side_effect=traced):
|
||||
with self.assertRaises(service.CurrentFileChanged):
|
||||
service.inspect_current_file(
|
||||
unsupported,
|
||||
source_label="output",
|
||||
saved=None,
|
||||
library={"status": "none"},
|
||||
)
|
||||
self.assertEqual(reads, [], "Unsupported media must not reach the parser")
|
||||
|
||||
|
||||
class HistoryImportTests(unittest.TestCase):
|
||||
def test_api_import_does_not_require_a_running_host_server(self):
|
||||
script = "; ".join(
|
||||
(
|
||||
"import sys, types",
|
||||
"sys.modules['folder_paths'] = types.ModuleType('folder_paths')",
|
||||
"import py.api",
|
||||
)
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_minimal_host_fixture_restores_getters_after_real_route_control(self):
|
||||
program = """
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
host = types.ModuleType("folder_paths")
|
||||
original = lambda: "unused-minimal-host-output"
|
||||
host.get_output_directory = original
|
||||
sys.modules["folder_paths"] = host
|
||||
from tests.test_api_history_outputs import HistoryOutputAPITests
|
||||
case = HistoryOutputAPITests(
|
||||
"test_complete_zero_incomplete_unavailable_and_not_captured_are_distinct"
|
||||
)
|
||||
try:
|
||||
runner = unittest.TextTestRunner(stream=io.StringIO())
|
||||
result = runner.run(unittest.TestSuite([case]))
|
||||
assert host.get_output_directory is original, "fixture leaked host getter"
|
||||
assert not hasattr(host, "get_temp_directory"), "fixture leaked optional getter"
|
||||
assert result.wasSuccessful(), result.errors
|
||||
finally:
|
||||
if hasattr(case, "api"):
|
||||
case.api.db.close_all()
|
||||
if hasattr(case, "_anchor_patch"):
|
||||
case._anchor_patch.stop()
|
||||
if hasattr(case, "_temp_db"):
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
Path(case._temp_db.name + suffix).unlink(missing_ok=True)
|
||||
if hasattr(case, "_output_tmp"):
|
||||
case._output_tmp.cleanup()
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", program],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Fresh epoch validation survives ended snapshots and retired API owners."""
|
||||
|
||||
import importlib
|
||||
import sqlite3
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from database.execution_history import HistoryEpochChanged, notify_history_restore
|
||||
from tests import test_execution_history_database as history_tests
|
||||
|
||||
|
||||
class HistoryReadFenceTests(unittest.TestCase):
|
||||
setUp = history_tests.HistoryDatabaseTests.setUp
|
||||
close_databases = history_tests.HistoryDatabaseTests.close_databases
|
||||
database = history_tests.HistoryDatabaseTests.database
|
||||
start = history_tests.HistoryDatabaseTests.start
|
||||
|
||||
def fence(self, db):
|
||||
try:
|
||||
module = importlib.import_module("utils.history_read_fence")
|
||||
except ImportError:
|
||||
self.fail("HistoryReadFence must protect publication of history reads")
|
||||
fence = module.HistoryReadFence(db)
|
||||
self.addCleanup(fence.close)
|
||||
return fence
|
||||
|
||||
def snapshot_file(self, db):
|
||||
path = str(Path(self.temp.name) / "source.db")
|
||||
self.assertTrue(db.model.backup_database(path))
|
||||
return path
|
||||
|
||||
def test_final_epoch_is_fresh_after_snapshot_even_before_restore_notification(self):
|
||||
reader = self.database()
|
||||
writer = self.database()
|
||||
start = self.start(reader)
|
||||
reader.write_execution_start(start)
|
||||
source = self.snapshot_file(reader)
|
||||
fence = self.fence(reader)
|
||||
generation = fence.begin()
|
||||
with reader.recorded_history_snapshot() as epoch:
|
||||
original = reader.get_history_run(start.run_id)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
writer.model.restore_from_file(source)
|
||||
self.assertEqual(reader.get_execution_epoch(), epoch)
|
||||
self.assertEqual(original["run"]["storage_epoch"], epoch)
|
||||
with self.assertRaises(HistoryEpochChanged):
|
||||
fence.validate(epoch, generation)
|
||||
self.assertEqual(fence.begin(), generation)
|
||||
|
||||
def test_restore_observer_rejects_same_epoch_even_when_storage_did_not_change(self):
|
||||
db = self.database()
|
||||
fence = self.fence(db)
|
||||
generation = fence.begin()
|
||||
epoch = db.get_execution_epoch()
|
||||
notify_history_restore(self.path)
|
||||
with self.assertRaises(HistoryEpochChanged):
|
||||
fence.validate(epoch, generation)
|
||||
self.assertGreater(fence.begin(), generation)
|
||||
fence.validate(epoch, fence.begin())
|
||||
|
||||
def test_new_read_before_delayed_notification_can_return_coherent_new_epoch(self):
|
||||
db = self.database()
|
||||
writer = self.database()
|
||||
fence = self.fence(db)
|
||||
old_epoch = db.get_execution_epoch()
|
||||
source = self.snapshot_file(db)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
writer.model.restore_from_file(source)
|
||||
generation = fence.begin()
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
self.assertNotEqual(epoch, old_epoch)
|
||||
self.assertIsNone(
|
||||
db.get_history_run("11111111-1111-4111-8111-111111111111")
|
||||
)
|
||||
fence.validate(epoch, generation)
|
||||
notify_history_restore(self.path)
|
||||
with self.assertRaises(HistoryEpochChanged):
|
||||
fence.validate(epoch, generation)
|
||||
|
||||
def test_restore_after_final_validation_is_later_staleness_not_relabeling(self):
|
||||
db = self.database()
|
||||
writer = self.database()
|
||||
start = self.start(db)
|
||||
db.write_execution_start(start)
|
||||
source = self.snapshot_file(db)
|
||||
fence = self.fence(db)
|
||||
generation = fence.begin()
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
detail = db.get_history_run(start.run_id)
|
||||
fence.validate(epoch, generation)
|
||||
writer.model.restore_from_file(source)
|
||||
self.assertEqual(detail["run"]["storage_epoch"], epoch)
|
||||
self.assertNotEqual(writer.get_execution_epoch(), epoch)
|
||||
|
||||
def test_validation_does_not_reopen_retired_owner_and_closes_read_only_handle(self):
|
||||
db = self.database()
|
||||
epoch = db.get_execution_epoch()
|
||||
fence = self.fence(db)
|
||||
generation = fence.begin()
|
||||
db.close_all()
|
||||
module = importlib.import_module("utils.history_read_fence")
|
||||
connect = sqlite3.connect
|
||||
opened = []
|
||||
|
||||
def tracked(*args, **kwargs):
|
||||
conn = connect(*args, **kwargs)
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
conn.execute(
|
||||
"UPDATE execution_history_metadata SET storage_epoch=storage_epoch"
|
||||
)
|
||||
opened.append(conn)
|
||||
return conn
|
||||
|
||||
with patch.object(
|
||||
db.model, "get_connection", side_effect=AssertionError("retired")
|
||||
):
|
||||
with patch.object(module.sqlite3, "connect", side_effect=tracked):
|
||||
fence.validate(epoch, generation)
|
||||
self.assertEqual(len(opened), 1)
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
opened[0].execute("SELECT 1")
|
||||
self.assertEqual(len(db.model._connections), 0)
|
||||
|
||||
def test_observer_or_close_between_epoch_select_and_publish_rejects_read(self):
|
||||
db = self.database()
|
||||
module = importlib.import_module("utils.history_read_fence")
|
||||
original_connect = sqlite3.connect
|
||||
epoch = db.get_execution_epoch()
|
||||
database_path = self.path
|
||||
for close in (False, True):
|
||||
fence = self.fence(db)
|
||||
generation = fence.begin()
|
||||
|
||||
class InterruptedConnection(sqlite3.Connection):
|
||||
def close(self):
|
||||
super().close()
|
||||
if close:
|
||||
fence.close()
|
||||
else:
|
||||
notify_history_restore(database_path)
|
||||
|
||||
def interrupted_connect(*args, **kwargs):
|
||||
return original_connect(*args, factory=InterruptedConnection, **kwargs)
|
||||
|
||||
with patch.object(
|
||||
module.sqlite3, "connect", side_effect=interrupted_connect
|
||||
):
|
||||
with self.assertRaises(sqlite3.Error if close else HistoryEpochChanged):
|
||||
fence.validate(epoch, generation)
|
||||
|
||||
def test_final_select_failure_always_closes_its_handle(self):
|
||||
db = self.database()
|
||||
fence = self.fence(db)
|
||||
module = importlib.import_module("utils.history_read_fence")
|
||||
epoch = db.get_execution_epoch()
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("DROP TABLE execution_history_metadata")
|
||||
original_connect = sqlite3.connect
|
||||
opened = []
|
||||
|
||||
def tracked(*args, **kwargs):
|
||||
conn = original_connect(*args, **kwargs)
|
||||
opened.append(conn)
|
||||
return conn
|
||||
|
||||
with patch.object(module.sqlite3, "connect", side_effect=tracked):
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
fence.validate(epoch, fence.begin())
|
||||
self.assertEqual(len(opened), 1)
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
opened[0].execute("SELECT 1")
|
||||
|
||||
def test_missing_metadata_and_connection_failure_are_not_empty_success(self):
|
||||
db = self.database()
|
||||
fence = self.fence(db)
|
||||
generation = fence.begin()
|
||||
epoch = db.get_execution_epoch()
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
conn.execute("DELETE FROM execution_history_metadata")
|
||||
conn.commit()
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
fence.validate(epoch, generation)
|
||||
module = importlib.import_module("utils.history_read_fence")
|
||||
with patch.object(
|
||||
module.sqlite3, "connect", side_effect=OSError("private/path")
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
fence.validate(epoch, generation)
|
||||
|
||||
def test_close_is_idempotent_unsubscribes_and_permanently_invalidates_reads(self):
|
||||
db = self.database()
|
||||
fence = self.fence(db)
|
||||
epoch = db.get_execution_epoch()
|
||||
generation = fence.begin()
|
||||
fence.close()
|
||||
fence.close()
|
||||
notify_history_restore(self.path)
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
fence.begin()
|
||||
with self.assertRaises(sqlite3.Error):
|
||||
fence.validate(epoch, generation)
|
||||
@@ -0,0 +1,915 @@
|
||||
"""Bounded chronological projections preserve frozen evidence and exact coordinates."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from database.execution_history import HistoryEpochChanged
|
||||
from database.execution_history_schema import history_schema_error
|
||||
from utils.execution_contexts import ContextBundle, RecipeContext, TextEvidence
|
||||
from utils.execution_outputs import OutputBundle
|
||||
from tests.test_execution_context_database import bundle, snapshot
|
||||
from tests.test_execution_output_database import bundle as outputs
|
||||
from tests import test_execution_history_database as history_tests
|
||||
|
||||
|
||||
class HistoryReadTests(unittest.TestCase):
|
||||
setUp = history_tests.HistoryDatabaseTests.setUp
|
||||
close_databases = history_tests.HistoryDatabaseTests.close_databases
|
||||
database = history_tests.HistoryDatabaseTests.database
|
||||
start = history_tests.HistoryDatabaseTests.start
|
||||
finish = history_tests.HistoryDatabaseTests.finish
|
||||
|
||||
def saved(self, db, text="positive", identity=1):
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(?,?)", (identity, text))
|
||||
return identity
|
||||
|
||||
def capture(self, db, item=0, context=None, output=None, time=100):
|
||||
start = self.start(db, item, time)
|
||||
db.write_execution_start(start)
|
||||
db.write_execution_finish(self.finish(start))
|
||||
if context is not False:
|
||||
db.write_execution_contexts(
|
||||
start.run_id, start.storage_epoch, context or bundle()
|
||||
)
|
||||
if output is not None:
|
||||
db.write_execution_outputs_for_finish(self.finish(start), output)
|
||||
return start
|
||||
|
||||
def require_reads(self, db):
|
||||
self.assertTrue(
|
||||
callable(getattr(db, "recorded_history_snapshot", None)),
|
||||
"PromptDatabase must expose bounded history reads",
|
||||
)
|
||||
|
||||
def test_aab_distinct_runs_exact_raw_scope(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.saved(db, identity=2)
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET run_count=31")
|
||||
conn.execute(
|
||||
"INSERT INTO generated_images(prompt_id,image_path,filename) "
|
||||
"VALUES(1,'/first','first'),(2,'/second','second')"
|
||||
)
|
||||
a = bundle("A").contexts[0]
|
||||
b = bundle("B").contexts[0]
|
||||
starts = [
|
||||
self.capture(
|
||||
db, 0, replace(bundle("A"), contexts=(a, replace(a, anchor_id="5")))
|
||||
),
|
||||
self.capture(
|
||||
db, 1, replace(bundle("A"), contexts=(a, replace(b, anchor_id="6")))
|
||||
),
|
||||
self.capture(db, 2, bundle("B"), outputs()),
|
||||
]
|
||||
conn = db.model.get_connection()
|
||||
tables = ("prompts", "generated_images", "prompt_pairs", "image_prompt_pairs")
|
||||
before = {t: list(conn.execute(f"SELECT * FROM {t}")) for t in tables}
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
duplicate = db.list_history_window(2)
|
||||
self.assertEqual(page["recorded_run_count"], 3)
|
||||
self.assertEqual(
|
||||
[r["run_id"] for r in page["items"]], [s.run_id for s in starts]
|
||||
)
|
||||
self.assertEqual(len(page["context_summaries"]), 2)
|
||||
self.assertEqual(
|
||||
page["group"]["raw_text_id"], duplicate["group"]["raw_text_id"]
|
||||
)
|
||||
self.assertEqual(len(page["items"][0]["matching_anchors"]), 2)
|
||||
self.assertEqual(
|
||||
len(page["context_summaries"][0]["negative_raw"]["evidence"]), 3
|
||||
)
|
||||
self.assertEqual(
|
||||
page["items"][0]["output_summary"]["list_status"], "not_captured"
|
||||
)
|
||||
self.assertEqual(
|
||||
page["items"][2]["output_summary"]["retained_occurrence_count"], 1
|
||||
)
|
||||
self.assertEqual(
|
||||
before, {t: list(conn.execute(f"SELECT * FROM {t}")) for t in tables}
|
||||
)
|
||||
|
||||
def test_raw_scope_distinctions_and_eligibility(self):
|
||||
db = self.database()
|
||||
values = (
|
||||
"positive",
|
||||
"Positive",
|
||||
" positive",
|
||||
"positive ",
|
||||
"é",
|
||||
"e\u0301",
|
||||
"",
|
||||
" ",
|
||||
)
|
||||
for i, raw in enumerate(values, 1):
|
||||
self.saved(db, raw, i)
|
||||
self.capture(db, i, bundle(positive=raw))
|
||||
modified = replace(
|
||||
snapshot("positive"),
|
||||
effective=snapshot("other").raw,
|
||||
prepend=snapshot("prefix").raw,
|
||||
)
|
||||
self.capture(
|
||||
db,
|
||||
50,
|
||||
replace(
|
||||
bundle(),
|
||||
contexts=(RecipeContext("4", modified, snapshot(""), "inferred"),),
|
||||
),
|
||||
)
|
||||
unknown = replace(
|
||||
snapshot("positive"),
|
||||
raw=TextEvidence(None, "unknown", "unknown", "missing"),
|
||||
)
|
||||
self.capture(
|
||||
db,
|
||||
51,
|
||||
ContextBundle(
|
||||
(RecipeContext("4", unknown, snapshot(""), "inferred"),),
|
||||
"partial",
|
||||
"captured",
|
||||
),
|
||||
)
|
||||
self.saved(db, "x" * 65537, 99)
|
||||
self.saved(db, "negative-only", 100)
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(1,100)"
|
||||
)
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
for i in range(1, len(values) + 1):
|
||||
self.assertEqual(
|
||||
db.list_history_window(i)["recorded_run_count"], 2 if i == 1 else 1
|
||||
)
|
||||
self.assertIsNone(db.list_history_window(100))
|
||||
self.assertIsNone(db.list_history_window(101))
|
||||
page = db.list_history_window(99)
|
||||
self.assertEqual(page["group"]["scope_status"], "uncapturable_text")
|
||||
self.assertIsNone(page["group"]["raw_text_id"])
|
||||
self.assertEqual(page["recorded_run_count"], 0)
|
||||
|
||||
def test_prefix_is_sql_bounded_and_nul_safe(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
negative = "A\0猫😀" + "z" * 20000
|
||||
self.capture(db, context=bundle(negative))
|
||||
self.capture(db, 1, bundle("A" + "😀" * 128))
|
||||
self.require_reads(db)
|
||||
traced = []
|
||||
conn = db.model.get_connection()
|
||||
conn.set_trace_callback(traced.append)
|
||||
self.addCleanup(conn.set_trace_callback, None)
|
||||
original_factory = conn.row_factory
|
||||
prefix_results = []
|
||||
|
||||
def bounded_rows(cursor, values):
|
||||
names = [column[0] for column in cursor.description]
|
||||
if "prefix" in names:
|
||||
prefix = values[names.index("prefix")]
|
||||
prefix_results.append(prefix)
|
||||
self.assertLessEqual(len(prefix), 512)
|
||||
return sqlite3.Row(cursor, values)
|
||||
|
||||
conn.row_factory = bounded_rows
|
||||
self.addCleanup(setattr, conn, "row_factory", original_factory)
|
||||
with patch.object(
|
||||
db, "get_execution_contexts", side_effect=AssertionError("full reader")
|
||||
):
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
raw = page["context_summaries"][0]["negative_raw"]
|
||||
self.assertEqual(len(prefix_results), 2)
|
||||
self.assertEqual(
|
||||
page["context_summaries"][1]["negative_raw"]["preview"],
|
||||
("A" + "😀" * 128)[:100],
|
||||
)
|
||||
self.assertEqual(raw["preview"], negative[:100])
|
||||
self.assertLessEqual(len(raw["preview"].encode()), 512)
|
||||
self.assertTrue(raw["truncated"])
|
||||
text_queries = [s.lower().replace(" ", "") for s in traced if "text_value" in s]
|
||||
self.assertTrue(
|
||||
any("substr(cast(" in s and "asblob),1,512)" in s for s in text_queries)
|
||||
)
|
||||
self.assertFalse(
|
||||
any(
|
||||
"selectr.text_value" in s or "select*fromexecution_context_texts" in s
|
||||
for s in text_queries
|
||||
)
|
||||
)
|
||||
|
||||
def test_empty_unknown_and_per_anchor_evidence(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
a = bundle("").contexts[0]
|
||||
cached = replace(
|
||||
a,
|
||||
negative=replace(
|
||||
a.negative, raw=replace(a.negative.raw, source="host_cache_output")
|
||||
),
|
||||
)
|
||||
self.capture(db, 0, replace(bundle(""), contexts=(a,)))
|
||||
self.capture(db, 1, replace(bundle(""), contexts=(cached,)))
|
||||
self.capture(
|
||||
db, 2, ContextBundle((replace(a, negative=None),), "partial", "captured")
|
||||
)
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
summaries = db.list_history_window(1)["context_summaries"]
|
||||
raw = summaries[0]["negative_raw"]
|
||||
self.assertEqual(
|
||||
(raw["state"], raw["preview"], raw["truncated"]),
|
||||
("explicit_empty", "", False),
|
||||
)
|
||||
self.assertEqual(
|
||||
{e["source"] for e in raw["evidence"]},
|
||||
{"runtime_return", "host_cache_output"},
|
||||
)
|
||||
unknown = summaries[1]["negative_raw"]
|
||||
self.assertEqual(
|
||||
(unknown["state"], unknown["preview"], unknown["truncated"]),
|
||||
("unknown", None, False),
|
||||
)
|
||||
|
||||
def test_four_key_paging_upper_bound_and_vacuum(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
starts = [self.capture(db, i, time=2**53 + 7) for i in range(7)]
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1, limit=2)
|
||||
self.assertEqual([r["queue_item_id"] for r in page["items"]], [5, 6])
|
||||
self.assertTrue(page["has_more"])
|
||||
self.capture(db, 8, time=2**53 + 8)
|
||||
db.model.get_connection().execute("VACUUM")
|
||||
ids = [r["run_id"] for r in page["items"]]
|
||||
while page["has_more"]:
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1, limit=2, after=page["next_position"])
|
||||
self.assertEqual(page["recorded_run_count"], 7)
|
||||
ids = [r["run_id"] for r in page["items"]] + ids
|
||||
self.assertEqual(ids, [s.run_id for s in starts])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET text='changed' WHERE id=1")
|
||||
from database.history_reads import WindowPosition
|
||||
|
||||
with db.recorded_history_snapshot():
|
||||
with self.assertRaises(ValueError):
|
||||
db.list_history_window(
|
||||
1,
|
||||
after=WindowPosition(
|
||||
"0" * 64,
|
||||
(1, starts[0].session_id, 0, starts[0].run_id),
|
||||
(1, starts[0].session_id, 0, starts[0].run_id),
|
||||
),
|
||||
)
|
||||
|
||||
def test_restart_is_upper_inclusive_with_fresh_rows_and_the_same_bounded_query_cost(
|
||||
self,
|
||||
):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
starts = [self.capture(db, i, time=i) for i in range(45)]
|
||||
conn = db.model.get_connection()
|
||||
trace = []
|
||||
conn.set_trace_callback(trace.append)
|
||||
with db.recorded_history_snapshot():
|
||||
first = db.list_history_window(1)
|
||||
self.assertIn("resume_position", first)
|
||||
baseline_queries = len([sql for sql in trace if sql.startswith("SELECT")])
|
||||
conn.set_trace_callback(None)
|
||||
for i in range(45, 106):
|
||||
self.capture(db, i, time=i)
|
||||
trace.clear()
|
||||
conn.set_trace_callback(trace.append)
|
||||
try:
|
||||
with db.recorded_history_snapshot():
|
||||
resumed = db.list_history_window(
|
||||
1, after=first["resume_position"], restart=True
|
||||
)
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertEqual(resumed["recorded_run_count"], 45)
|
||||
self.assertEqual(
|
||||
[run["run_id"] for run in resumed["items"]],
|
||||
[start.run_id for start in starts[5:]],
|
||||
)
|
||||
self.assertLessEqual(
|
||||
len([sql for sql in trace if sql.startswith("SELECT")]), baseline_queries
|
||||
)
|
||||
run_queries = [
|
||||
sql for sql in trace if sql.startswith("SELECT r.* FROM execution_runs")
|
||||
]
|
||||
self.assertEqual(len(run_queries), 1)
|
||||
self.assertIn("LIMIT 41", run_queries[0])
|
||||
plan = list(conn.execute("EXPLAIN QUERY PLAN " + run_queries[0]))
|
||||
self.assertTrue(any("INDEX" in row[3] for row in plan), plan)
|
||||
with db.recorded_history_snapshot():
|
||||
earlier = db.list_history_window(1, after=resumed["next_position"])
|
||||
self.assertEqual(
|
||||
[run["run_id"] for run in earlier["items"]],
|
||||
[start.run_id for start in starts[:5]],
|
||||
)
|
||||
self.assertFalse(earlier["has_more"])
|
||||
self.assertEqual(earlier["resume_position"], first["resume_position"])
|
||||
|
||||
def test_restart_requires_a_complete_authenticated_style_upper_position_before_sql(
|
||||
self,
|
||||
):
|
||||
from database.history_reads import WindowPosition
|
||||
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.capture(db)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
self.assertIn("resume_position", page)
|
||||
position = page["resume_position"]
|
||||
for options in (
|
||||
{"restart": True},
|
||||
{"after": position, "restart": 1},
|
||||
{
|
||||
"after": WindowPosition(
|
||||
position.raw_text_id,
|
||||
position.upper,
|
||||
(0, position.upper[1], 0, position.upper[3]),
|
||||
),
|
||||
"restart": True,
|
||||
},
|
||||
):
|
||||
self.assert_position_rejected_before_sql(
|
||||
db, lambda: db.list_history_window(1, **options)
|
||||
)
|
||||
|
||||
def test_snapshot_epoch_first_ownership_and_rollback(self):
|
||||
db = self.database()
|
||||
self.require_reads(db)
|
||||
conn = db.model.get_connection()
|
||||
for read in (
|
||||
lambda: db.list_history_window(1),
|
||||
lambda: db.get_history_run("missing"),
|
||||
lambda: db.list_history_outputs("missing"),
|
||||
lambda: db.get_history_occurrence("missing", "9", 0),
|
||||
):
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
read()
|
||||
conn.execute("BEGIN")
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
with db.recorded_history_snapshot():
|
||||
pass
|
||||
self.assertTrue(conn.in_transaction)
|
||||
conn.rollback()
|
||||
trace = []
|
||||
conn.set_trace_callback(trace.append)
|
||||
|
||||
def abort_snapshot() -> None:
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
self.assertEqual(epoch, db.get_execution_epoch())
|
||||
db.get_execution_contexts("missing")
|
||||
db.get_execution_outputs("missing")
|
||||
self.assertTrue(conn.in_transaction)
|
||||
raise RuntimeError("abort")
|
||||
|
||||
try:
|
||||
self.assertRaisesRegex(RuntimeError, "abort", abort_snapshot)
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertIn("storage_epoch", next(q for q in trace if q.startswith("SELECT")))
|
||||
self.assertFalse(conn.in_transaction)
|
||||
with self.assertRaises(HistoryEpochChanged):
|
||||
with db.recorded_history_snapshot("wrong"):
|
||||
pass
|
||||
self.assertFalse(conn.in_transaction)
|
||||
|
||||
def test_detail_preserves_full_text_snapshots_hints_and_archive(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.saved(db, "negative", 2)
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(7,1,2)"
|
||||
)
|
||||
start = self.capture(db, output=outputs())
|
||||
legacy = db.get_execution_contexts(start.run_id)
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
detail = db.get_history_run(start.run_id)
|
||||
self.assertIsNone(db.get_history_run("missing"))
|
||||
c = detail["contexts"][0]
|
||||
self.assertTrue(c["positive_snapshot_id"])
|
||||
self.assertTrue(c["negative_snapshot_id"])
|
||||
self.assertEqual(c["pair_id"], 7)
|
||||
self.assertEqual(
|
||||
c["navigation"],
|
||||
dict(positive_available=True, negative_available=True, pair_available=True),
|
||||
)
|
||||
self.assertEqual(c["positive"], legacy["contexts"][0]["positive"])
|
||||
db.archive_prompt_pair(7)
|
||||
with db.recorded_history_snapshot():
|
||||
c = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertEqual(c["pair_id"], 7)
|
||||
self.assertFalse(c["navigation"]["pair_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE execution_history_metadata SET storage_epoch=?",
|
||||
("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",),
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
detail = db.get_history_run(start.run_id)
|
||||
self.assertEqual(detail["run"]["storage_epoch"], start.storage_epoch)
|
||||
self.assertEqual(detail["contexts"][0]["reference_epoch"], start.storage_epoch)
|
||||
self.assertIsNone(detail["contexts"][0]["positive_prompt_id"])
|
||||
|
||||
def test_output_coordinates_fingerprint_and_capture_states(self):
|
||||
db = self.database()
|
||||
ref = outputs().references[0]
|
||||
refs = tuple(
|
||||
replace(ref, output_node_id=n, image_ordinal=i)
|
||||
for n, i in (("2", 0), ("9", 0), ("9", 1), ("😀", 0))
|
||||
)
|
||||
start = self.capture(db, output=replace(outputs(), references=refs))
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_outputs(start.run_id, limit=2)
|
||||
last = db.list_history_outputs(
|
||||
start.run_id, limit=2, after=page["next_position"]
|
||||
)
|
||||
one = db.get_history_occurrence(start.run_id, "9", 0)
|
||||
self.assertIsNone(db.get_history_occurrence(start.run_id, "9", 8))
|
||||
self.assertIsNone(db.list_history_outputs("missing"))
|
||||
items = page["items"] + last["items"]
|
||||
self.assertEqual(
|
||||
[
|
||||
(i["occurrence"]["output_node_id"], i["occurrence"]["image_ordinal"])
|
||||
for i in items
|
||||
],
|
||||
[(r.output_node_id, r.image_ordinal) for r in refs],
|
||||
)
|
||||
self.assertEqual(one["receipt"], items[1])
|
||||
self.assertEqual(one["capture"], page["capture"])
|
||||
self.assertEqual(page["retained_occurrence_count"], 4)
|
||||
self.assertEqual(
|
||||
page["capture_fingerprint"],
|
||||
hashlib.sha256(
|
||||
json.dumps(
|
||||
[1, "complete", "reported", 1, "host_final_history", 4],
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
).hexdigest(),
|
||||
)
|
||||
self.assertEqual(items[0]["file_identity"], "unverified")
|
||||
self.assertEqual(items[0]["context_membership"], "unknown")
|
||||
db.write_execution_outputs_for_finish(
|
||||
self.finish(start), outputs("different.png")
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
from database import history_reads
|
||||
|
||||
changed = getattr(history_reads, "HistoryCaptureChanged", None)
|
||||
self.assertTrue(
|
||||
isinstance(changed, type), "capture drift needs a distinct exception"
|
||||
)
|
||||
self.assertTrue(issubclass(changed, ValueError))
|
||||
with self.assertRaises(changed):
|
||||
db.list_history_outputs(start.run_id, after=page["next_position"])
|
||||
self.assertEqual(
|
||||
db.list_history_outputs(start.run_id)["list_status"], "incomplete"
|
||||
)
|
||||
for index, capture in enumerate(
|
||||
(
|
||||
None,
|
||||
OutputBundle((), "complete", "none_reported"),
|
||||
OutputBundle((), "unavailable", "unavailable"),
|
||||
replace(outputs(), state="partial", reason="unsupported"),
|
||||
),
|
||||
1,
|
||||
):
|
||||
s = self.capture(db, index, output=capture)
|
||||
with db.recorded_history_snapshot():
|
||||
p = db.list_history_outputs(s.run_id)
|
||||
self.assertEqual(
|
||||
p["retained_occurrence_count"],
|
||||
None if capture is None else len(capture.references),
|
||||
)
|
||||
self.assertEqual(
|
||||
p["list_status"],
|
||||
(
|
||||
"not_captured"
|
||||
if capture is None
|
||||
else "complete" if capture.state == "complete" else "incomplete"
|
||||
),
|
||||
)
|
||||
self.assertFalse(p["has_more"])
|
||||
|
||||
def test_limits_empty_group_and_orphan_validation(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
s = self.capture(db, context=False)
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
p = db.list_history_window(1)
|
||||
self.assertEqual(p["group"]["scope_status"], "no_matches")
|
||||
self.assertEqual(p["items"], [])
|
||||
self.assertEqual(
|
||||
db.get_history_run(s.run_id)["context_capture"]["state"], "not_captured"
|
||||
)
|
||||
for limit in (0, 101, True, 1.5):
|
||||
with self.assertRaises(ValueError):
|
||||
db.list_history_window(1, limit=limit)
|
||||
with self.assertRaises(ValueError):
|
||||
db.list_history_outputs(s.run_id, limit=limit)
|
||||
self.capture(db, 1, output=outputs())
|
||||
conn = db.model.get_connection()
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
self.addCleanup(conn.execute, "PRAGMA foreign_keys=ON")
|
||||
with conn:
|
||||
conn.execute("DELETE FROM execution_output_capture")
|
||||
with db.recorded_history_snapshot():
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
db.list_history_outputs(self.start(db, 1).run_id)
|
||||
|
||||
def test_query_indexes_additive_and_reinitializable(self):
|
||||
db = self.database()
|
||||
conn = db.model.get_connection()
|
||||
self.require_reads(db)
|
||||
expected = {
|
||||
"execution_prompt_snapshots": ("raw_text_id", "snapshot_id"),
|
||||
"execution_contexts": ("positive_snapshot_id", "context_id"),
|
||||
"execution_run_contexts": ("context_id", "run_id", "anchor_id"),
|
||||
"execution_runs": (
|
||||
"observed_dequeue_at",
|
||||
"session_id",
|
||||
"queue_item_id",
|
||||
"run_id",
|
||||
),
|
||||
}
|
||||
for table, columns in expected.items():
|
||||
indexes = [
|
||||
r for r in conn.execute(f"PRAGMA index_list({table})") if not r[2]
|
||||
]
|
||||
self.assertTrue(
|
||||
any(
|
||||
tuple(r[2] for r in conn.execute(f"PRAGMA index_info({i[1]})"))
|
||||
== columns
|
||||
for i in indexes
|
||||
),
|
||||
table,
|
||||
)
|
||||
self.assertIsNone(history_schema_error(conn, allow_missing=False))
|
||||
from database.execution_history_schema import migrate_execution_history
|
||||
|
||||
for table in expected:
|
||||
conn.execute(f"DROP INDEX idx_history_read_{table}")
|
||||
# Optional read indexes are not an owned-schema validity requirement.
|
||||
self.assertIsNone(history_schema_error(conn, allow_missing=False))
|
||||
migrate_execution_history(conn)
|
||||
migrate_execution_history(conn)
|
||||
self.assertTrue(
|
||||
all(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name=?",
|
||||
(f"idx_history_read_{t}",),
|
||||
).fetchone()
|
||||
for t in expected
|
||||
)
|
||||
)
|
||||
|
||||
def test_epoch_first_snapshot_excludes_concurrent_finish(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
start = self.start(db)
|
||||
db.write_execution_start(start)
|
||||
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
|
||||
peer = self.database()
|
||||
before = db.model.get_connection().total_changes
|
||||
with db.recorded_history_snapshot():
|
||||
peer.write_execution_finish(self.finish(start))
|
||||
self.assertEqual(
|
||||
db.get_history_run(start.run_id)["run"]["outcome"], "observed"
|
||||
)
|
||||
page = db.list_history_window(1)
|
||||
self.assertEqual(page["items"][0]["outcome"], "observed")
|
||||
self.assertIsNone(page["items"][0]["observed_callback_at"])
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(
|
||||
db.get_history_run(start.run_id)["run"]["outcome"], "succeeded"
|
||||
)
|
||||
self.assertEqual(db.model.get_connection().total_changes, before)
|
||||
|
||||
def test_compact_diagnostics_full_detail_and_no_filesystem(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
start = self.start(db)
|
||||
db.write_execution_start(start)
|
||||
db.write_execution_finish(
|
||||
self.finish(
|
||||
start,
|
||||
"execution_error",
|
||||
node_id="4",
|
||||
node_type="Example",
|
||||
exception_type="ValueError",
|
||||
)
|
||||
)
|
||||
db.write_execution_contexts(
|
||||
start.run_id, start.storage_epoch, bundle("猫" * 20000)
|
||||
)
|
||||
conn = db.model.get_connection()
|
||||
before = conn.total_changes
|
||||
with (
|
||||
patch("builtins.open", side_effect=AssertionError("filesystem")),
|
||||
patch("os.stat", side_effect=AssertionError("filesystem")),
|
||||
):
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
detail = db.get_history_run(start.run_id)
|
||||
db.list_history_outputs(start.run_id)
|
||||
db.get_history_occurrence(start.run_id, "9", 0)
|
||||
compact = page["items"][0]
|
||||
self.assertTrue(compact["diagnostics_available"])
|
||||
self.assertNotIn("node_id", compact)
|
||||
self.assertEqual(detail["run"]["node_id"], "4")
|
||||
self.assertEqual(detail["contexts"][0]["negative"]["raw"], "猫" * 20000)
|
||||
self.assertEqual(conn.total_changes, before)
|
||||
|
||||
def test_all_four_order_keys_across_sessions_and_run_ids(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
conn = db.model.get_connection()
|
||||
# Equal local dequeue coordinates in separate sessions are legitimate.
|
||||
starts = []
|
||||
for i, session in enumerate(
|
||||
(
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
)
|
||||
):
|
||||
start = replace(
|
||||
self.start(db, 2**53 + 1, 2**53 + 2),
|
||||
session_id=session,
|
||||
run_id=f"{i+1:08x}-1111-4111-8111-111111111111",
|
||||
)
|
||||
db.write_execution_start(start)
|
||||
db.write_execution_contexts(start.run_id, start.storage_epoch, bundle())
|
||||
starts.append(start)
|
||||
with db.recorded_history_snapshot():
|
||||
p = db.list_history_window(1, limit=1)
|
||||
q = db.list_history_window(1, limit=1, after=p["next_position"])
|
||||
self.assertEqual(p["items"][0]["session_id"], starts[0].session_id)
|
||||
self.assertEqual(q["items"][0]["session_id"], starts[1].session_id)
|
||||
self.assertEqual(q["items"][0]["queue_item_id"], 2**53 + 1)
|
||||
self.assertFalse(q["has_more"])
|
||||
self.assertNotIn(
|
||||
"rowid",
|
||||
" ".join(
|
||||
r[3]
|
||||
for r in conn.execute(
|
||||
"EXPLAIN QUERY PLAN SELECT run_id FROM execution_runs "
|
||||
"ORDER BY observed_dequeue_at,session_id,queue_item_id,run_id"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_exact_scalar_collision_check_and_original_hint_invalidation(self):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
start = self.capture(db)
|
||||
# A fake digest collision must never group different full scalars.
|
||||
from utils.execution_contexts import content_digest
|
||||
|
||||
self.saved(db, "different", 2)
|
||||
with patch(
|
||||
"database.history_reads.content_digest",
|
||||
return_value=content_digest("text", ("positive",)),
|
||||
):
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(db.list_history_window(2)["recorded_run_count"], 0)
|
||||
self.saved(db, "positive", 3)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertIsNone(context["positive_prompt_id"])
|
||||
self.assertFalse(context["navigation"]["positive_available"])
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM prompts WHERE id IN (1,3)")
|
||||
self.saved(db, "positive", 4)
|
||||
with db.recorded_history_snapshot():
|
||||
context = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertIsNone(context["positive_prompt_id"])
|
||||
self.assertEqual(context["reference_epoch"], start.storage_epoch)
|
||||
|
||||
def test_original_positive_hint_remains_but_negative_only_navigation_is_unavailable(
|
||||
self,
|
||||
):
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
start = self.capture(db)
|
||||
with db.recorded_history_snapshot():
|
||||
original = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertEqual(original["positive_prompt_id"], 1)
|
||||
self.assertTrue(original["navigation"]["positive_available"])
|
||||
self.saved(db, "other positive", 3)
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(3,1)"
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
current = db.get_history_run(start.run_id)["contexts"][0]
|
||||
self.assertIsNone(db.list_history_window(1))
|
||||
self.assertEqual(current["positive_prompt_id"], 1)
|
||||
self.assertEqual(current["reference_epoch"], original["reference_epoch"])
|
||||
self.assertFalse(current["navigation"]["positive_available"])
|
||||
self.assertEqual(current["positive"], original["positive"])
|
||||
self.assertEqual(current["evidence"], original["evidence"])
|
||||
|
||||
def test_snapshot_ownership_cleanup_survives_connection_close(self):
|
||||
from database.history_reads import _snapshots
|
||||
|
||||
db = self.database()
|
||||
previous = _snapshots.get()
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
with db.recorded_history_snapshot():
|
||||
db.close()
|
||||
self.assertEqual(_snapshots.get(), previous)
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertIsNone(db.get_history_run("missing"))
|
||||
|
||||
def assert_position_rejected_before_sql(self, db, read):
|
||||
trace = []
|
||||
conn = db.model.get_connection()
|
||||
conn.set_trace_callback(trace.append)
|
||||
try:
|
||||
with self.assertRaises(Exception) as raised:
|
||||
read()
|
||||
self.assertIs(type(raised.exception), ValueError)
|
||||
self.assertEqual(trace, [])
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
|
||||
def test_malformed_output_fingerprint_is_not_capture_drift(self):
|
||||
from database.history_reads import OutputPosition
|
||||
|
||||
db = self.database()
|
||||
start = self.capture(db, output=outputs())
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(
|
||||
db.list_history_outputs(start.run_id)["retained_occurrence_count"], 1
|
||||
)
|
||||
self.assert_position_rejected_before_sql(
|
||||
db,
|
||||
lambda: db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition("not-a-sha256", ("9", 0))
|
||||
),
|
||||
)
|
||||
|
||||
def test_malformed_output_coordinate_does_not_reach_sql(self):
|
||||
from database.history_reads import OutputPosition
|
||||
|
||||
db = self.database()
|
||||
start = self.capture(db, output=outputs())
|
||||
with db.recorded_history_snapshot():
|
||||
fingerprint = db.list_history_outputs(start.run_id)["capture_fingerprint"]
|
||||
self.assert_position_rejected_before_sql(
|
||||
db,
|
||||
lambda: db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition(fingerprint, ("9",))
|
||||
),
|
||||
)
|
||||
|
||||
def test_malformed_window_key_does_not_reach_sql(self):
|
||||
from database.history_reads import WindowPosition
|
||||
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
self.capture(db)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
self.assertEqual(page["recorded_run_count"], 1)
|
||||
self.assert_position_rejected_before_sql(
|
||||
db,
|
||||
lambda: db.list_history_window(
|
||||
1, after=WindowPosition(page["group"]["raw_text_id"], (1,), (1,))
|
||||
),
|
||||
)
|
||||
|
||||
def test_malformed_position_scalars_arity_and_bounds(self):
|
||||
from database.history_reads import OutputPosition, WindowPosition
|
||||
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
start = self.capture(db, output=outputs())
|
||||
with db.recorded_history_snapshot():
|
||||
raw_id = db.list_history_window(1)["group"]["raw_text_id"]
|
||||
fingerprint = db.list_history_outputs(start.run_id)["capture_fingerprint"]
|
||||
key = (1, start.session_id, 0, start.run_id)
|
||||
bad_digests = (None, 1, "a" * 63, "A" * 64, "g" * 64, "a" * 64 + "\0")
|
||||
bad_keys = (None, (), (1,), (*key, 1), list(key))
|
||||
for index in (0, 2):
|
||||
for scalar in (None, True, 1.5, "1", -1, 2**63):
|
||||
bad_keys += (key[:index] + (scalar,) + key[index + 1 :],)
|
||||
for index in (1, 3):
|
||||
for scalar in (
|
||||
None,
|
||||
1,
|
||||
"x" * 36,
|
||||
"AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA",
|
||||
start.run_id + "\0",
|
||||
):
|
||||
bad_keys += (key[:index] + (scalar,) + key[index + 1 :],)
|
||||
windows = [WindowPosition(digest, key, key) for digest in bad_digests]
|
||||
for bad in bad_keys:
|
||||
windows.extend(
|
||||
(WindowPosition(raw_id, bad, key), WindowPosition(raw_id, key, bad))
|
||||
)
|
||||
windows.extend(
|
||||
({}, (raw_id, key, key), OutputPosition(fingerprint, ("9", 0)))
|
||||
)
|
||||
bad_coordinates = (
|
||||
None,
|
||||
(),
|
||||
("9",),
|
||||
("9", 0, 1),
|
||||
["9", 0],
|
||||
(None, 0),
|
||||
(9, 0),
|
||||
("", 0),
|
||||
("x" * 129, 0),
|
||||
("😀" * 129, 0),
|
||||
("nul\0node", 0),
|
||||
("\ud800", 0),
|
||||
("9", None),
|
||||
("9", True),
|
||||
("9", 1.5),
|
||||
("9", "0"),
|
||||
("9", -1),
|
||||
("9", 256),
|
||||
("9", 2**63),
|
||||
)
|
||||
output_positions = [
|
||||
OutputPosition(digest, ("9", 0)) for digest in bad_digests
|
||||
]
|
||||
output_positions.extend(
|
||||
OutputPosition(fingerprint, coordinate)
|
||||
for coordinate in bad_coordinates
|
||||
)
|
||||
output_positions.extend(
|
||||
({}, (fingerprint, ("9", 0)), WindowPosition(raw_id, key, key))
|
||||
)
|
||||
for position in windows:
|
||||
with self.subTest(window=position):
|
||||
self.assert_position_rejected_before_sql(
|
||||
db, lambda: db.list_history_window(1, after=position)
|
||||
)
|
||||
for position in output_positions:
|
||||
with self.subTest(output=position):
|
||||
self.assert_position_rejected_before_sql(
|
||||
db,
|
||||
lambda: db.list_history_outputs(start.run_id, after=position),
|
||||
)
|
||||
|
||||
def test_position_boundary_controls_preserve_binary_coordinates_and_large_keys(
|
||||
self,
|
||||
):
|
||||
from database.history_reads import (
|
||||
HistoryCaptureChanged,
|
||||
OutputPosition,
|
||||
WindowPosition,
|
||||
)
|
||||
|
||||
db = self.database()
|
||||
self.saved(db)
|
||||
node = "😀" * 128
|
||||
ref = replace(outputs().references[0], output_node_id=node, image_ordinal=255)
|
||||
start = self.capture(
|
||||
db, time=2**63 - 1, output=replace(outputs(), references=(ref,))
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(1)
|
||||
raw_id = page["group"]["raw_text_id"]
|
||||
key = (2**63 - 1, start.session_id, 2**63 - 1, start.run_id)
|
||||
older = db.list_history_window(1, after=WindowPosition(raw_id, key, key))
|
||||
self.assertEqual(older["recorded_run_count"], 1)
|
||||
self.assertEqual(older["items"][0]["run_id"], start.run_id)
|
||||
output = db.list_history_outputs(start.run_id)
|
||||
fingerprint = output["capture_fingerprint"]
|
||||
end = db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition(fingerprint, (node, 255))
|
||||
)
|
||||
self.assertEqual(end["items"], [])
|
||||
self.assertFalse(end["has_more"])
|
||||
self.assertIsNone(end["next_position"])
|
||||
with self.assertRaises(ValueError) as mismatch:
|
||||
db.list_history_window(1, after=WindowPosition("0" * 64, key, key))
|
||||
self.assertIs(type(mismatch.exception), ValueError)
|
||||
with self.assertRaises(HistoryCaptureChanged):
|
||||
db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition("0" * 64, (node, 0))
|
||||
)
|
||||
@@ -463,3 +463,45 @@ class TestInspectionResponse(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["metadata"]["raw"], read.metadata)
|
||||
self.assertEqual(result["prompts"], PROMPTS)
|
||||
|
||||
def test_history_identity_shares_rooted_evidence_and_library_projection(self):
|
||||
from utils.inspection_response import encode_history_inspection_response
|
||||
|
||||
read = inspection({"prompt": graph_text()})
|
||||
identity = {
|
||||
"occurrence": {
|
||||
"run_id": "run",
|
||||
"output_node_id": "node",
|
||||
"image_ordinal": 2,
|
||||
},
|
||||
"reference": {"filename": "image.png"},
|
||||
"history_epoch": "epoch",
|
||||
"current_scope": "scope",
|
||||
"location_key": "location",
|
||||
"file_identity": "unverified",
|
||||
}
|
||||
body = json.loads(
|
||||
encode_history_inspection_response(
|
||||
current_output=identity,
|
||||
file={"size": 7, "observation": "current-file"},
|
||||
prompts=PROMPTS,
|
||||
resolution={"state": "resolved"},
|
||||
inspection=read,
|
||||
library={"status": "linked", "tags": ["猫" * 12000]},
|
||||
)
|
||||
)
|
||||
rooted = json.loads(
|
||||
encode(
|
||||
read,
|
||||
file={"size": 7, "observation": "current-file"},
|
||||
library={"status": "linked", "tags": ["猫" * 12000]},
|
||||
)
|
||||
)
|
||||
self.assertEqual(body["current_output"], identity)
|
||||
self.assertEqual(body.get("history_version"), 1)
|
||||
self.assertEqual(body.get("history_epoch"), "epoch")
|
||||
for key in ("file", "prompts", "metadata", "library"):
|
||||
self.assertEqual(body[key], rooted[key])
|
||||
self.assertEqual(body["library"]["status"], "limited")
|
||||
self.assertNotIn("image", body)
|
||||
self.assertNotIn("root_scope", body)
|
||||
|
||||
Vendored
+2
-2
@@ -28,10 +28,10 @@ test('execution script discovers only the new matrix and preserves all 78 stock
|
||||
const executions = list('tools/vendor/playwright.execution.config.mjs');
|
||||
assert.match(executions, /\[classic-start\]/);
|
||||
assert.match(executions, /\[nodes2-start\]/);
|
||||
assert.match(executions, /Total: 42 tests in 2 files/);
|
||||
assert.match(executions, /Total: 46 tests in 2 files/);
|
||||
for (const project of ['classic-start', 'nodes2-start']) {
|
||||
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
|
||||
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 13);
|
||||
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
|
||||
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
|
||||
}
|
||||
assert.doesNotMatch(executions, /prompt-manager.spec/);
|
||||
|
||||
+118
-94
@@ -7,7 +7,13 @@
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"classic-node.js": {
|
||||
"lines": 98,
|
||||
"branches": 79,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -23,6 +29,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"focused-node.js": {
|
||||
"lines": 97,
|
||||
"branches": 87,
|
||||
"functions": 90,
|
||||
"statements": 97
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
@@ -53,12 +65,36 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"metadata-autocomplete.js": {
|
||||
"lines": 98,
|
||||
"branches": 82,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
},
|
||||
"png-metadata.js": {
|
||||
"lines": 98,
|
||||
"branches": 90,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-bridge-adapter.js": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-bridge-host.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-library.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-list-sort.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -66,10 +102,28 @@
|
||||
"statements": 98
|
||||
},
|
||||
"scan-progress.js": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/core.js": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/prompt-bridge.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/suggestions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"sse-stream.js": {
|
||||
"lines": 98,
|
||||
@@ -101,60 +155,36 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"classic-node.js": {
|
||||
"lines": 98,
|
||||
"branches": 79,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/core.js": {
|
||||
"lines": 98,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"focused-node.js": {
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 73,
|
||||
"functions": 87,
|
||||
"branches": 92,
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"prompt-library.js": {
|
||||
"workspace/history-media.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"metadata-autocomplete.js": {
|
||||
"lines": 98,
|
||||
"branches": 81,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
"workspace/history-state.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 91,
|
||||
"functions": 96,
|
||||
"statements": 97
|
||||
},
|
||||
"shared/toolkit/suggestions.js": {
|
||||
"workspace/history.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/icons.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-layout.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -169,40 +199,28 @@
|
||||
},
|
||||
"workspace/images.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 87,
|
||||
"branches": 88,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"prompt-bridge-adapter.js": {
|
||||
"workspace/index.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/inspection-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"prompt-bridge-host.js": {
|
||||
"workspace/library-layout.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"shared/toolkit/prompt-bridge.js": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-inspector.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-sender.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
@@ -210,35 +228,11 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 80,
|
||||
"functions": 93,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/icons.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-layout.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 92,
|
||||
"functions": 88,
|
||||
"branches": 84,
|
||||
"functions": 94,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/inspection-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/media-viewer.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
@@ -246,10 +240,40 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
"lines": 88,
|
||||
"branches": 88,
|
||||
"lines": 89,
|
||||
"branches": 89,
|
||||
"functions": 98,
|
||||
"statements": 88
|
||||
"statements": 89
|
||||
},
|
||||
"workspace/prompt-inspector.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-sender.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 91,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/recorded-run.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 83,
|
||||
"functions": 71,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/viewer-state.mjs": {
|
||||
"lines": 98,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Restore publication checks for snapshot-owned history reads."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..database.operations import PromptDatabase
|
||||
|
||||
try:
|
||||
from ..database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
)
|
||||
except ImportError:
|
||||
from database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
)
|
||||
|
||||
|
||||
class HistoryReadFence:
|
||||
"""Combine a fresh storage epoch with process-local restore notifications."""
|
||||
|
||||
def __init__(self, db: "PromptDatabase") -> None:
|
||||
self._path = os.path.normcase(os.path.realpath(db.model.db_path))
|
||||
self._lock = threading.Lock()
|
||||
self._generation = 0
|
||||
self._closed = False
|
||||
self._unsubscribe = subscribe_history_restore(db.model.db_path, self._restored)
|
||||
|
||||
def _restored(self) -> None:
|
||||
with self._lock:
|
||||
if not self._closed:
|
||||
self._generation += 1
|
||||
|
||||
def _check(self, generation: int | None = None, epoch: str = "") -> int:
|
||||
if self._closed:
|
||||
raise sqlite3.OperationalError("History reads are closed")
|
||||
if generation is not None and generation != self._generation:
|
||||
raise HistoryEpochChanged(epoch)
|
||||
return self._generation
|
||||
|
||||
def begin(self) -> int:
|
||||
with self._lock:
|
||||
return self._check()
|
||||
|
||||
def validate(self, epoch: str, generation: int) -> None:
|
||||
"""Validate after the caller's read transaction and response work ended."""
|
||||
with self._lock:
|
||||
self._check(generation, epoch)
|
||||
# An API restore retires its previous model. Never reopen that owner,
|
||||
# and never reuse the projection transaction's view of the epoch.
|
||||
conn = sqlite3.connect(
|
||||
Path(self._path).as_uri() + "?mode=ro", uri=True, timeout=0.25
|
||||
)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT storage_epoch FROM execution_history_metadata "
|
||||
"WHERE singleton_id=1"
|
||||
).fetchone()
|
||||
if row is None or not isinstance(row[0], str):
|
||||
raise sqlite3.DatabaseError("Missing history storage epoch")
|
||||
current_epoch = row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
with self._lock:
|
||||
self._check(generation, epoch)
|
||||
if current_epoch != epoch:
|
||||
raise HistoryEpochChanged(current_epoch)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._generation += 1
|
||||
self._unsubscribe()
|
||||
@@ -237,10 +237,9 @@ def _library_projection(library: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def encode_inspection_response(
|
||||
def _encode_inspection(
|
||||
*,
|
||||
image: dict,
|
||||
root_scope: str,
|
||||
identity: dict,
|
||||
file: dict,
|
||||
prompts: dict,
|
||||
resolution: dict,
|
||||
@@ -252,8 +251,7 @@ def encode_inspection_response(
|
||||
response = {
|
||||
"success": True,
|
||||
"inspection_version": 1,
|
||||
"root_scope": root_scope,
|
||||
"image": image,
|
||||
**identity,
|
||||
"file": _file_projection(file, inspection),
|
||||
"prompts": prompts,
|
||||
"resolution": resolution,
|
||||
@@ -277,3 +275,48 @@ def encode_inspection_response(
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
raise ValueError("Inspection response exceeds limits")
|
||||
return body
|
||||
|
||||
|
||||
def encode_inspection_response(
|
||||
*,
|
||||
image: dict,
|
||||
root_scope: str,
|
||||
file: dict,
|
||||
prompts: dict,
|
||||
resolution: dict,
|
||||
inspection: "InspectionRead | None",
|
||||
library: dict,
|
||||
) -> bytes:
|
||||
"""Preserve the existing rooted wire and its bounded metadata projection."""
|
||||
return _encode_inspection(
|
||||
identity={"root_scope": root_scope, "image": image},
|
||||
file=file,
|
||||
prompts=prompts,
|
||||
resolution=resolution,
|
||||
inspection=inspection,
|
||||
library=library,
|
||||
)
|
||||
|
||||
|
||||
def encode_history_inspection_response(
|
||||
*,
|
||||
current_output: dict,
|
||||
file: dict,
|
||||
prompts: dict,
|
||||
resolution: dict,
|
||||
inspection: "InspectionRead | None",
|
||||
library: dict,
|
||||
) -> bytes:
|
||||
"""Use occurrence identity with the same current-file evidence projection."""
|
||||
return _encode_inspection(
|
||||
identity={
|
||||
"history_version": 1,
|
||||
"history_epoch": current_output["history_epoch"],
|
||||
"current_output": current_output,
|
||||
},
|
||||
file=file,
|
||||
prompts=prompts,
|
||||
resolution=resolution,
|
||||
inspection=inspection,
|
||||
library=library,
|
||||
)
|
||||
|
||||
+15
-3
@@ -15,11 +15,23 @@
|
||||
"use strict";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, code = null) {
|
||||
constructor(status, message, code = null, recovery = null) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null;
|
||||
this.recovery = null;
|
||||
if (recovery && typeof recovery === "object" && !Array.isArray(recovery)) {
|
||||
const keys = Object.keys(recovery);
|
||||
if (keys.length === 1 && keys[0] === "action" &&
|
||||
["refresh-history", "reload-outputs", "retry-read"].includes(recovery.action)) {
|
||||
this.recovery = { action: recovery.action };
|
||||
} else if (keys.length === 2 && keys.includes("action") && keys.includes("limit") &&
|
||||
recovery.action === "smaller-limit" && Number.isInteger(recovery.limit) &&
|
||||
recovery.limit >= 1 && recovery.limit <= 100) {
|
||||
this.recovery = { action: "smaller-limit", limit: recovery.limit };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +75,11 @@
|
||||
|
||||
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code);
|
||||
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery);
|
||||
}
|
||||
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
|
||||
if (data && typeof data === "object" && data.success === false) {
|
||||
throw new ApiError(response.status, serverMessage || "Request failed", data.code);
|
||||
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton('Refresh image collection');
|
||||
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
|
||||
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
|
||||
} else if (state.error && onRetry) {
|
||||
@@ -161,7 +161,13 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
inner.append(explanation);
|
||||
}
|
||||
const provenance = disclosure('File & provenance');
|
||||
provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted'));
|
||||
provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted'));
|
||||
if (record.identity.kind === 'reported-location') {
|
||||
const occurrence = record.identity.occurrence;
|
||||
provenance.append(definitionList([
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'file-occurrence'));
|
||||
}
|
||||
if (file) provenance.append(definitionList([
|
||||
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {projectInspectionPayload} from './inspection-state.mjs';
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||||
const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
||||
function scalar(value, bytes, nonempty = false) {
|
||||
if (typeof value !== 'string' || (nonempty && !value) || value.includes('\0') || /[\ud800-\udfff]/u.test(value) || new TextEncoder().encode(value).length > bytes) throw new TypeError('Invalid reported location scalar');
|
||||
}
|
||||
function coordinate(value) {
|
||||
if (!object(value) || !uuid(value.run_id) || !Number.isSafeInteger(value.image_ordinal) || value.image_ordinal < 0 || value.image_ordinal > 255) throw new TypeError('Invalid output occurrence');
|
||||
scalar(value.output_node_id, 512, true);
|
||||
if ([...value.output_node_id].length > 128) throw new TypeError('Invalid output node');
|
||||
}
|
||||
function reference(value) {
|
||||
if (!object(value) || !['output', 'temp'].includes(value.folder_type) || !['posix', 'windows'].includes(value.path_style) || !(value.root_key === null || digest(value.root_key))) throw new TypeError('Invalid output reference');
|
||||
scalar(value.filename, 1024, true); scalar(value.subfolder, 2048);
|
||||
const windows = value.path_style === 'windows', separators = windows ? /[/\\]/ : /\//;
|
||||
if (['.', '..'].includes(value.filename) || separators.test(value.filename) || value.subfolder.startsWith('/') ||
|
||||
(windows && (/^[\\/]/.test(value.subfolder) || /:/.test(value.filename + value.subfolder))) ||
|
||||
(value.subfolder && value.subfolder.split(separators).some(part => !part || part === '.' || part === '..'))) throw new TypeError('Unsafe output reference');
|
||||
if (128 + new TextEncoder().encode(value.folder_type + value.path_style + value.subfolder + value.filename + (value.root_key || '')).length > 4096) throw new TypeError('Output reference exceeds limit');
|
||||
}
|
||||
export function occurrenceKey(occurrence) {
|
||||
coordinate(occurrence);
|
||||
return JSON.stringify([occurrence.run_id, occurrence.output_node_id, occurrence.image_ordinal]);
|
||||
}
|
||||
function validateTarget(target) {
|
||||
if (!object(target) || target.kind !== 'reported-location' || !uuid(target.history_epoch) || !digest(target.current_scope) || !digest(target.location_key) || typeof target.is_video !== 'boolean') throw new TypeError('Invalid reported location');
|
||||
coordinate(target.occurrence); reference(target.reference);
|
||||
if (target.filename !== target.reference.filename) throw new TypeError('Mismatched reported filename');
|
||||
}
|
||||
function urlFor(target, field, endpoint) {
|
||||
const location = globalThis.location || globalThis.window?.location;
|
||||
const base = new URL(location?.href || 'http://localhost/');
|
||||
const resolve = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (value => value);
|
||||
if (typeof target[field] !== 'string' || !target[field]) throw new TypeError('Missing reported media URL');
|
||||
const value = resolve(target[field]), url = new URL(value, base);
|
||||
const expected = new URL(resolve(`/prompt_manager/history/runs/${target.occurrence.run_id}/output/current/${endpoint}`), base);
|
||||
const identity = {node:target.occurrence.output_node_id, ordinal:String(target.occurrence.image_ordinal), epoch:target.history_epoch, current_scope:target.current_scope};
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== base.origin || url.username || url.password || url.hash ||
|
||||
url.pathname !== expected.pathname || /\/(?:\.|%2e){1,2}(?:\/|%2f)/i.test(value) ||
|
||||
[...url.searchParams.keys()].some(key => !Object.hasOwn(identity, key) && key !== '_pm_viewer') || url.searchParams.getAll('_pm_viewer').length > 1 ||
|
||||
Object.entries(identity).some(([key, expected]) => url.searchParams.getAll(key).length !== 1 || url.searchParams.get(key) !== expected)) throw new TypeError('Invalid reported media identity');
|
||||
return value;
|
||||
}
|
||||
function validate(target) {
|
||||
validateTarget(target);
|
||||
urlFor(target, 'url', 'media'); urlFor(target, 'inspection_url', 'inspection'); urlFor(target, 'thumbnail_url', 'thumbnail');
|
||||
}
|
||||
function changed(code) {
|
||||
return Object.assign(new Error('Current recorded location changed; reload before using this file.'), {status:409, refreshRequired:true, code, recovery:{action:code === 'history-changed' ? 'refresh-history' : 'reload-outputs'}});
|
||||
}
|
||||
function sameReference(a, b) {
|
||||
return object(a) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => a[key] === b[key]);
|
||||
}
|
||||
export function toHistoryInspectionRecord(result, target, extractFacts) {
|
||||
validate(target);
|
||||
if (!object(result) || result.success !== true || result.history_version !== 1 || result.inspection_version !== 1) throw new TypeError('Invalid history inspection');
|
||||
if (result.history_epoch !== target.history_epoch) throw changed('history-changed');
|
||||
const current = result.current_output;
|
||||
if (!object(current) || current.history_epoch !== target.history_epoch || current.current_scope !== target.current_scope || current.location_key !== target.location_key ||
|
||||
!object(current.occurrence) || ['run_id', 'output_node_id', 'image_ordinal'].some(key => current.occurrence[key] !== target.occurrence[key]) || !sameReference(current.reference, target.reference)) throw changed('candidate-stale');
|
||||
if (current.observation !== 'current-file' || current.evidence_class !== 'locator_candidate' || current.file_identity !== 'unverified' || current.newly_generated !== 'unknown' || current.context_membership !== 'unknown' || current.transient !== (target.reference.folder_type === 'temp')) throw new TypeError('Invalid occurrence evidence');
|
||||
const path = [target.reference.subfolder, target.reference.filename].filter(Boolean).join('/');
|
||||
const identity = {kind:'reported-location', key:target.location_key, path:target.reference.path_style === 'windows' ? path.replaceAll('\\', '/') : path,
|
||||
occurrence:{...target.occurrence}, history_epoch:target.history_epoch, current_scope:target.current_scope, reference:{...target.reference}};
|
||||
return projectInspectionPayload(result, identity, {url:urlFor(target, 'url', 'media'), filename:target.filename}, extractFacts);
|
||||
}
|
||||
export const reportedLocationAdapter = Object.freeze({
|
||||
validate,
|
||||
key(target) {validateTarget(target); return target.location_key;},
|
||||
scope(target) {validateTarget(target); return `${target.history_epoch}:${target.current_scope}`;},
|
||||
inspectionPath(target) {validate(target); return target.inspection_url;},
|
||||
originalDownload(target) {validate(target); return {url:urlFor(target, 'url', 'media'), filename:target.filename};},
|
||||
toRecord:toHistoryInspectionRecord,
|
||||
});
|
||||
|
||||
/** Only loaded, currently available locations enter a frozen Viewer opening. */
|
||||
export function distinctCurrentMedia(receipts, selectedOccurrence) {
|
||||
if (!Array.isArray(receipts)) throw new TypeError('Invalid loaded receipts');
|
||||
const selected = selectedOccurrence === null || selectedOccurrence === undefined ? null : occurrenceKey(selectedOccurrence);
|
||||
const locations = new Map();
|
||||
for (const receipt of receipts) {
|
||||
if (receipt?.current_candidate?.status !== 'available') continue;
|
||||
const target = receipt.current_candidate.media;
|
||||
validate(target);
|
||||
if (occurrenceKey(receipt.occurrence) !== occurrenceKey(target.occurrence) || !sameReference(receipt.reference, target.reference)) throw new TypeError('Mismatched receipt media');
|
||||
if (!locations.has(target.location_key) || occurrenceKey(target.occurrence) === selected) locations.set(target.location_key, target);
|
||||
}
|
||||
return [...locations.values()];
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import {occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||||
const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||||
const id = value => Number.isSafeInteger(value) && value > 0;
|
||||
const count = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const fields = ['raw', 'effective', 'prepend', 'append'];
|
||||
const times = ['queue_item_id', 'observed_dequeue_at', 'observed_callback_at', 'executor_started_at', 'terminal_event_at'];
|
||||
function requireValue(valid, message = 'Invalid recorded history response') {if (!valid) throw new TypeError(message);}
|
||||
function text(value, maxBytes, maxChars = Infinity) {
|
||||
return typeof value === 'string' && [...value].length <= maxChars && !/[\ud800-\udfff]/u.test(value) && new TextEncoder().encode(value).length <= maxBytes;
|
||||
}
|
||||
function node(value) {return text(value, 512, 128) && value.length > 0 && !value.includes('\0');}
|
||||
function decimal(value) {return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,18})$/.test(value) && BigInt(value) <= 9223372036854775807n;}
|
||||
function freeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
|
||||
return value;
|
||||
}
|
||||
function envelope(value, epoch = null) {
|
||||
requireValue(object(value) && value.success === true && value.history_version === 1 && uuid(value.history_epoch));
|
||||
if (epoch !== null && value.history_epoch !== epoch) throw Object.assign(new Error('Recorded history changed'), {status:409, code:'history-changed'});
|
||||
}
|
||||
function pagination(value, previousCursor) {
|
||||
requireValue(typeof value.has_more === 'boolean' && (value.next_cursor === null || (text(value.next_cursor, 4096) && value.next_cursor.length > 0)) &&
|
||||
value.has_more === (value.next_cursor !== null) && (!value.has_more || value.next_cursor !== previousCursor));
|
||||
}
|
||||
function capture(value, output = false) {
|
||||
requireValue(object(value) && ['complete', 'partial', 'ambiguous', 'unavailable', 'conflicting', 'not_captured'].includes(value.state) &&
|
||||
(value.reason === null || text(value.reason, 128)) && (value.state === 'not_captured' ? value.format_version === null : value.format_version === 1));
|
||||
if (output) requireValue(!['ambiguous'].includes(value.state) && (value.state === 'not_captured' ? value.source === null : value.source === 'host_final_history'));
|
||||
}
|
||||
function summary(value) {
|
||||
capture(value, true);
|
||||
requireValue((value.retained_occurrence_count === null || (count(value.retained_occurrence_count) && value.retained_occurrence_count <= 256)) &&
|
||||
value.list_status === (value.state === 'not_captured' ? 'not_captured' : value.state === 'complete' ? 'complete' : 'incomplete') &&
|
||||
(value.state === 'not_captured') === (value.retained_occurrence_count === null));
|
||||
}
|
||||
function evidence(value) {
|
||||
requireValue(object(value) && ['recorded', 'inferred', 'unknown'].includes(value.provenance) &&
|
||||
({recorded:['runtime_return', 'host_cache_output'], inferred:['graph_literal', 'graph_semantics'], unknown:['unknown']})[value.provenance].includes(value.source) &&
|
||||
(value.provenance === 'unknown' ? ['unavailable', 'unsupported', 'ambiguous', 'limit_reached', 'missing'].includes(value.reason) : value.reason === null));
|
||||
}
|
||||
function anchor(value) {
|
||||
requireValue(object(value) && node(value.anchor_id) && digest(value.context_id) &&
|
||||
(value.positive_snapshot_id === null || digest(value.positive_snapshot_id)) && (value.negative_snapshot_id === null || digest(value.negative_snapshot_id)));
|
||||
}
|
||||
function validateRun(value, window = false) {
|
||||
requireValue(object(value) && ['run_id', 'session_id', 'job_id', 'storage_epoch'].every(key => uuid(value[key])) && value.record_version === 1);
|
||||
for (const key of times) requireValue((!['queue_item_id', 'observed_dequeue_at'].includes(key) && value[key] === null) || decimal(value[key]));
|
||||
requireValue(['observed', 'succeeded', 'failed', 'interrupted', 'unknown'].includes(value.outcome) &&
|
||||
(value.reason === null || text(value.reason, 128)) && typeof value.diagnostics_omitted === 'boolean');
|
||||
const reasons = {succeeded:['terminal_success'], failed:['terminal_error'], interrupted:['terminal_interrupted'], unknown:['missing_terminal', 'conflicting_terminal', 'invalid_evidence', 'evidence_limit']};
|
||||
requireValue(value.outcome === 'observed' ? value.reason === null && value.observed_callback_at === null : value.observed_callback_at !== null && reasons[value.outcome].includes(value.reason));
|
||||
if (window) {
|
||||
requireValue(typeof value.diagnostics_available === 'boolean' && Array.isArray(value.matching_anchors) && value.matching_anchors.length <= 16);
|
||||
value.matching_anchors.forEach(anchor); capture(value.context_capture); summary(value.output_summary);
|
||||
requireValue(new Set(value.matching_anchors.map(a => a.anchor_id)).size === value.matching_anchors.length);
|
||||
}
|
||||
}
|
||||
function compareRun(a, b) {
|
||||
for (const key of ['observed_dequeue_at', 'session_id', 'queue_item_id', 'run_id']) {
|
||||
const av = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(a[key]) : a[key];
|
||||
const bv = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(b[key]) : b[key];
|
||||
if (av !== bv) return av < bv ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function validateWindow(value, positiveId, cursor, epoch) {
|
||||
envelope(value, epoch); pagination(value, cursor);
|
||||
requireValue(value.items?.length ? text(value.resume_cursor, 4096) && value.resume_cursor.length > 0 : value.resume_cursor === null);
|
||||
requireValue(object(value.group) && value.group.positive_id === positiveId && (value.group.raw_text_id === null || digest(value.group.raw_text_id)) &&
|
||||
value.group.relation === 'exact_frozen_raw_text' && ['uncapturable_text', 'no_matches', 'matched'].includes(value.group.scope_status) &&
|
||||
count(value.recorded_run_count) && Array.isArray(value.items) && value.items.length <= 100 && value.recorded_run_count >= value.items.length && Array.isArray(value.context_summaries));
|
||||
value.items.forEach(run => validateRun(run, true));
|
||||
requireValue(new Set(value.items.map(r => r.run_id)).size === value.items.length && value.items.every((r, i) => i === 0 || compareRun(value.items[i - 1], r) < 0));
|
||||
const branches = new Set();
|
||||
for (const s of value.context_summaries) {
|
||||
requireValue(object(s) && digest(s.context_id) && !branches.has(s.context_id) && digest(s.positive_snapshot_id) && (s.negative_snapshot_id === null || digest(s.negative_snapshot_id)));
|
||||
branches.add(s.context_id);
|
||||
const raw = s.negative_raw;
|
||||
requireValue(object(raw) && ['unknown', 'explicit_empty', 'known'].includes(raw.state) && typeof raw.truncated === 'boolean' && Array.isArray(raw.evidence) &&
|
||||
(raw.state === 'unknown' ? raw.preview === null && !raw.truncated : text(raw.preview, 512, 100) && (raw.state === 'explicit_empty' ? raw.preview === '' && !raw.truncated : raw.preview !== '')));
|
||||
for (const e of raw.evidence) {requireValue(uuid(e.run_id) && node(e.anchor_id)); evidence(e);}
|
||||
}
|
||||
requireValue((!value.has_more || value.items.length > 0) && value.items.every(r => r.matching_anchors.every(a => branches.has(a.context_id))));
|
||||
for (const s of value.context_summaries) {
|
||||
const matching = value.items.flatMap(r => r.matching_anchors.filter(a => a.context_id === s.context_id).map(a => ({...a, run_id:r.run_id})));
|
||||
requireValue(matching.length > 0 && matching.every(a => a.positive_snapshot_id === s.positive_snapshot_id && a.negative_snapshot_id === s.negative_snapshot_id) &&
|
||||
s.negative_raw.evidence.length === matching.length && new Set(s.negative_raw.evidence.map(e => JSON.stringify([e.run_id, e.anchor_id]))).size === matching.length &&
|
||||
s.negative_raw.evidence.every(e => matching.some(a => a.run_id === e.run_id && a.anchor_id === e.anchor_id)));
|
||||
}
|
||||
}
|
||||
function validateContext(value) {
|
||||
anchor(value);
|
||||
requireValue(uuid(value.reference_epoch) && object(value.evidence) && value.evidence.format_version === 1 && value.evidence.role_provenance === 'inferred');
|
||||
requireValue(value.positive_raw_text_id === null ? value.positive?.raw == null : digest(value.positive_raw_text_id) && typeof value.positive?.raw === 'string');
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const side = value[role], proof = value.evidence[role];
|
||||
requireValue((side === null) === (value[`${role}_snapshot_id`] === null));
|
||||
if (side === null) {requireValue(proof === null); continue;}
|
||||
requireValue(object(side) && object(proof));
|
||||
for (const field of fields) {
|
||||
requireValue(side[field] === null || text(side[field], 65536)); evidence(proof[field]);
|
||||
requireValue((side[field] === null) === (proof[field].provenance === 'unknown'));
|
||||
}
|
||||
}
|
||||
requireValue(['positive_prompt_id', 'negative_prompt_id', 'pair_id'].every(key => value[key] === null || id(value[key])) && object(value.navigation) &&
|
||||
['positive_available', 'negative_available', 'pair_available'].every(key => typeof value.navigation[key] === 'boolean'));
|
||||
}
|
||||
function validateDetail(value, runId, epoch) {
|
||||
envelope(value, epoch); validateRun(value.run); capture(value.context_capture); summary(value.output_summary);
|
||||
requireValue(value.run.run_id === runId && Array.isArray(value.contexts) && value.contexts.length <= 16);
|
||||
value.contexts.forEach(validateContext);
|
||||
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
|
||||
}
|
||||
export function frozenPromptRoles(context, mode) {
|
||||
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
const value = context[role]?.[mode] ?? null, proof = context.evidence[role]?.[mode] ?? null;
|
||||
return [role, {status:value === null ? 'unresolved' : 'resolved', text:value, source:proof?.provenance === 'recorded' ? 'execution' : proof?.provenance === 'inferred' ? 'graph-inferred' : null, evidence:proof,
|
||||
message:value === null ? 'This frozen field was not recorded.' : undefined}];
|
||||
}));
|
||||
}
|
||||
function validateReference(r) {
|
||||
requireValue(object(r) && ['output', 'temp'].includes(r.folder_type) && ['posix', 'windows'].includes(r.path_style) && (r.root_key === null || digest(r.root_key)) &&
|
||||
text(r.filename, 1024) && r.filename && !['.', '..'].includes(r.filename) && text(r.subfolder, 2048) && !r.filename.includes('\0') && !r.subfolder.includes('\0'));
|
||||
const split = r.path_style === 'windows' ? /[/\\]/ : /\//;
|
||||
requireValue(!split.test(r.filename) && !r.subfolder.startsWith('/') && (!r.subfolder || r.subfolder.split(split).every(p => p && p !== '.' && p !== '..')) &&
|
||||
(r.path_style !== 'windows' || (!r.subfolder.startsWith('\\') && !/:/.test(r.filename + r.subfolder))));
|
||||
}
|
||||
function compareCoordinate(a, b) {
|
||||
const aa = new TextEncoder().encode(a.output_node_id), bb = new TextEncoder().encode(b.output_node_id);
|
||||
for (let i = 0; i < Math.min(aa.length, bb.length); i++) if (aa[i] !== bb[i]) return aa[i] - bb[i];
|
||||
return aa.length - bb.length || a.image_ordinal - b.image_ordinal;
|
||||
}
|
||||
function validateOutputs(value, runId, epoch, cursor, previous) {
|
||||
envelope(value, epoch); pagination(value, cursor); capture(value.capture, true);
|
||||
summary({...value.capture, retained_occurrence_count:value.retained_occurrence_count, list_status:value.list_status});
|
||||
requireValue(digest(value.capture_fingerprint) && Array.isArray(value.items) && value.items.length <= 100 && (value.retained_occurrence_count ?? 0) >= value.items.length);
|
||||
if (cursor !== null && previous.capture_fingerprint !== value.capture_fingerprint) throw Object.assign(new Error('Output capture changed'), {status:409, code:'capture-changed'});
|
||||
for (const r of value.items) {
|
||||
occurrenceKey(r.occurrence); validateReference(r.reference);
|
||||
requireValue(r.occurrence.run_id === runId && r.file_identity === 'unverified' && r.newly_generated === 'unknown' && r.context_membership === 'unknown' && r.transient === (r.reference.folder_type === 'temp'));
|
||||
const c = r.current_candidate;
|
||||
requireValue(object(c) && ['available', 'unavailable', 'error'].includes(c.status) && c.evidence_class === 'locator_candidate');
|
||||
if (c.status === 'available') {
|
||||
reportedLocationAdapter.validate(c.media);
|
||||
requireValue(c.reason === null && c.current_scope === c.media.current_scope && c.location_key === c.media.location_key && c.media.history_epoch === epoch &&
|
||||
occurrenceKey(c.media.occurrence) === occurrenceKey(r.occurrence) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => c.media.reference[key] === r.reference[key]) && text(c.relative_path, 4096) && text(c.source_label, 4096) &&
|
||||
['none', 'unique', 'ambiguous'].includes(c.gallery_match) && (c.gallery_image_id === null || id(c.gallery_image_id)));
|
||||
} else requireValue(text(c.reason, 128) && c.reason.length > 0 && !['media', 'url', 'thumbnail_url', 'inspection_url'].some(key => Object.hasOwn(c, key)));
|
||||
}
|
||||
requireValue(value.items.every((r, i) => i === 0 || compareCoordinate(value.items[i - 1].occurrence, r.occurrence) < 0));
|
||||
if (cursor !== null && previous.items.length && value.items.length) requireValue(compareCoordinate(previous.items.at(-1).occurrence, value.items[0].occurrence) < 0);
|
||||
}
|
||||
const emptyWindow = () => ({items:[], context_summaries:[], group:null, recorded_run_count:0, next_cursor:null, resume_cursor:null, has_more:false, history_epoch:null, loading:false, error:null});
|
||||
const emptyRun = () => ({detail:null, loading:false, error:null});
|
||||
const emptyOutputs = () => ({items:[], capture:null, capture_fingerprint:null, retained_occurrence_count:null, list_status:null, next_cursor:null, has_more:false, loading:false, error:null});
|
||||
const emptySelection = mode => ({run_id:null, anchor_id:null, context_id:null, mode, receipt:null});
|
||||
|
||||
/** Fixed run positions; an off-screen focused column remains in semantic order. */
|
||||
export function visibleHistoryColumns(count, left, width, focusedIndex = -1) {
|
||||
requireValue(Number.isSafeInteger(count) && count >= 0, 'Invalid history column count');
|
||||
if (count <= 120) return Array.from({length:count}, (_, index) => index);
|
||||
const start = Math.max(0, Math.floor((Number.isFinite(left) ? Math.max(0, left) : 0) / 64) - 3);
|
||||
const right = (Number.isFinite(left) ? Math.max(0, left) : 0) + (Number.isFinite(width) ? Math.max(1, width) : 1);
|
||||
const end = Math.min(count - 1, Math.ceil(right / 64) - 1 + 3);
|
||||
const result = [];
|
||||
for (let index = start; index <= end; index++) result.push(index);
|
||||
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0 && focusedIndex < count && !result.includes(focusedIndex)) {
|
||||
result.push(focusedIndex);result.sort((a, b) => a - b);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createHistoryController({api, positiveId, onChange = () => {}, restore}) {
|
||||
requireValue(id(positiveId), 'Invalid saved positive ID');
|
||||
const offset = value => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
const intent = {search:typeof restore?.search === 'string' ? restore.search : '', scrollTop:offset(restore?.scrollTop), scrollLeft:offset(restore?.scrollLeft),
|
||||
anchor:uuid(restore?.anchor?.runId) && digest(restore?.anchor?.contextId) && Number.isFinite(restore.anchor.x) && Number.isFinite(restore.anchor.y) ? {runId:restore.anchor.runId, contextId:restore.anchor.contextId, x:restore.anchor.x, y:restore.anchor.y} : null};
|
||||
let state = freeze({window:emptyWindow(), run:emptyRun(), outputs:emptyOutputs(), selection:emptySelection(restore?.mode === 'effective' ? 'effective' : 'raw'),
|
||||
parent:{detail:null, loading:false, error:null}, intent, refreshRequired:null, refreshLabel:'', validating:false, actionsEnabled:false});
|
||||
let disposed = false, failedOutput = null, runOptions = null;
|
||||
const receiptReads = new Map();
|
||||
let restoreHint = uuid(restore?.runId) && (restore.anchorId === undefined || restore.anchorId === null || node(restore.anchorId)) && (restore.contextId === undefined || restore.contextId === null || digest(restore.contextId)) ? {runId:restore.runId, anchorId:restore.anchorId ?? null, contextId:restore.contextId ?? null} : null;
|
||||
const restoreEpoch = uuid(restore?.historyEpoch) ? restore.historyEpoch : null;
|
||||
let restoreRange = restoreEpoch && text(restore?.resumeCursor, 4096) && restore.resumeCursor && id(restore?.loadedCount) ? {cursor:restore.resumeCursor, pages:Math.ceil(restore.loadedCount / 40), epoch:restoreEpoch} : null;
|
||||
if (restoreHint) {
|
||||
restoreHint.epoch = restoreEpoch;restoreHint.outputCount = count(restore?.outputCount) && restore.outputCount <= 256 ? restore.outputCount : 0;restoreHint.outputsLoaded = restore?.outputsLoaded === true;
|
||||
try {if (restoreEpoch && restoreHint.outputCount && restore?.receipt?.run_id === restoreHint.runId) {occurrenceKey(restore.receipt);restoreHint.receipt = {...restore.receipt};}} catch { /* Invalid selection intent cannot become a receipt. */ }
|
||||
}
|
||||
const requests = {window:null, run:null, outputs:null, parent:null, validation:null};
|
||||
const generations = {window:0, run:0, outputs:0, parent:0, validation:0};
|
||||
function cancel(slot) {generations[slot]++; requests[slot]?.abort(); requests[slot] = null;}
|
||||
function begin(slot) {cancel(slot); const request = requests[slot] = new AbortController(), generation = generations[slot]; return {signal:request.signal, current:() => !disposed && requests[slot] === request && generations[slot] === generation};}
|
||||
function emit(patch) {
|
||||
if (disposed) return;
|
||||
const next = {...state, ...patch};
|
||||
next.actionsEnabled = !!next.window.history_epoch && !next.refreshRequired && !next.validating && !next.window.loading && !next.window.error && !next.run.loading && !next.run.error;
|
||||
state = freeze(next); onChange(state);
|
||||
}
|
||||
function clearOutputs() {cancel('outputs'); failedOutput = null; receiptReads.clear(); return emptyOutputs();}
|
||||
function clearDependents() {cancel('run'); return {run:emptyRun(), outputs:clearOutputs(), selection:emptySelection(state.selection.mode)};}
|
||||
async function validateParent() {
|
||||
const request = begin('parent'); emit({parent:{detail:null, loading:true, error:null}});
|
||||
try {
|
||||
const detail = await api.get(`/prompt_manager/library/${positiveId}`, {signal:request.signal});
|
||||
if (!request.current()) return;
|
||||
requireValue(object(detail) && object(detail.positive) && detail.positive.id === positiveId && typeof detail.positive.text === 'string');
|
||||
emit({parent:{detail:structuredClone(detail), loading:false, error:null}});
|
||||
} catch (error) {if (request.current()) emit({parent:{detail:null, loading:false, error}});}
|
||||
}
|
||||
function recover(error) {
|
||||
if (error?.status !== 409 || !['history-changed', 'capture-changed', 'candidate-stale'].includes(error.code)) return false;
|
||||
if (error.code === 'history-changed') {
|
||||
restoreHint = null;restoreRange = null;runOptions = null;
|
||||
cancel('window'); cancel('validation');
|
||||
emit({...clearDependents(), window:emptyWindow(), validating:false, refreshRequired:error.code, refreshLabel:'Reload recorded history'});
|
||||
void validateParent();
|
||||
} else {
|
||||
if (state.refreshRequired === error.code) {emit({outputs:{...state.outputs, loading:false, error}}); return true;}
|
||||
emit({outputs:clearOutputs(), selection:{...state.selection, receipt:null}, refreshRequired:error.code, refreshLabel:'Reload reported outputs'});
|
||||
void fetchOutputs(null, 40);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function mergeSummaries(earlier, later, items) {
|
||||
const map = new Map();
|
||||
for (const s of [...earlier, ...later]) {
|
||||
const previous = map.get(s.context_id);
|
||||
const evidence = new Map([...(previous?.negative_raw.evidence || []), ...s.negative_raw.evidence].map(e => [JSON.stringify([e.run_id, e.anchor_id]), e]));
|
||||
map.set(s.context_id, {...s, negative_raw:{...s.negative_raw, evidence:[...evidence.values()]}});
|
||||
}
|
||||
const ordered = new Map(); for (const r of items) for (const a of r.matching_anchors) ordered.set(a.context_id, map.get(a.context_id));
|
||||
return [...ordered.values()];
|
||||
}
|
||||
async function fetchWindow(earlier = false, {owner = null, expectedEpoch = null, resume = null} = {}) {
|
||||
if (disposed || (earlier && (state.window.loading || !state.window.has_more || state.refreshRequired || (state.validating && !owner)))) return false;
|
||||
const cursor = earlier ? state.window.next_cursor : null, epoch = earlier ? state.window.history_epoch : expectedEpoch;
|
||||
const previous = state.window, request = begin('window');
|
||||
emit({window:{...state.window, loading:true, error:null}});
|
||||
const query = new URLSearchParams({limit:'40'});if (cursor !== null) query.set('cursor', cursor);else if (resume !== null) query.set('resume', resume);
|
||||
try {
|
||||
const value = await api.get(`/prompt_manager/library/${positiveId}/history?${query}`, {signal:request.signal});
|
||||
if (!request.current() || (owner && !owner.current())) return false;
|
||||
validateWindow(value, positiveId, cursor, epoch);
|
||||
requireValue(!earlier || value.group.raw_text_id === previous.group.raw_text_id);
|
||||
const page = structuredClone(value), merged = new Map();
|
||||
for (const r of [...page.items, ...(earlier ? previous.items : [])]) merged.set(r.run_id, r);
|
||||
const items = [...merged.values()].sort(compareRun);
|
||||
emit({window:{...page, items, context_summaries:mergeSummaries(page.context_summaries, earlier ? previous.context_summaries : [], items), loading:false, error:null}});
|
||||
return true;
|
||||
} catch (error) {if (request.current() && (!owner || owner.current()) && !recover(error)) emit({window:{...state.window, loading:false, error}});return false;}
|
||||
}
|
||||
function selectedIntent() {
|
||||
return state.selection.run_id ? {runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, epoch:state.window.history_epoch,
|
||||
outputCount:state.outputs.items.length, outputsLoaded:state.outputs.capture !== null, receipt:state.selection.receipt && {...state.selection.receipt}} : null;
|
||||
}
|
||||
async function restoreSelection(hint, owner) {
|
||||
await selectRun(hint.runId, {...hint, restoring:true});
|
||||
if (!owner.current()) return;
|
||||
if (!state.run.detail && (state.run.error?.status === 404 || state.run.error instanceof TypeError)) {
|
||||
emit({validating:false, selection:emptySelection(state.selection.mode)});return;
|
||||
}
|
||||
if (state.run.detail && (hint.outputsLoaded || hint.outputCount)) {
|
||||
const pages = Math.max(1, Math.ceil(hint.outputCount / 40));
|
||||
for (let page = 0; page < pages; page++) {
|
||||
await fetchOutputs(state.outputs.next_cursor, 40);
|
||||
if (!owner.current()) return;
|
||||
if (!state.outputs.capture || state.outputs.error || state.refreshRequired || !state.outputs.has_more || state.outputs.items.length >= hint.outputCount) break;
|
||||
}
|
||||
}
|
||||
if (!owner.current()) return;
|
||||
const receipt = hint.receipt && state.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(hint.receipt));
|
||||
emit({validating:false, selection:{...state.selection, receipt:receipt ? {...receipt.occurrence} : null}});
|
||||
}
|
||||
async function loadHistory(useVisit = false) {
|
||||
if (disposed) return;
|
||||
const hint = selectedIntent() || (useVisit ? restoreHint : null), range = useVisit ? restoreRange : null;
|
||||
const expectedEpoch = state.window.history_epoch || range?.epoch || hint?.epoch || null, owner = begin('validation');
|
||||
cancel('window');emit({...clearDependents(), window:emptyWindow(), validating:true, refreshRequired:null, refreshLabel:''});
|
||||
const pages = range?.pages || 1;
|
||||
for (let page = 0; page < pages; page++) {
|
||||
const loaded = await fetchWindow(page > 0, {owner, expectedEpoch, resume:page === 0 ? range?.cursor ?? null : null});
|
||||
if (!owner.current()) return;
|
||||
if (!loaded) {emit({validating:false});return;}
|
||||
if (!state.window.has_more) break;
|
||||
}
|
||||
restoreHint = null;restoreRange = null;
|
||||
if (hint && (hint.epoch === state.window.history_epoch || state.window.items.some(run => run.run_id === hint.runId))) void restoreSelection(hint, owner);
|
||||
else emit({validating:false});
|
||||
}
|
||||
async function selectRun(runId, {anchorId = null, contextId = null, restoring = false} = {}) {
|
||||
if (disposed) return;
|
||||
requireValue(uuid(runId) && (anchorId === null || node(anchorId)) && (contextId === null || digest(contextId)), 'Invalid run selection');
|
||||
requireValue(state.window.history_epoch !== null && !state.refreshRequired, 'Load recorded history before selecting a run');
|
||||
if (!restoring) {cancel('validation');cancel('window');}
|
||||
runOptions = {runId, anchorId, contextId};
|
||||
const request = begin('run'), epoch = state.window.history_epoch;
|
||||
emit({validating:restoring, window:{...state.window, loading:false}, run:{...emptyRun(), loading:true}, outputs:clearOutputs(), selection:{...emptySelection(state.selection.mode), run_id:runId}});
|
||||
try {
|
||||
const value = await api.get(`/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}`, {signal:request.signal});
|
||||
if (!request.current()) return;
|
||||
validateDetail(value, runId, epoch);
|
||||
const detail = structuredClone(value);
|
||||
requireValue(digest(state.window.group?.raw_text_id) && detail.contexts.some(context => context.positive_raw_text_id === state.window.group.raw_text_id), 'Recorded run is outside this positive history');
|
||||
const selected = detail.contexts.find(c => (anchorId === null || c.anchor_id === anchorId) && (contextId === null || c.context_id === contextId));
|
||||
requireValue((anchorId === null && contextId === null) || !!selected, 'Recorded context is unavailable');
|
||||
emit({run:{detail, loading:false, error:null}, selection:{...state.selection, anchor_id:selected?.anchor_id ?? null, context_id:selected?.context_id ?? null}});
|
||||
} catch (error) {if (request.current() && !recover(error)) emit({run:{detail:null, loading:false, error}});}
|
||||
}
|
||||
function cancelDependentRead() {cancel('outputs'); emit({outputs:{...state.outputs, loading:false}});}
|
||||
async function fetchOutputs(cursor, limit) {
|
||||
if (disposed || !state.run.detail || state.refreshRequired === 'history-changed') return;
|
||||
const runId = state.selection.run_id, epoch = state.window.history_epoch, previous = state.outputs, request = begin('outputs');
|
||||
const attempt = {cursor, limit};
|
||||
emit({outputs:{...previous, loading:true, error:null}});
|
||||
const query = new URLSearchParams({epoch, limit:String(limit)}); if (cursor !== null) query.set('cursor', cursor);
|
||||
try {
|
||||
const value = await api.get(`/prompt_manager/history/runs/${runId}/outputs?${query}`, {signal:request.signal});
|
||||
if (!request.current()) return;
|
||||
validateOutputs(value, runId, epoch, cursor, previous);
|
||||
const page = structuredClone(value), items = cursor === null ? page.items : [...previous.items, ...page.items];
|
||||
requireValue(items.length <= 256 && (page.retained_occurrence_count ?? 0) >= items.length && (page.has_more ? page.items.length > 0 && (page.retained_occurrence_count ?? 0) > items.length : (page.retained_occurrence_count ?? 0) === items.length));
|
||||
if (cursor === null) receiptReads.clear();
|
||||
const read = {cursor, limit, keys:page.items.map(item => occurrenceKey(item.occurrence))};
|
||||
for (const key of read.keys) receiptReads.set(key, read);
|
||||
failedOutput = null;
|
||||
emit({outputs:{...page, items, loading:false, error:null}, refreshRequired:null, refreshLabel:''});
|
||||
} catch (error) {
|
||||
if (!request.current() || recover(error)) return;
|
||||
failedOutput = attempt;
|
||||
if (error?.status === 422 && error.code === 'response-limit' && error.recovery?.action === 'smaller-limit' && Number.isSafeInteger(error.recovery.limit) && error.recovery.limit >= 1 && error.recovery.limit < limit) failedOutput = {cursor, limit:error.recovery.limit};
|
||||
emit({outputs:{...previous, loading:false, error}});
|
||||
}
|
||||
}
|
||||
async function retryReceipt(occurrence) {
|
||||
if (disposed || state.refreshRequired || state.validating || state.outputs.loading || !state.run.detail) return;
|
||||
const key = occurrenceKey(occurrence), read = receiptReads.get(key);
|
||||
requireValue(!!read, 'Reported output is unavailable');
|
||||
const previous = state.outputs, request = begin('outputs'), runId = state.selection.run_id, epoch = state.window.history_epoch;
|
||||
let cursor = read.cursor, limit = failedOutput?.receipt && occurrenceKey(failedOutput.receipt) === key ? failedOutput.limit : read.limit;
|
||||
emit({outputs:{...previous, loading:true, error:null}});
|
||||
try {
|
||||
const replacements = new Map();
|
||||
// A reduced response budget may need several reads, bounded by this one loaded page.
|
||||
for (let offset = 0; offset < read.keys.length;) {
|
||||
const query = new URLSearchParams({epoch, limit:String(limit)});if (cursor !== null) query.set('cursor', cursor);
|
||||
const value = await api.get(`/prompt_manager/history/runs/${runId}/outputs?${query}`, {signal:request.signal});
|
||||
if (!request.current()) return;
|
||||
validateOutputs(value, runId, epoch, cursor, {...previous, items:[]});
|
||||
if (value.capture_fingerprint !== previous.capture_fingerprint) throw Object.assign(new Error('Output capture changed'), {status:409, code:'capture-changed'});
|
||||
const page = structuredClone(value);
|
||||
requireValue(page.items.length > 0 && page.items.length <= read.keys.length - offset);
|
||||
for (const [index, item] of page.items.entries()) {
|
||||
const itemKey = occurrenceKey(item.occurrence), old = previous.items.find(item => occurrenceKey(item.occurrence) === itemKey);
|
||||
requireValue(itemKey === read.keys[offset + index] && old && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(field => old.reference[field] === item.reference[field]));
|
||||
replacements.set(itemKey, item);
|
||||
}
|
||||
offset += page.items.length;
|
||||
if (replacements.has(key)) break;
|
||||
requireValue(page.has_more);cursor = page.next_cursor;
|
||||
}
|
||||
requireValue(replacements.has(key));failedOutput = null;
|
||||
emit({outputs:{...previous, items:previous.items.map(item => replacements.get(occurrenceKey(item.occurrence)) || item), loading:false, error:null}});
|
||||
} catch (error) {
|
||||
if (!request.current() || recover(error)) return;
|
||||
if (error?.status === 422 && error.code === 'response-limit' && error.recovery?.action === 'smaller-limit' && Number.isSafeInteger(error.recovery.limit) && error.recovery.limit >= 1 && error.recovery.limit < limit) limit = error.recovery.limit;
|
||||
failedOutput = {receipt:{...occurrence}, limit};
|
||||
emit({outputs:{...previous, loading:false, error}});
|
||||
}
|
||||
}
|
||||
async function revalidate() {
|
||||
if (disposed || !state.window.history_epoch || state.refreshRequired) return;
|
||||
const request = begin('validation'), epoch = state.window.history_epoch, runId = state.selection.run_id;
|
||||
cancel('window'); cancel('run'); cancel('outputs');
|
||||
emit({validating:true, window:{...state.window, loading:false}, run:{...state.run, loading:false}, outputs:{...state.outputs, loading:false}});
|
||||
try {
|
||||
const value = await api.get(runId ? `/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}` : `/prompt_manager/library/${positiveId}/history?limit=40`, {signal:request.signal});
|
||||
if (!request.current()) return;
|
||||
if (runId) validateDetail(value, runId, epoch); else validateWindow(value, positiveId, null, epoch);
|
||||
const hadOutputs = state.outputs.capture !== null;
|
||||
emit({validating:false, ...(runId ? {run:{detail:structuredClone(value), loading:false, error:null}} : {}),
|
||||
...(hadOutputs ? {outputs:clearOutputs(), selection:{...state.selection, receipt:null}} : {})});
|
||||
if (hadOutputs) return fetchOutputs(null, 40);
|
||||
} catch (error) {if (request.current() && !recover(error)) emit({validating:false, refreshRequired:'revalidation-failed', refreshLabel:'Reload recorded history', window:{...state.window, error}});}
|
||||
}
|
||||
return {
|
||||
get state() {return state;}, load:() => loadHistory(true), refresh:() => {restoreRange = null;return loadHistory();}, loadEarlier:() => fetchWindow(true), selectRun,
|
||||
selectContext(anchorId) {
|
||||
if (disposed) return;
|
||||
requireValue(node(anchorId), 'Invalid context anchor'); const c = state.run.detail?.contexts.find(c => c.anchor_id === anchorId); requireValue(!!c, 'Recorded context is unavailable');
|
||||
cancelDependentRead(); emit({selection:{...state.selection, anchor_id:c.anchor_id, context_id:c.context_id}});
|
||||
},
|
||||
setMode(mode) {if (disposed) return; requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); if (mode === state.selection.mode) return; cancelDependentRead(); emit({selection:{...state.selection, mode}});},
|
||||
loadOutputs() {if (disposed || state.outputs.loading || (state.outputs.capture !== null && !state.outputs.has_more)) return; return fetchOutputs(state.outputs.next_cursor, 40);},
|
||||
selectReceipt(occurrence) {
|
||||
if (disposed) return;
|
||||
const key = occurrenceKey(occurrence); requireValue(!state.refreshRequired && !state.validating && state.outputs.items.some(r => occurrenceKey(r.occurrence) === key), 'Reported output is unavailable');
|
||||
cancel('outputs'); emit({outputs:{...state.outputs, loading:false}, selection:{...state.selection, receipt:{...occurrence}}});
|
||||
},
|
||||
retryRun() {if (!disposed && !state.refreshRequired && runOptions) return selectRun(runOptions.runId, runOptions);},
|
||||
retryReceipt,
|
||||
retryOutputs() {if (!disposed && !state.refreshRequired && failedOutput) return failedOutput.receipt ? retryReceipt(failedOutput.receipt) : fetchOutputs(failedOutput.cursor, failedOutput.limit);},
|
||||
inspectionFailed(target, error) {
|
||||
if (disposed || state.validating || state.refreshRequired || !error?.error || !error.refreshRequired || !state.selection.receipt) return;
|
||||
const selected = occurrenceKey(state.selection.receipt), candidate = state.outputs.items.find(item => occurrenceKey(item.occurrence) === selected)?.current_candidate;
|
||||
if (candidate?.status !== 'available' || occurrenceKey(target.occurrence) !== selected || target.history_epoch !== state.window.history_epoch ||
|
||||
target.current_scope !== candidate.media.current_scope || target.location_key !== candidate.media.location_key) return;
|
||||
recover({status:409, code:error.code, recovery:error.recovery});
|
||||
},
|
||||
revalidate,
|
||||
dispose() {if (disposed) return; disposed = true; Object.keys(requests).forEach(cancel); failedOutput = null; runOptions = null; state = freeze({...state, ...clearDependents(), window:emptyWindow(), parent:{detail:null, loading:false, error:null}, actionsEnabled:false});},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
|
||||
import {mountRecordedRun} from './recorded-run.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function preview(summary) {
|
||||
const raw = summary.negative_raw;
|
||||
return raw.state === 'unknown' ? 'Negative unknown' : raw.state === 'explicit_empty' ? 'Explicitly empty negative' : `${raw.preview}${raw.truncated ? '…' : ''}`;
|
||||
}
|
||||
function distinction(summary) {
|
||||
const sources = [...new Set(summary.negative_raw.evidence.map(e => e.source))];
|
||||
return `Frozen composition · ${sources.join(', ') || 'unknown evidence'}`;
|
||||
}
|
||||
function observedLabel(value) {
|
||||
const time = Number(value);
|
||||
return `Observed dequeue ${value} ms (${Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'outside display range'})`;
|
||||
}
|
||||
|
||||
// New nodes use ordinary insertion; connected moves preserve browser state where supported.
|
||||
function place(parent, node, before) {
|
||||
if (node === before || (node.parentNode === parent && node.nextSibling === before)) return;
|
||||
if (typeof parent.moveBefore === 'function' && parent.isConnected && node.isConnected && parent.ownerDocument === node.ownerDocument) parent.moveBefore(node, before);
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
const section = el('section', undefined, 'library-surface recorded-history');
|
||||
section.setAttribute('aria-label', 'Recorded executions');
|
||||
section.append(el('h2', 'Recorded executions'), el('p', 'same positive raw text · frozen contexts can differ in composition and evidence.', 'muted'));
|
||||
const count = el('p', '', 'history-count muted');count.setAttribute('role', 'status');
|
||||
const disclosure = el('details', undefined, 'history-scope');disclosure.append(el('summary', 'Recorded history scope'));
|
||||
const scopeText = el('p', '', 'muted');disclosure.append(scopeText);
|
||||
const toolbar = el('div', undefined, 'history-toolbar');
|
||||
const earlier = createButton('Load earlier runs'), refresh = createButton('Refresh history'), retry = createButton('Retry recorded history');
|
||||
const reload = createButton('Reload recorded history'), selected = createButton('View selected run');selected.hidden = true;
|
||||
toolbar.append(earlier, refresh, retry, reload, selected);
|
||||
const status = el('p', '', 'history-status muted');status.setAttribute('role', 'status');
|
||||
const timeline = el('div', undefined, 'history-timeline');timeline.setAttribute('aria-label', 'Chronological branch timeline');
|
||||
const canvas = el('div', undefined, 'history-canvas');
|
||||
const edge = el('span', undefined, 'history-earlier-edge');edge.setAttribute('aria-hidden', 'true');
|
||||
const positive = el('div', undefined, 'history-positive-lane');positive.append(el('span', 'Positive raw text · unchanged'));positive.setAttribute('aria-hidden', 'true');
|
||||
const branches = el('div', undefined, 'history-context-branches');branches.setAttribute('aria-hidden', 'true');
|
||||
const positions = el('ol', undefined, 'history-run-list');positions.setAttribute('aria-label', 'Recorded run positions');
|
||||
canvas.append(edge, positive, branches, positions);timeline.append(canvas);
|
||||
section.append(count, disclosure, toolbar, status, timeline, el('p', 'Green + positive · red − negative · small dot repeated frozen context. Colors identify prompt roles.', 'history-legend muted'));
|
||||
container.append(section);
|
||||
let state, runView, frame = 0, observer, items = null, summaries = [], indexes = new Map(), contexts = new Map();
|
||||
let initialized = false, focusedRun = null, activeParent = null;
|
||||
let restorePosition = typeof visit.resumeCursor === 'string' && Number.isFinite(visit.scrollLeft) && visit.scrollLeft >= 0;
|
||||
const columns = new Map(), branchNodes = new Map();
|
||||
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
|
||||
render(next);runView?.setState(next);
|
||||
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
|
||||
}});
|
||||
state = controller.state;
|
||||
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
|
||||
|
||||
function restoreMarker(selection) {
|
||||
const index = indexes.get(selection?.runId || state.selection.run_id);
|
||||
if (index === undefined) {refresh.focus({preventScroll:true});return;}
|
||||
focusedRun = items[index].run_id;renderColumns();
|
||||
const column = columns.get(focusedRun);
|
||||
const button = [...column.node.querySelectorAll('button')].find(node => node.dataset.contextId === (selection?.contextId || state.selection.context_id)) || column.node.querySelector('button');
|
||||
button?.focus({preventScroll:true});
|
||||
}
|
||||
function captureAnchor() {
|
||||
const bounds = timeline.getBoundingClientRect(), active = document.activeElement;
|
||||
let button = timeline.contains(active) && active.matches('[data-run-id]') ? active : null;
|
||||
if (!button) for (const column of columns.values()) {
|
||||
const box = column.node.getBoundingClientRect();
|
||||
if (box.right > bounds.left + 1 && box.left < bounds.right) {button = column.node.querySelector('button');break;}
|
||||
}
|
||||
if (!button) return null;
|
||||
const box = button.getBoundingClientRect();
|
||||
return {run:button.dataset.runId, context:button.dataset.contextId, x:box.left - bounds.left, y:box.top - bounds.top};
|
||||
}
|
||||
function restoreAnchor(anchor) {
|
||||
const column = columns.get(anchor?.run);
|
||||
const button = column && [...column.buttons.values()].find(node => node.dataset.contextId === anchor.context);
|
||||
if (!button) return;
|
||||
const box = button.getBoundingClientRect(), bounds = timeline.getBoundingClientRect();
|
||||
timeline.scrollLeft += box.left - bounds.left - anchor.x;
|
||||
timeline.scrollTop += box.top - bounds.top - anchor.y;
|
||||
}
|
||||
function columnFor(run) {
|
||||
const node = el('li', undefined, 'history-run-column');node.dataset.historyRun = run.run_id;
|
||||
const position = el('span', '', 'history-position');position.setAttribute('aria-hidden', 'true');node.append(position);
|
||||
return {node, position, buttons:new Map()};
|
||||
}
|
||||
function renderColumns() {
|
||||
if (scope.signal.aborted || !items) return;
|
||||
const active = document.activeElement;
|
||||
const focused = indexes.get(timeline.contains(active) ? active.closest('[data-history-run]')?.dataset.historyRun : focusedRun || state.selection.run_id) ?? -1;
|
||||
const visible = visibleHistoryColumns(items.length, Math.max(0, timeline.scrollLeft - 38), timeline.clientWidth, focused);
|
||||
const keep = new Set(visible.map(index => items[index].run_id));
|
||||
for (const [id, column] of columns) if (!keep.has(id)) {column.node.remove();columns.delete(id);}
|
||||
let before = positions.firstChild;
|
||||
for (const index of visible) {
|
||||
const run = items[index];let column = columns.get(run.run_id);
|
||||
if (!column) {column = columnFor(run);columns.set(run.run_id, column);}
|
||||
const {node, buttons} = column;
|
||||
node.style.insetInlineStart = `${38 + index * 64}px`;node.value = index + 1;
|
||||
node.setAttribute('aria-posinset', String(index + 1));node.setAttribute('aria-setsize', String(items.length));
|
||||
column.position.textContent = String(index + 1);
|
||||
const anchors = new Map();for (const anchor of run.matching_anchors) if (!anchors.has(anchor.context_id)) anchors.set(anchor.context_id, anchor);
|
||||
for (const anchor of anchors.values()) {
|
||||
let button = buttons.get(anchor.context_id);
|
||||
if (!button) {
|
||||
button = createButton('', 'history-run-marker');button.dataset.runId = run.run_id;button.dataset.anchorId = anchor.anchor_id;button.dataset.contextId = anchor.context_id;
|
||||
buttons.set(anchor.context_id, button);node.append(button);
|
||||
}
|
||||
const context = contexts.get(anchor.context_id), first = context.first === index;
|
||||
button.classList.toggle('history-context-anchor', first);
|
||||
button.style.insetBlockStart = `${80 + context.row * 57}px`;
|
||||
button.textContent = first ? '−' : '';
|
||||
const label = `Recorded run ${index + 1} of ${items.length} loaded · ${observedLabel(run.observed_dequeue_at)} · Outcome ${run.outcome} · ${preview(context.summary)} · context ${anchor.context_id.slice(0, 8)} · anchor ${anchor.anchor_id}`;
|
||||
button.setAttribute('aria-label', label);button.title = label;
|
||||
button.setAttribute('aria-pressed', String(state.selection.run_id === run.run_id && state.selection.context_id === anchor.context_id));
|
||||
// Disabling a focused native button drops focus during a prepend read.
|
||||
button.setAttribute('aria-disabled', String(!state.actionsEnabled));
|
||||
}
|
||||
place(positions, node, before);before = node.nextSibling;
|
||||
}
|
||||
if (timeline.contains(active) && active.isConnected && document.activeElement !== active) active.focus({preventScroll:true});
|
||||
}
|
||||
function render(next) {
|
||||
if (scope.signal.aborted) return;
|
||||
const anchor = initialized && items !== next.window.items ? captureAnchor() : null;
|
||||
state = next;
|
||||
const window = next.window;
|
||||
if (items !== window.items) {
|
||||
items = window.items;indexes = new Map(items.map((run, index) => [run.run_id, index]));
|
||||
const firsts = new Map();
|
||||
for (const [index, run] of items.entries()) for (const anchor of run.matching_anchors) if (!firsts.has(anchor.context_id)) firsts.set(anchor.context_id, index);
|
||||
summaries = [...window.context_summaries].sort((a, b) => firsts.get(a.context_id) - firsts.get(b.context_id) || (a.context_id < b.context_id ? -1 : 1));
|
||||
contexts = new Map(summaries.map((summary, row) => [summary.context_id, {summary, row, first:firsts.get(summary.context_id)}]));
|
||||
canvas.style.inlineSize = `${Math.max(520, 76 + items.length * 64)}px`;
|
||||
canvas.style.blockSize = `${Math.max(150, 115 + summaries.length * 57)}px`;
|
||||
const keep = new Set(contexts.keys());
|
||||
for (const [id, node] of branchNodes) if (!keep.has(id)) {node.remove();branchNodes.delete(id);}
|
||||
let before = branches.firstChild;
|
||||
for (const [id, context] of contexts) {
|
||||
let branch = branchNodes.get(id);
|
||||
if (!branch) {branch = el('div', undefined, 'history-context-branch');branch.dataset.contextBranch = id;branch.append(el('span', '', 'history-context-label'));branchNodes.set(id, branch);}
|
||||
branch.style.insetBlockStart = `${80 + context.row * 57}px`;branch.style.insetInlineStart = `${38 + context.first * 64}px`;
|
||||
branch.firstChild.textContent = `${preview(context.summary)} · ${id.slice(0, 8)} · ${distinction(context.summary)}`;
|
||||
place(branches, branch, before);before = branch.nextSibling;
|
||||
}
|
||||
if (anchor) {
|
||||
const index = indexes.get(anchor.run);
|
||||
if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x);
|
||||
}
|
||||
}
|
||||
count.textContent = window.group ? `${window.recorded_run_count} distinct recorded runs as of this read · ${window.items.length} loaded positions` : '';
|
||||
const group = window.group;
|
||||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||||
status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status');
|
||||
status.textContent = next.validating ? 'Revalidating recorded history…' : window.loading ? 'Loading recorded history…' : window.error ? 'Recorded history could not be loaded.' : next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : !items.length ? 'No matching recorded executions.' : window.has_more ? 'Newest bounded window · displayed in chronological order.' : 'All matching recorded runs in this window are loaded.';
|
||||
if (next.run.error && !next.selection.run_id && !next.refreshRequired) status.textContent += ' The previous recorded selection could not be restored.';
|
||||
const outside = !!next.selection.run_id && !indexes.has(next.selection.run_id);
|
||||
if (outside && !next.validating && !next.refreshRequired) status.textContent += ` Selected run ${next.selection.run_id} is outside this loaded timeline window.`;
|
||||
selected.hidden = !outside;selected.disabled = next.validating || window.loading || !!next.refreshRequired;
|
||||
earlier.hidden = !window.has_more;earlier.disabled = window.loading || !!next.refreshRequired;earlier.setAttribute('aria-disabled', String(earlier.disabled));
|
||||
refresh.disabled = window.loading || next.validating;retry.hidden = !window.error || !!next.refreshRequired;
|
||||
reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||||
timeline.hidden = !items.length;
|
||||
let initialAnchor = null;
|
||||
if (!initialized && items.length && !window.loading && !next.validating && !next.refreshRequired) {
|
||||
timeline.scrollLeft = restorePosition ? state.intent.scrollLeft : canvas.offsetWidth;
|
||||
timeline.scrollTop = restorePosition ? state.intent.scrollTop : 0;
|
||||
if (restorePosition && state.intent.anchor) initialAnchor = {run:state.intent.anchor.runId, context:state.intent.anchor.contextId, x:state.intent.anchor.x, y:state.intent.anchor.y};
|
||||
initialized = true;restorePosition = false;
|
||||
}
|
||||
if (next.refreshRequired === 'history-changed') {initialized = false;restorePosition = false;}
|
||||
renderColumns();if (initialAnchor || anchor) restoreAnchor(initialAnchor || anchor);
|
||||
}
|
||||
const schedule = () => {if (!frame) frame = requestAnimationFrame(() => {frame = 0;renderColumns();});};
|
||||
scope.listen(timeline, 'scroll', schedule);scope.listen(window, 'resize', schedule);
|
||||
scope.listen(positions, 'focusin', event => {focusedRun = event.target.closest('[data-history-run]')?.dataset.historyRun || focusedRun;});
|
||||
scope.listen(positions, 'click', event => {
|
||||
const button = event.target.closest('button[data-run-id]');if (!button || !state.actionsEnabled) return;
|
||||
runView.open({runId:button.dataset.runId, anchorId:button.dataset.anchorId, contextId:button.dataset.contextId, trigger:button});
|
||||
});
|
||||
scope.listen(positions, 'keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !state.actionsEnabled) return;
|
||||
const current = event.target.closest('[data-history-run]');if (!current) return;
|
||||
const index = indexes.get(current.dataset.historyRun), next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : index + (event.key === 'ArrowLeft' ? -1 : 1);
|
||||
if (next < 0 || next >= items.length) return;
|
||||
event.preventDefault();focusedRun = items[next].run_id;renderColumns();
|
||||
const button = columns.get(focusedRun).node.querySelector('button');button.focus({preventScroll:true});button.scrollIntoView({block:'nearest', inline:'nearest'});renderColumns();
|
||||
});
|
||||
scope.listen(earlier, 'click', () => controller.loadEarlier());scope.listen(refresh, 'click', () => {initialized = false;restorePosition = false;controller.refresh();});
|
||||
scope.listen(selected, 'click', () => {
|
||||
if (!selected.disabled && state.selection.run_id) runView.open({runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, trigger:selected});
|
||||
});
|
||||
scope.listen(retry, 'click', () => controller.load());scope.listen(reload, 'click', () => state.refreshRequired === 'history-changed' || state.refreshRequired === 'revalidation-failed' ? controller.refresh() : controller.loadOutputs());
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
observer = new IntersectionObserver(entries => {if (entries.some(entry => entry.isIntersecting) && state.actionsEnabled && state.window.has_more) controller.loadEarlier();}, {root:timeline});observer.observe(edge);
|
||||
}
|
||||
scope.listen(window, 'pagehide', dispose);
|
||||
scope.cleanup(() => {
|
||||
const anchor = captureAnchor();
|
||||
Object.assign(visit, {mode:state.selection.mode, runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id,
|
||||
historyEpoch:state.window.history_epoch, resumeCursor:state.window.resume_cursor, loadedCount:state.window.items.length,
|
||||
outputCount:state.outputs.items.length, outputsLoaded:state.outputs.capture !== null, receipt:state.selection.receipt && {...state.selection.receipt},
|
||||
scrollLeft:timeline.scrollLeft, scrollTop:timeline.scrollTop, anchor:anchor && {runId:anchor.run, contextId:anchor.context, x:anchor.x, y:anchor.y}});
|
||||
observer?.disconnect();cancelAnimationFrame(frame);runView.dispose();controller.dispose();columns.clear();branchNodes.clear();section.remove();
|
||||
});
|
||||
render(state);
|
||||
void controller.load();
|
||||
return {dispose};
|
||||
}
|
||||
@@ -151,10 +151,16 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
|
||||
if (!legacy && result.inspection_version !== 1) throw new TypeError('Unsupported inspection version');
|
||||
if (!legacy && result.success !== true) throw new TypeError('Invalid inspection success status');
|
||||
if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity');
|
||||
return projectInspectionPayload(result,
|
||||
{ kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
|
||||
downloadFor(image, !legacy || scope !== null), extractFacts, legacy);
|
||||
}
|
||||
|
||||
/** Shared bounded payload projection; adapters validate identity before entering here. */
|
||||
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) {
|
||||
const record = {
|
||||
identity: { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
|
||||
file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
|
||||
generation: unavailableFacts(), historyTarget: null, download: downloadFor(image, !legacy || scope !== null),
|
||||
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
|
||||
generation: unavailableFacts(), historyTarget: null, download,
|
||||
};
|
||||
if (legacy) return record;
|
||||
const resolution = result.resolution;
|
||||
@@ -170,7 +176,12 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
|
||||
return record;
|
||||
}
|
||||
|
||||
export function createInspectionLoader(api, onChange, { extractFacts } = {}) {
|
||||
export const rootedFileAdapter = Object.freeze({
|
||||
validate: validateIdentity, key: imageKey, scope: descriptorScope,
|
||||
inspectionPath: inspectPath, originalDownload: originalMediaDownload, toRecord: toInspectionRecord,
|
||||
});
|
||||
|
||||
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
|
||||
let request = null, disposed = false;
|
||||
return {
|
||||
async load(image) {
|
||||
@@ -179,24 +190,27 @@ export function createInspectionLoader(api, onChange, { extractFacts } = {}) {
|
||||
const current = request = new AbortController();
|
||||
onChange({ loading: true });
|
||||
try {
|
||||
const result = await api.get(inspectPath(image), { signal: current.signal });
|
||||
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
|
||||
if (disposed || request !== current) return;
|
||||
const record = toInspectionRecord(result, image, extractFacts);
|
||||
const record = adapter.toRecord(result, image, extractFacts);
|
||||
onChange({ prompts: record.prompts, record });
|
||||
} catch (error) {
|
||||
if (disposed || request !== current) return;
|
||||
if (error?.status === 409 && error.code === 'source-changed') error = new InspectionSourceChangedError();
|
||||
const refreshRequired = error instanceof InspectionSourceChangedError;
|
||||
onChange({ error: true, refreshRequired, message: refreshRequired ? SOURCE_CHANGED : 'Could not read details from this file.' });
|
||||
const historyRecovery = adapter !== rootedFileAdapter && error?.status === 409 && ['history-changed', 'candidate-stale', 'capture-changed'].includes(error.code);
|
||||
const refreshRequired = error instanceof InspectionSourceChangedError || error?.refreshRequired === true || historyRecovery;
|
||||
onChange({ error: true, refreshRequired, code: error?.code ?? null, recovery: error?.recovery ?? null,
|
||||
message: error instanceof InspectionSourceChangedError ? SOURCE_CHANGED : refreshRequired ?
|
||||
error.code === 'history-changed' ? 'Recorded history changed. Reload recorded history.' : 'Current output locations changed. Reload reported outputs.' : 'Could not read details from this file.' });
|
||||
}
|
||||
},
|
||||
dispose() { disposed = true; request?.abort(); request = null; },
|
||||
};
|
||||
}
|
||||
|
||||
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false });
|
||||
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
|
||||
|
||||
export function createInspectionController({ api, extractFacts }) {
|
||||
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) {
|
||||
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
|
||||
let attempted, hasAttempt = false;
|
||||
const listeners = new Set();
|
||||
@@ -205,7 +219,7 @@ export function createInspectionController({ api, extractFacts }) {
|
||||
state = Object.freeze({ ...emptyState(), ...update });
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts });
|
||||
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
|
||||
let loader = makeLoader();
|
||||
function read(image) {
|
||||
pending = loader.load(image);
|
||||
@@ -217,14 +231,15 @@ export function createInspectionController({ api, extractFacts }) {
|
||||
if (disposed) return;
|
||||
let selected = null;
|
||||
try {
|
||||
const scope = validateIdentity(image), key = imageKey(image);
|
||||
if (current?.key === key && current.scope === scope) return pending;
|
||||
selected = { key, scope };
|
||||
adapter.validate(image);
|
||||
const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image);
|
||||
if (current?.key === key && current.scope === scope && current.path === path) return pending;
|
||||
selected = { key, scope, path };
|
||||
} catch {
|
||||
// The loader publishes validation errors after invalidating the previous source.
|
||||
}
|
||||
loader.dispose(); loader = makeLoader(); current = selected;
|
||||
attempted = object(image) ? { ...image } : image; hasAttempt = true;
|
||||
attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true;
|
||||
return read(attempted);
|
||||
},
|
||||
retry() {
|
||||
|
||||
@@ -46,3 +46,79 @@ main.library-main { --library-positive: #73dda9; }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
|
||||
|
||||
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
|
||||
|
||||
/* Role colors and branch geometry follow the approved history surface. */
|
||||
main.library-main { --library-negative: #ff8c98; }
|
||||
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
|
||||
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
|
||||
.library-main .library-branches { overflow-x: auto; position: relative; }
|
||||
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
|
||||
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
|
||||
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
|
||||
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
|
||||
.library-main .library-variants .library-branches { margin-block: 0; }
|
||||
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
|
||||
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
|
||||
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
|
||||
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
|
||||
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
|
||||
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
|
||||
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
|
||||
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
|
||||
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
|
||||
.history-scope summary { cursor: pointer; }
|
||||
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
|
||||
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; }
|
||||
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
|
||||
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
|
||||
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
|
||||
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
|
||||
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
|
||||
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); color: #38151b; font-size: 12px; }
|
||||
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
|
||||
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-border); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
|
||||
.recorded-run-dialog::backdrop { background: #0009; }
|
||||
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.recorded-run-header h2 { margin: 0; font-size: 20px; }
|
||||
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
|
||||
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
|
||||
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
|
||||
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
|
||||
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
|
||||
.recorded-provenance summary { cursor: pointer; }
|
||||
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
|
||||
.recorded-outputs { margin-block: 24px; }
|
||||
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
|
||||
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
|
||||
.recorded-receipt > div { margin-block-end: 10px; }
|
||||
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
|
||||
@media (max-width: 700px) {
|
||||
.library-main .library-surface { padding: 22px; }
|
||||
.recorded-run-dialog { padding: 16px; }
|
||||
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.recorded-history :where(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
|
||||
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
|
||||
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); }
|
||||
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import {mountImages} from './images.mjs';
|
||||
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
const variantSelections=new Map();
|
||||
const historyVisits=new Map();
|
||||
function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
@@ -242,40 +244,92 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text);
|
||||
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
|
||||
const summary=el('article',undefined,'library-surface');summary.append(el('span',detail.positive.category || 'Uncategorized','library-chip'));
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
|
||||
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
|
||||
contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
|
||||
const all=createButton('All images for this positive');
|
||||
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
|
||||
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
|
||||
const branches=el('div',undefined,'library-branches');branches.append(el('p','+ Positive prompt · unchanged','library-positive-lane'));
|
||||
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
const choices=[];
|
||||
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
|
||||
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
|
||||
const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true;
|
||||
contentScope.listen(more,'click',()=>loader.loadMore());contentScope.listen(retry,'click',()=>loader.loadMore());
|
||||
contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();});
|
||||
updateBranches=state=>{
|
||||
const known=new Set(choices.map(choice=>choice.variant.id));
|
||||
for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) {
|
||||
const lane=el('div',undefined,'library-variant-lane');const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
|
||||
button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id));
|
||||
lane.append(button,el('span',`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`));branches.append(lane);choices.push({button,variant});
|
||||
const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label});
|
||||
contentScope.listen(button,'click',()=>select(variant.id));
|
||||
}
|
||||
for(const [index,choice] of choices.entries()) {
|
||||
choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant;
|
||||
const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`;
|
||||
choice.button.disabled=savedReadState!=='current';
|
||||
}
|
||||
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants));retry.hidden=!state.variantsError;
|
||||
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError;
|
||||
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';
|
||||
variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':'';
|
||||
};
|
||||
updateBranches(state);
|
||||
if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted'));
|
||||
const note=el('p','Saved combinations, not an execution timeline.','muted');variants.append(branches,variantCount,variantError,more,retry,note,all);
|
||||
const note=el('p','Saved combinations, not an execution timeline.','muted');
|
||||
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
|
||||
variants.append(branches,variantError,more,retry,variantFooter);
|
||||
const context=el('p','','library-variant-context');context.setAttribute('role','status');
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;
|
||||
body.append(subtitle,summary,variants,gallery);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(subtitle,summary,variants,recorded,gallery);
|
||||
function setParentState(parent) {
|
||||
if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
|
||||
parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true;
|
||||
if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';}
|
||||
else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;}
|
||||
else {
|
||||
const fresh=parent.detail;
|
||||
if(fresh.positive.id!==positiveId || fresh.positive.text!==ownerText) {
|
||||
savedReadState='stale';parentStatus.textContent='Saved Library owner changed. This view retains the previous prompt and gallery. Reload the saved Library view to inspect current data.';reloadSaved.hidden=false;
|
||||
} else {
|
||||
const observed=new Map(fresh.variants.map(variant=>[variant.id,variant]));const loaded=new Set(detail.variants.map(variant=>variant.id));const additional=fresh.variants.some(variant=>!loaded.has(variant.id));let changed=false,unobserved=false;
|
||||
const variants=detail.variants.map(variant=>{
|
||||
const current=observed.get(variant.id);
|
||||
if(!current) {unobserved=true;return variant;}
|
||||
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
|
||||
return {...variant,...current};
|
||||
});
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
|
||||
savedReadState=changed || unobserved || additional?'stale':'current';
|
||||
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
|
||||
reloadSaved.hidden=savedReadState==='current';
|
||||
}
|
||||
}
|
||||
updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});
|
||||
}
|
||||
async function retrySavedParent() {
|
||||
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
|
||||
try {
|
||||
const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal});
|
||||
if(contentScope.signal.aborted || generation!==parentGeneration) return;
|
||||
if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details');
|
||||
setParentState({detail:fresh});
|
||||
} catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});}
|
||||
}
|
||||
contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
}});
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope});
|
||||
function select(pairId) {
|
||||
if(galleryScope && activePairId===pairId) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.update(libraryPromptRoles(detail,pairId));
|
||||
if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId;
|
||||
variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true});
|
||||
for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`;
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createViewerSession} from './viewer-state.mjs';
|
||||
import {originalMediaDownload} from './inspection-state.mjs';
|
||||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
|
||||
let viewerId = 0;
|
||||
@@ -17,7 +17,7 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose}) {
|
||||
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
const scope = createScope();
|
||||
let active = null, retryVersion = 0;
|
||||
const dispose = () => scope.dispose();
|
||||
@@ -33,7 +33,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
const {items,selectedKey,trigger = document.activeElement} = options;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
|
||||
let originalDownload = null, inspectionState = inspectionController.state;
|
||||
const session = createViewerSession({items,initialKey:selectedKey,onChange:(state,selected) => {
|
||||
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
|
||||
if (!mounted) { initial = [state,selected]; return; }
|
||||
update(state,selected);
|
||||
}});
|
||||
@@ -109,7 +109,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
const mediaLocal = mediaScope, key = session.state.key;
|
||||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||||
let original;
|
||||
try { original = originalMediaDownload(selected); }
|
||||
try { original = adapter.originalDownload(selected); }
|
||||
catch { session.failed(key);return; }
|
||||
const url = new URL(original.url,window.location.href);
|
||||
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
|
||||
@@ -193,7 +193,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
if (position.textContent !== label) position.textContent = label;
|
||||
if (currentKey !== state.key) {
|
||||
currentKey = state.key;title.textContent = entry.filename;
|
||||
try { originalDownload = originalMediaDownload(entry); } catch { originalDownload = null; }
|
||||
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
|
||||
onSelect?.(entry);
|
||||
rebuild(entry);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ const statuses = {
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
const fieldSources = {
|
||||
runtime_return: 'Runtime return',
|
||||
host_cache_output: 'Host cache output',
|
||||
graph_literal: 'Inferred graph literal',
|
||||
graph_semantics: 'Inferred graph semantics',
|
||||
};
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -39,6 +45,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
scope.listen(container, 'click', async event => {
|
||||
if (!actionsEnabled) return;
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
const role = send.dataset.sendRole;
|
||||
@@ -57,10 +64,11 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||||
});
|
||||
let roles = {};
|
||||
let roles = {}, actionsEnabled = true;
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
roles = {};
|
||||
actionsEnabled = state.actionsEnabled !== false;
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
|
||||
@@ -83,7 +91,8 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
heading.append(el('h3', title));
|
||||
block.append(heading);
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
const proof = record.evidence;
|
||||
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
@@ -93,11 +102,13 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${role} prompt`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${role} prompt`);
|
||||
copy.title = `Copy ${role} prompt`;
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
actions.append(copy, send);
|
||||
heading.append(actions);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
@@ -107,6 +118,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
pair.disabled = !actionsEnabled;
|
||||
container.append(pair);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {frozenPromptRoles} from './history-state.mjs';
|
||||
import {mountPromptBlocks} from './prompt-blocks.mjs';
|
||||
import {createInspectionController} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {createMediaViewer} from './media-viewer.mjs';
|
||||
import {distinctCurrentMedia, occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function facts(entries, values) {
|
||||
const list = el('dl', undefined, 'recorded-run-facts');
|
||||
for (const [label, value] of entries) {const row = el('div'), description = el('dd', value ?? 'Not recorded');row.append(el('dt', label), description);list.append(row);values.set(label, description);}
|
||||
return list;
|
||||
}
|
||||
function observed(value) {
|
||||
const time = Number(value);
|
||||
return Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'Recorded time outside display range';
|
||||
}
|
||||
function outputSummary(summary) {
|
||||
if (summary.state === 'not_captured') return 'Reported outputs not captured';
|
||||
const count = summary.retained_occurrence_count;
|
||||
return summary.list_status === 'complete' ? `${count} reported outputs · complete` : `${count} retained reported output${count === 1 ? '' : 's'} · incomplete · ${summary.state}`;
|
||||
}
|
||||
|
||||
export function mountRecordedRun(container, {api, controller, sender, parentScope, onClose, onRestoreFocus}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
let active = null, latest = controller.state;
|
||||
if (parentScope.signal.aborted) {dispose();return {open() {}, setState() {}, dispose};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
scope.cleanup(() => active?.close(false));
|
||||
|
||||
function open({runId, anchorId = null, contextId = null, trigger = document.activeElement}) {
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const local = createScope(), dialog = el('dialog', undefined, 'recorded-run-dialog');dialog.setAttribute('aria-label', 'Recorded run');
|
||||
const header = el('header', undefined, 'recorded-run-header');
|
||||
const close = createButton('×');close.setAttribute('aria-label', 'Close recorded run');close.autofocus = true;
|
||||
header.append(el('h2', 'Recorded run'), close);
|
||||
const message = el('p', '', 'recorded-run-status muted');message.setAttribute('role', 'status');
|
||||
const retry = createButton('Retry run details'), reload = createButton('Reload recorded history');
|
||||
const body = el('div', undefined, 'recorded-run-body');
|
||||
const metadata = el('section');metadata.setAttribute('aria-label', 'Run evidence');
|
||||
const anchors = el('div', undefined, 'recorded-run-anchors');anchors.setAttribute('role', 'group');anchors.setAttribute('aria-label', 'Recorded anchors');
|
||||
const mode = el('div', undefined, 'recorded-run-mode');mode.setAttribute('role', 'group');mode.setAttribute('aria-label', 'Frozen text field');
|
||||
const raw = createButton('Raw'), effective = createButton('Effective');mode.append(raw, effective);
|
||||
const promptSection = el('section');promptSection.setAttribute('aria-label', 'Frozen run prompt texts');
|
||||
promptSection.append(el('h3', 'Frozen prompt fields'), el('p', 'Only the selected stored field is shown. Role assignment: inferred. Field evidence is disclosed separately.', 'muted'));
|
||||
const prompts = el('div');promptSection.append(prompts);
|
||||
const provenance = el('details', undefined, 'recorded-provenance');provenance.append(el('summary', 'Frozen field provenance'));
|
||||
const proof = el('div');provenance.append(proof);promptSection.append(provenance);
|
||||
const outputs = el('section', undefined, 'recorded-outputs');outputs.setAttribute('aria-label', 'Reported outputs');
|
||||
const outputState = el('p', '', 'recorded-output-status muted');outputState.setAttribute('role', 'status');
|
||||
const receipts = el('ol', undefined, 'recorded-receipts');receipts.setAttribute('aria-label', 'Ordered reported output receipts');
|
||||
const more = createButton('Load more reported outputs'), retryOutputs = createButton('Retry reported outputs');
|
||||
outputs.append(el('h3', 'Reported outputs'), el('p', 'Host-reported locations · file identity unverified · newly generated unknown · Context membership unknown.', 'muted'), outputState, receipts, more, retryOutputs);
|
||||
const current = el('section', undefined, 'recorded-current-file');current.setAttribute('aria-label', 'Current reported file');
|
||||
current.append(el('h3', 'Current file at reported location'), el('p', 'Current file prompts and facts are separate from frozen run fields.', 'muted'));
|
||||
const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true;
|
||||
body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog);
|
||||
const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter});
|
||||
let closed = false, renderedDetail = null, renderedRoles = '', initialOutputQueued = false, inspected = null, viewerOpen = false;
|
||||
const receiptNodes = new Map();
|
||||
const evidenceValues = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
}
|
||||
function restoreReceiptFocus() {
|
||||
const key = latest.selection.receipt && occurrenceKey(latest.selection.receipt);
|
||||
const row = receiptNodes.get(key);(row?.view.hidden ? row.select : row?.view)?.focus({preventScroll:true});
|
||||
}
|
||||
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
|
||||
onSelect:entry => {
|
||||
if (closed || !latest.actionsEnabled) return;
|
||||
inspected = occurrenceKey(entry.occurrence);controller.selectReceipt(entry.occurrence);inspection.select(entry);
|
||||
}, onRefresh:refreshCurrent, onRestoreFocus:restoreReceiptFocus,
|
||||
onClose:() => {viewerOpen = false;restoreReceiptFocus();},
|
||||
});
|
||||
let viewer = makeViewer();
|
||||
function resetViewer() {viewer.dispose();viewerOpen = false;viewer = makeViewer();}
|
||||
function refreshCurrent() {
|
||||
if (closed) return;
|
||||
resetViewer();inspection.clear();inspected = null;
|
||||
if (latest.refreshRequired === 'history-changed') controller.refresh();
|
||||
else controller.revalidate();
|
||||
}
|
||||
function showViewer(triggerButton) {
|
||||
if (!latest.actionsEnabled || inspection.state.refreshRequired || closed) return;
|
||||
const receipt = selectedReceipt();if (receipt?.current_candidate.status !== 'available') return;
|
||||
const items = distinctCurrentMedia(latest.outputs.items, latest.selection.receipt);
|
||||
viewerOpen = true;viewer.open({items, selectedKey:reportedLocationAdapter.key(receipt.current_candidate.media), trigger:triggerButton || receiptNodes.get(occurrenceKey(receipt.occurrence))?.view});
|
||||
}
|
||||
function chooseReceipt(receipt, triggerButton) {
|
||||
if (!latest.actionsEnabled || closed) return;
|
||||
inspected = occurrenceKey(receipt.occurrence);controller.selectReceipt(receipt.occurrence);
|
||||
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
|
||||
}
|
||||
function selectReceipt(receipt) {
|
||||
if (!latest.actionsEnabled || closed) return;
|
||||
inspected = null;controller.selectReceipt(receipt.occurrence);inspection.clear();if (viewerOpen) resetViewer();
|
||||
}
|
||||
function closeRun(restore = true) {
|
||||
if (closed) return;closed = true;active = null;local.dispose();
|
||||
if (restore && !scope.signal.aborted) {
|
||||
if (trigger?.isConnected) trigger.focus({preventScroll:true});
|
||||
else onRestoreFocus?.({runId, anchorId, contextId});
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
local.cleanup(() => {viewer.dispose();inspection.dispose();inspector.dispose();blocks.dispose();receiptNodes.clear();if (dialog.open) dialog.close();dialog.remove();});
|
||||
local.listen(close, 'click', () => closeRun());local.listen(dialog, 'close', () => closeRun());
|
||||
local.listen(dialog, 'cancel', event => {event.preventDefault();closeRun();});
|
||||
local.listen(retry, 'click', () => controller.retryRun());
|
||||
local.listen(reload, 'click', () => {
|
||||
if (['capture-changed', 'candidate-stale'].includes(latest.refreshRequired)) controller.loadOutputs();
|
||||
else {closeRun();controller.refresh();}
|
||||
});
|
||||
local.listen(raw, 'click', () => {if (latest.actionsEnabled) controller.setMode('raw');});
|
||||
local.listen(effective, 'click', () => {if (latest.actionsEnabled) controller.setMode('effective');});
|
||||
local.listen(anchors, 'click', event => {const button = event.target.closest('[data-select-anchor]');if (button && latest.actionsEnabled) controller.selectContext(button.dataset.selectAnchor);});
|
||||
local.listen(more, 'click', () => controller.loadOutputs());local.listen(retryOutputs, 'click', () => controller.retryOutputs());
|
||||
local.listen(receipts, 'click', event => {
|
||||
const button = event.target.closest('[data-view-receipt],[data-select-receipt],[data-retry-receipt]');if (!button || button.disabled) return;
|
||||
const key = button.dataset.viewReceipt ?? button.dataset.selectReceipt ?? button.dataset.retryReceipt;
|
||||
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === key);if (!receipt) return;
|
||||
if (button.hasAttribute('data-view-receipt')) chooseReceipt(receipt, button);
|
||||
else if (button.hasAttribute('data-select-receipt')) selectReceipt(receipt);
|
||||
else {selectReceipt(receipt);void controller.retryReceipt(receipt.occurrence);}
|
||||
});
|
||||
local.cleanup(inspection.subscribe(state => {
|
||||
if (closed) return;
|
||||
const target = selectedReceipt()?.current_candidate.media;
|
||||
if (target && inspected === occurrenceKey(target.occurrence) && state.error && state.refreshRequired) {
|
||||
controller.inspectionFailed(target, state);
|
||||
if (inspected === null || !latest.selection.receipt) return;
|
||||
}
|
||||
inspector.setState(state);
|
||||
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || latest.outputs.loading || state.refreshRequired;
|
||||
}));
|
||||
function render(next) {
|
||||
if (closed || local.signal.aborted) return;
|
||||
const selectedRun = next.selection.run_id === runId, detail = selectedRun ? next.run.detail : null;
|
||||
const enabled = next.actionsEnabled && selectedRun;
|
||||
message.setAttribute('role', next.run.error || next.refreshRequired ? 'alert' : 'status');
|
||||
message.textContent = next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : next.validating ? 'Revalidating recorded run…' : next.run.error && selectedRun ? 'Recorded run details could not be loaded.' : !detail ? 'Loading recorded run…' : '';
|
||||
retry.hidden = !selectedRun || !next.run.error || !!next.refreshRequired;reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||||
body.hidden = !detail;raw.disabled = effective.disabled = !enabled;
|
||||
if (!detail) {
|
||||
if (renderedDetail) {
|
||||
inspected = null;inspection.clear();resetViewer();blocks.setState({actionsEnabled:false});proof.replaceChildren();current.hidden = true;
|
||||
renderedDetail = null;renderedRoles = '';metadata.replaceChildren();anchors.replaceChildren();receipts.replaceChildren();receiptNodes.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
raw.setAttribute('aria-pressed', String(next.selection.mode === 'raw'));effective.setAttribute('aria-pressed', String(next.selection.mode === 'effective'));
|
||||
if (detail !== renderedDetail) {
|
||||
renderedDetail = detail;const run = detail.run;
|
||||
metadata.replaceChildren(facts([
|
||||
['Run ID', run.run_id], ['Session ID', run.session_id], ['Queue item ID', run.queue_item_id], ['Job / correlation ID', run.job_id],
|
||||
['Observed dequeue', observed(run.observed_dequeue_at)], ['Observed dequeue · exact decimal milliseconds', run.observed_dequeue_at],
|
||||
['Outcome', run.outcome], ['Outcome reason', run.reason], ['Context capture', `${detail.context_capture.state} · ${detail.context_capture.reason ?? 'not captured'}`],
|
||||
['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`],
|
||||
['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type],
|
||||
['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'],
|
||||
], evidenceValues));
|
||||
if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted'));
|
||||
anchors.replaceChildren();
|
||||
for (const context of detail.contexts) {
|
||||
const button = createButton(`Anchor ${context.anchor_id} · context ${context.context_id.slice(0, 8)}`);button.dataset.selectAnchor = context.anchor_id;anchors.append(button);
|
||||
}
|
||||
}
|
||||
for (const button of anchors.querySelectorAll('button')) {button.disabled = !enabled;button.setAttribute('aria-pressed', String(button.dataset.selectAnchor === next.selection.anchor_id));}
|
||||
const context = detail.contexts.find(item => item.anchor_id === next.selection.anchor_id);
|
||||
const roleKey = `${next.selection.anchor_id}:${next.selection.mode}:${enabled}:${detail.context_capture.state}`;
|
||||
if (roleKey !== renderedRoles) {
|
||||
renderedRoles = roleKey;
|
||||
blocks.setState({prompts:context ? frozenPromptRoles(context, next.selection.mode) : undefined, actionsEnabled:enabled});
|
||||
proof.replaceChildren(el('p', 'Role assignment: inferred. Sources describe each field, not blanket execution proof.', 'muted'));
|
||||
if (context) for (const role of ['positive', 'negative']) {
|
||||
const section = el('section');section.append(el('h4', `${role[0].toUpperCase() + role.slice(1)} fields`));
|
||||
for (const field of ['raw', 'effective', 'prepend', 'append']) {
|
||||
const value = context[role]?.[field] ?? null, evidence = context.evidence[role]?.[field];
|
||||
section.append(el('p', `${field[0].toUpperCase() + field.slice(1)} · ${evidence?.provenance || 'unknown'} · ${evidence?.source || 'unknown'}${evidence?.reason ? ` · reason: ${evidence.reason}` : ''}`, 'muted'));
|
||||
section.append(el('pre', value === null ? 'Unknown · not recorded' : value === '' ? '(explicitly empty)' : value, 'prompt-text'));
|
||||
}
|
||||
proof.append(section);
|
||||
}
|
||||
}
|
||||
const output = next.outputs;
|
||||
const outputEvidence = output.capture ? {...output.capture, retained_occurrence_count:output.retained_occurrence_count, list_status:output.list_status} : detail.output_summary;
|
||||
evidenceValues.get('Output capture').textContent = `${outputEvidence.state} · ${outputEvidence.reason ?? 'not captured'}`;
|
||||
outputState.setAttribute('role', output.error ? 'alert' : 'status');
|
||||
outputState.textContent = `${outputSummary(outputEvidence)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`;
|
||||
more.hidden = !output.has_more;more.disabled = output.loading || !enabled;
|
||||
retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired;
|
||||
const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence)));
|
||||
for (const [key, record] of receiptNodes) if (!keep.has(key)) {record.node.remove();receiptNodes.delete(key);}
|
||||
for (const receipt of output.items) {
|
||||
const key = occurrenceKey(receipt.occurrence), candidate = receipt.current_candidate;
|
||||
let record = receiptNodes.get(key);
|
||||
if (!record) {
|
||||
const node = el('li', undefined, 'recorded-receipt');node.dataset.receipt = key;node.dataset.outputNodeId = receipt.occurrence.output_node_id;node.dataset.imageOrdinal = String(receipt.occurrence.image_ordinal);
|
||||
const description = el('div'), select = createButton('Select reported output'), view = createButton('View current file'), retry = createButton('Retry current availability');
|
||||
select.dataset.selectReceipt = view.dataset.viewReceipt = retry.dataset.retryReceipt = key;
|
||||
select.title = `Select output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal}`;
|
||||
node.append(description, select, view, retry);receipts.append(node);record = {node, description, select, view, retry};receiptNodes.set(key, record);
|
||||
}
|
||||
const reference = receipt.reference;
|
||||
record.description.textContent = `Output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal} · ${reference.folder_type}/${[reference.subfolder, reference.filename].filter(Boolean).join('/')} · ${reference.path_style} · root namespace ${reference.root_key ?? 'not recorded'} · ${receipt.transient ? 'Transient' : 'Non-transient locator'} · capture ${output.capture?.state || detail.output_summary.state} · Current candidate: ${candidate.status}${candidate.reason ? ` · ${candidate.reason}` : ''}`;
|
||||
record.node.setAttribute('aria-current', String(next.selection.receipt !== null && key === occurrenceKey(next.selection.receipt)));
|
||||
record.select.setAttribute('aria-pressed', record.node.getAttribute('aria-current'));record.select.disabled = !enabled;
|
||||
record.retry.hidden = candidate.status === 'available';record.retry.disabled = !enabled || output.loading;
|
||||
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || output.loading || inspection.state.refreshRequired;
|
||||
}
|
||||
const selected = selectedReceipt(), selectedKey = selected && occurrenceKey(selected.occurrence);
|
||||
current.hidden = !selected || inspected !== selectedKey || selected.current_candidate.status !== 'available';
|
||||
if (inspected !== null && inspected !== selectedKey) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
|
||||
if (!enabled && viewerOpen) resetViewer();
|
||||
if (enabled && !initialOutputQueued && !output.capture && !output.loading && !output.error) {
|
||||
initialOutputQueued = true;
|
||||
queueMicrotask(() => {
|
||||
initialOutputQueued = false;
|
||||
if (!closed && !local.signal.aborted && latest.selection.run_id === runId && latest.actionsEnabled && latest.run.detail && !latest.run.loading && !latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error) controller.loadOutputs();
|
||||
});
|
||||
}
|
||||
}
|
||||
active = {close:closeRun, render};
|
||||
dialog.showModal();
|
||||
render(latest);
|
||||
const reusable = latest.actionsEnabled && latest.selection.run_id === runId && latest.run.detail && latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error;
|
||||
if (reusable) {
|
||||
const context = latest.run.detail.contexts.find(context => (anchorId === null || context.anchor_id === anchorId) && (contextId === null || context.context_id === contextId));
|
||||
if (context && context.anchor_id !== latest.selection.anchor_id) controller.selectContext(context.anchor_id);
|
||||
} else void controller.selectRun(runId, {anchorId, contextId});
|
||||
}
|
||||
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
|
||||
return {open, setState, dispose};
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import {imageKey} from './images-state.mjs';
|
||||
import {inspectPath} from './inspection-state.mjs';
|
||||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
|
||||
/** A viewer visits only the descriptors loaded by its opening query. */
|
||||
export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) {
|
||||
export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, adapter = rootedFileAdapter}) {
|
||||
if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required');
|
||||
const keys = new Set();
|
||||
const loaded = Object.freeze(items.map(item => {
|
||||
inspectPath(item);
|
||||
const key = imageKey(item);
|
||||
adapter.validate(item);
|
||||
const key = adapter.key(item);
|
||||
if (keys.has(key)) throw new TypeError('Duplicate viewer identity');
|
||||
keys.add(key);
|
||||
return Object.freeze({...item});
|
||||
return Object.freeze({...item, ...(adapter !== rootedFileAdapter && item.occurrence ? {occurrence:Object.freeze({...item.occurrence}), reference:Object.freeze({...item.reference})} : {})});
|
||||
}));
|
||||
const index = loaded.findIndex(item => imageKey(item) === initialKey);
|
||||
const index = loaded.findIndex(item => adapter.key(item) === initialKey);
|
||||
if (index < 0) throw new TypeError('Selected media is not loaded');
|
||||
let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false});
|
||||
let timer = null, deadline = 0;
|
||||
@@ -42,7 +41,7 @@ export function createViewerSession({items, initialKey, onChange = () => {}, set
|
||||
if (loaded.length === 1) return;
|
||||
cancel();
|
||||
const next = (state.index + direction + loaded.length) % loaded.length;
|
||||
publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
|
||||
publish({index:next,key:adapter.key(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
|
||||
}
|
||||
publish({});
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user