feat(history): add chronological runs and exact output inspection (#227)
* feat(history): add bounded chronological read projections (#209) * fix(history): validate internal continuation positions (#209) * feat(history): expose fenced history windows and run details (#209) * fix(history): release retired projection connections (#209) * feat(history): resolve reported outputs through current authorization (#209) * fix(history): reject invalid inspection targets (#209) * feat(history): add scoped history state and current-file adapter (#209) * fix(history): preserve slideshow and clear stale reentry actions (#209) * feat(history): render recorded executions and frozen run details (#209) * fix(history): recover receipts and refresh capture evidence (#209) * test(history): verify chronological history across restore and host modes (#209) * fix(history): enforce chronological marker visual parity (#209) * fix(history): resolve final chronological review findings Address #209 recovery, bounded visit restoration, exact receipt availability, and portable regression coverage. * test(history): repair Windows fixtures and review feedback Address #209 and PR #227 native path, cleanup, media and review-comment expectations. Document Windows portability preflight without changing production behavior. * ci: raise the viewerjs job budget for the larger browser suite The cross-browser workflows suite grew from 396 to 496 tests on this branch, which pushed the whole viewerjs job to about 25.5 minutes against the 25 minute cap set in #181. Two exact-head runs on this branch timed out without any test failing: run 39173019283 was killed on test 474 of 496, and run 39175125474 printed "496 passed (12.8m)" and was then cancelled during Playwright shutdown. Raise only the job budget. Browser projects, workers and per-test timeouts are unchanged. Splitting the browsers into parallel jobs is tracked separately.
This commit is contained in:
@@ -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