From ecd21165a78944dd1cd21369f4fefccd74bec2b2 Mon Sep 17 00:00:00 2001 From: Vito Date: Sun, 18 Oct 2026 17:17:05 -0700 Subject: [PATCH] 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. --- .github/workflows/viewerjs-check.yml | 2 +- TESTING.md | 153 +- __init__.py | 3 + database/execution_history_schema.py | 11 + database/history_gallery.py | 127 ++ database/history_reads.py | 527 +++++++ database/operations.py | 2 + py/api/__init__.py | 5 + py/api/history.py | 748 +++++++++ py/api/history_current.py | 399 +++++ py/api/images.py | 100 +- py/api/inspection_service.py | 107 ++ .../browser/history-canonical-comparison.mjs | 219 +++ tests/browser/server.py | 75 + tests/browser/workspace-history.spec.mjs | 661 ++++++++ tests/browser/workspace-library.spec.mjs | 14 + tests/browser/workspace-visual.spec.mjs | 109 ++ tests/browser/workspace_history_fixture.py | 538 +++++++ tests/comfyui/execution-history.spec.mjs | 77 + tests/comfyui/execution-outputs.spec.mjs | 18 + tests/js/api-client.test.js | 31 + tests/js/prompt-blocks.test.js | 20 + tests/js/workspace-file-inspector.test.js | 6 + tests/js/workspace-history-layout.test.js | 23 + tests/js/workspace-history-media.test.js | 41 + tests/js/workspace-history-state.test.js | 278 ++++ tests/js/workspace-inspection-state.test.js | 17 + tests/js/workspace-media-viewer.test.js | 126 ++ tests/js/workspace-viewer-state.test.js | 9 + tests/test_api_history.py | 1082 +++++++++++++ tests/test_api_history_outputs.py | 1334 +++++++++++++++++ tests/test_api_images.py | 2 +- tests/test_api_prompt_inspector.py | 4 +- tests/test_execution_history_restore.py | 35 + tests/test_history_current.py | 511 +++++++ tests/test_history_read_fence.py | 203 +++ tests/test_history_reads.py | 915 +++++++++++ tests/test_inspection_response.py | 42 + tests/vendor/comfyui-config.test.mjs | 4 +- tools/vendor/frontend-coverage-baseline.json | 212 +-- utils/history_read_fence.py | 80 + utils/inspection_response.py | 53 +- web/js/api-client.js | 18 +- web/js/workspace/file-inspector.mjs | 10 +- web/js/workspace/history-media.mjs | 91 ++ web/js/workspace/history-state.mjs | 416 +++++ web/js/workspace/history.mjs | 221 +++ web/js/workspace/inspection-state.mjs | 45 +- web/js/workspace/library.css | 76 + web/js/workspace/library.mjs | 74 +- web/js/workspace/media-viewer.mjs | 10 +- web/js/workspace/prompt-blocks.mjs | 16 +- web/js/workspace/recorded-run.mjs | 246 +++ web/js/workspace/viewer-state.mjs | 15 +- 54 files changed, 9929 insertions(+), 232 deletions(-) create mode 100644 database/history_gallery.py create mode 100644 database/history_reads.py create mode 100644 py/api/history.py create mode 100644 py/api/history_current.py create mode 100644 py/api/inspection_service.py create mode 100644 tests/browser/history-canonical-comparison.mjs create mode 100644 tests/browser/workspace-history.spec.mjs create mode 100644 tests/browser/workspace_history_fixture.py create mode 100644 tests/js/workspace-history-layout.test.js create mode 100644 tests/js/workspace-history-media.test.js create mode 100644 tests/js/workspace-history-state.test.js create mode 100644 tests/test_api_history.py create mode 100644 tests/test_api_history_outputs.py create mode 100644 tests/test_history_current.py create mode 100644 tests/test_history_read_fence.py create mode 100644 tests/test_history_reads.py create mode 100644 utils/history_read_fence.py create mode 100644 web/js/workspace/history-media.mjs create mode 100644 web/js/workspace/history-state.mjs create mode 100644 web/js/workspace/history.mjs create mode 100644 web/js/workspace/recorded-run.mjs diff --git a/.github/workflows/viewerjs-check.yml b/.github/workflows/viewerjs-check.yml index 6f0118d..4dc9411 100644 --- a/.github/workflows/viewerjs-check.yml +++ b/.github/workflows/viewerjs-check.yml @@ -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 diff --git a/TESTING.md b/TESTING.md index 0e356ad..5dd8cea 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/__init__.py b/__init__.py index 2663b6a..f494589 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/database/execution_history_schema.py b/database/execution_history_schema.py index fd678e6..191033d 100644 --- a/database/execution_history_schema.py +++ b/database/execution_history_schema.py @@ -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) diff --git a/database/history_gallery.py b/database/history_gallery.py new file mode 100644 index 0000000..08d8324 --- /dev/null +++ b/database/history_gallery.py @@ -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 diff --git a/database/history_reads.py b/database/history_reads.py new file mode 100644 index 0000000..9d84f24 --- /dev/null +++ b/database/history_reads.py @@ -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 + ) diff --git a/database/operations.py b/database/operations.py index 55c2614..d3d218b 100644 --- a/database/operations.py +++ b/database/operations.py @@ -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.""" diff --git a/py/api/__init__.py b/py/api/__init__.py index c085e63..404e0c4 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -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) diff --git a/py/api/history.py b/py/api/history.py new file mode 100644 index 0000000..9282ac7 --- /dev/null +++ b/py/api/history.py @@ -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) diff --git a/py/api/history_current.py b/py/api/history_current.py new file mode 100644 index 0000000..c277e13 --- /dev/null +++ b/py/api/history_current.py @@ -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() diff --git a/py/api/images.py b/py/api/images.py index b920ec3..d12af2b 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -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) diff --git a/py/api/inspection_service.py b/py/api/inspection_service.py new file mode 100644 index 0000000..5f4e0d7 --- /dev/null +++ b/py/api/inspection_service.py @@ -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, + } diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs new file mode 100644 index 0000000..dec68f4 --- /dev/null +++ b/tests/browser/history-canonical-comparison.mjs @@ -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); +} diff --git a/tests/browser/server.py b/tests/browser/server.py index 419fa3a..df17e50 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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, diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs new file mode 100644 index 0000000..e14378c --- /dev/null +++ b/tests/browser/workspace-history.spec.mjs @@ -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();} +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 2b29e30..6440722 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 75d2f1a..9aa5374 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -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']) { diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py new file mode 100644 index 0000000..34c569e --- /dev/null +++ b/tests/browser/workspace_history_fixture.py @@ -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 diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs index 6cf84ac..cb85c24 100644 --- a/tests/comfyui/execution-history.spec.mjs +++ b/tests/comfyui/execution-history.spec.mjs @@ -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 = []; diff --git a/tests/comfyui/execution-outputs.spec.mjs b/tests/comfyui/execution-outputs.spec.mjs index ad98a57..22529f3 100644 --- a/tests/comfyui/execution-outputs.spec.mjs +++ b/tests/comfyui/execution-outputs.spec.mjs @@ -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; } diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 5595111..211f064 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -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; + }); + } + } +}); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index 566871a..9805a61 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -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();} +}); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 8843406..d97be19 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -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); +}); diff --git a/tests/js/workspace-history-layout.test.js b/tests/js/workspace-history-layout.test.js new file mode 100644 index 0000000..11124bb --- /dev/null +++ b/tests/js/workspace-history-layout.test.js @@ -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); +}); diff --git a/tests/js/workspace-history-media.test.js b/tests/js/workspace-history-media.test.js new file mode 100644 index 0000000..fdc908c --- /dev/null +++ b/tests/js/workspace-history-media.test.js @@ -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; + }); + } +}); diff --git a/tests/js/workspace-history-state.test.js b/tests/js/workspace-history-state.test.js new file mode 100644 index 0000000..bb6c446 --- /dev/null +++ b/tests/js/workspace-history-state.test.js @@ -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(); + } +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index f514ef8..f49c869 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -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(); +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index b50e1f6..e69187e 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -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();} +}); diff --git a/tests/js/workspace-viewer-state.test.js b/tests/js/workspace-viewer-state.test.js index 3bf7265..1440323 100644 --- a/tests/js/workspace-viewer-state.test.js +++ b/tests/js/workspace-viewer-state.test.js @@ -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); +}); diff --git a/tests/test_api_history.py b/tests/test_api_history.py new file mode 100644 index 0000000..7914009 --- /dev/null +++ b/tests/test_api_history.py @@ -0,0 +1,1082 @@ +"""History HTTP contracts use disposable SQLite, exact cursors and restore fences.""" + +import asyncio +import base64 +import copy +import importlib +import json +import os +import sqlite3 +import tempfile +import threading +import unittest +from contextlib import contextmanager, ExitStack +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from aiohttp import FormData, web + +try: + from test_api_images import ImageAPITestCase +except ImportError: + from tests.test_api_images import ImageAPITestCase + +from database.operations import PromptDatabase +from py.api.library import _encode, _scope +from tests.test_execution_context_database import bundle, snapshot +from tests.test_execution_history_database import JOB, SESSION +from utils.execution_contexts import ContextBundle, RecipeContext, TextEvidence +from utils.execution_history_events import make_finish, make_start + +OTHER_SESSION = "aaaaaaaa-3333-4333-8333-333333333333" +MISSING_RUN = "bbbbbbbb-4444-4444-8444-444444444444" + + +class HistoryAPITests(ImageAPITestCase): + async def tearDownAsync(self): + if hasattr(self.api, "close_history_reads"): + self.api.close_history_reads() + await super().tearDownAsync() + + def capture( + self, item=0, time=100, session=SESSION, context=True, negative="negative" + ): + start = make_start( + session, + ((0, JOB, {}, {}), item), + self.api.db.get_execution_epoch(), + time, + ) + self.api.db.write_execution_start(start) + self.api.db.write_execution_finish( + make_finish( + session, item, start.storage_epoch, SimpleNamespace(messages=[]), time + ) + ) + if context: + self.api.db.write_execution_contexts( + start.run_id, start.storage_epoch, bundle(negative) + ) + return start + + async def window(self, positive, params=None): + response = await self.client.get( + f"/prompt_manager/library/{positive}/history", params=params + ) + self.assertEqual( + response.content_type, "application/json", "history route missing" + ) + return response, await response.json() + + async def detail(self, run, params=None): + response = await self.client.get( + f"/prompt_manager/history/runs/{run}", params=params + ) + self.assertEqual( + response.content_type, "application/json", "history route missing" + ) + return response, await response.json() + + def assert_error(self, response, body, status, code, recovery=None): + self.assertEqual(response.status, status, body) + self.assertEqual( + body, + { + "success": False, + "history_version": 1, + "code": code, + "error": body["error"], + "recovery": recovery, + }, + ) + self.assertIsInstance(body["error"], str) + self.assertLessEqual(len(body["error"]), 160) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertNotIn(str(self.comfy_dir), json.dumps(body)) + + def cursor_payload(self, token): + decoded = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)) + return json.loads(decoded[32:])["key"] + + def signed(self, payload, kind="history-window"): + return _encode(payload, _scope({"kind": kind})) + + async def test_window_defaults_bounds_and_no_context_recomposition(self): + positive = self._save_prompt("positive") + starts = [self.capture(i, i) for i in range(42)] + with patch.object( + self.api.db, + "get_execution_contexts", + side_effect=AssertionError("full contexts"), + ): + response, body = await self.window(positive) + self.assertEqual(response.status, 200, body) + self.assertEqual(body["history_version"], 1) + self.assertEqual(body["history_epoch"], starts[0].storage_epoch) + self.assertEqual( + [r["run_id"] for r in body["items"]], [s.run_id for s in starts[2:]] + ) + self.assertEqual(body["recorded_run_count"], 42) + self.assertEqual(body["group"]["relation"], "exact_frozen_raw_text") + self.assertEqual(len(body["context_summaries"]), 1) + self.assertTrue(body["has_more"]) + self.assertIsInstance(body["next_cursor"], str) + self.assertLessEqual(len(body["next_cursor"].encode()), 4096) + self.assertNotIn("next_position", body) + for limit, count in ((1, 1), (100, 42)): + response, page = await self.window(positive, {"limit": str(limit)}) + self.assertEqual(response.status, 200) + self.assertEqual(len(page["items"]), count) + for params in ( + [("limit", "1"), ("limit", "2")], + {"limit": "0"}, + {"limit": "101"}, + {"limit": "true"}, + {"limit": "false"}, + {"limit": "1.0"}, + {"limit": "01"}, + {"limit": "-1"}, + {"limit": ""}, + [("cursor", "a"), ("cursor", "b")], + ): + response, error = await self.window(positive, params) + self.assert_error(response, error, 400, "invalid-request") + + async def test_exact_detail_preserves_frozen_roles_nulls_evidence_and_navigation( + self, + ): + pair = self.api.db.save_prompt_pair("positive", "negative") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + start = self.capture(9007199254740993, 2**63 - 1) + response, body = await self.detail(start.run_id, {"epoch": start.storage_epoch}) + self.assertEqual(response.status, 200, body) + self.assertEqual(body["run"]["queue_item_id"], "9007199254740993") + self.assertEqual(body["run"]["observed_dequeue_at"], "9223372036854775807") + self.assertEqual(body["run"]["observed_callback_at"], "9223372036854775807") + self.assertIsNone(body["run"]["executor_started_at"]) + context = body["contexts"][0] + _, window = await self.window(positive) + self.assertEqual( + context["positive_raw_text_id"], window["group"]["raw_text_id"] + ) + self.assertEqual( + context["positive"], + {"raw": "positive", "effective": "positive", "prepend": "", "append": ""}, + ) + self.assertEqual( + context["negative"], + {"raw": "negative", "effective": "negative", "prepend": "", "append": ""}, + ) + self.assertEqual(context["reference_epoch"], start.storage_epoch) + self.assertEqual( + context["navigation"], + { + "positive_available": True, + "negative_available": True, + "pair_available": True, + }, + ) + for key in ("positive_snapshot_id", "negative_snapshot_id", "context_id"): + self.assertRegex(context[key], r"^[0-9a-f]{64}$") + self.assertEqual( + context["evidence"]["positive"]["raw"]["provenance"], "recorded" + ) + with self.api.db.model.get_connection() as conn: + conn.execute("UPDATE prompts SET text='edited' WHERE id=?", (positive,)) + _, changed = await self.detail(start.run_id) + self.assertEqual(changed["contexts"][0]["positive"], context["positive"]) + self.assertFalse(changed["contexts"][0]["navigation"]["positive_available"]) + self.assertFalse(changed["contexts"][0]["navigation"]["pair_available"]) + bare = self.capture(1, context=False) + response, uncaptured = await self.detail(bare.run_id) + self.assertEqual(response.status, 200) + self.assertEqual(uncaptured["context_capture"]["state"], "not_captured") + self.assertEqual(uncaptured["contexts"], []) + self.assertIsNone(uncaptured["output_summary"]["retained_occurrence_count"]) + + async def test_window_and_exact_run_publish_boolean_diagnostics_from_sqlite( + self, + ) -> None: + positive = self._save_prompt("positive") + starts = [] + for item, omitted in enumerate((False, True)): + start = make_start( + SESSION, + ((0, JOB, {}, {}), item), + self.api.db.get_execution_epoch(), + item, + ) + self.api.db.write_execution_start(start) + finish = make_finish( + SESSION, + item, + start.storage_epoch, + SimpleNamespace( + messages=[ + ( + "execution_success", + { + "prompt_id": JOB, + "timestamp": item, + "node_type": object() if omitted else None, + }, + ) + ] + ), + item, + ) + self.api.db.write_execution_finish(finish) + self.api.db.write_execution_contexts( + start.run_id, start.storage_epoch, bundle() + ) + self.assertEqual( + self.api.db.get_execution_run(start.run_id)["diagnostics_omitted"], + int(omitted), + ) + starts.append(start) + response, window = await self.window(positive) + self.assertEqual(response.status, 200, window) + for start, omitted in zip(starts, (False, True)): + with self.subTest(omitted=omitted): + summary = next( + row for row in window["items"] if row["run_id"] == start.run_id + ) + self.assertIs(summary["diagnostics_omitted"], omitted) + response, detail = await self.detail(start.run_id) + self.assertEqual(response.status, 200, detail) + self.assertIs(detail["run"]["diagnostics_omitted"], omitted) + + async def test_missing_and_invalid_ids_epochs_and_duplicate_queries(self): + response, body = await self.window(999999) + self.assert_error(response, body, 404, "not-found") + response, body = await self.detail(MISSING_RUN) + self.assert_error(response, body, 404, "not-found") + for identity in ("0", "-1", "true", "9223372036854775808", "01"): + response, body = await self.window(identity) + self.assert_error(response, body, 400, "invalid-request") + for run in ("not-a-uuid", MISSING_RUN.upper()): + response, body = await self.detail(run) + self.assert_error(response, body, 400, "invalid-request") + start = self.capture() + for params in ( + {"epoch": ""}, + {"epoch": "not-a-uuid"}, + [("epoch", start.storage_epoch), ("epoch", start.storage_epoch)], + ): + response, body = await self.detail(start.run_id, params) + self.assert_error(response, body, 400, "invalid-request") + response, body = await self.detail(start.run_id, {"epoch": OTHER_SESSION}) + self.assert_error( + response, body, 409, "history-changed", {"action": "refresh-history"} + ) + + async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self): + start = make_start( + SESSION, + ((0, JOB, {}, {}), 9007199254740993), + self.api.db.get_execution_epoch(), + 9007199254740993, + ) + self.api.db.write_execution_start(start) + nullable = replace( + snapshot("positive"), + effective=TextEvidence(None, "unknown", "unknown", "missing"), + ) + self.api.db.write_execution_contexts( + start.run_id, + start.storage_epoch, + ContextBundle( + (RecipeContext("7", nullable, None, "inferred"),), + "partial", + "captured", + ), + ) + self.api.db.write_execution_finish( + make_finish( + SESSION, + start.queue_item_id, + start.storage_epoch, + SimpleNamespace( + messages=[ + ( + "execution_start", + {"prompt_id": JOB, "timestamp": 9007199254740993}, + ), + ( + "execution_success", + {"prompt_id": JOB, "timestamp": 2**63 - 1}, + ), + ] + ), + 2**63 - 1, + ) + ) + response, body = await self.detail(start.run_id) + self.assertEqual(response.status, 200) + self.assertEqual(body["run"]["executor_started_at"], "9007199254740993") + self.assertEqual(body["run"]["terminal_event_at"], "9223372036854775807") + self.assertEqual(body["run"]["observed_callback_at"], "9223372036854775807") + context = body["contexts"][0] + self.assertEqual( + context["positive"], + {"raw": "positive", "effective": None, "prepend": "", "append": ""}, + ) + self.assertIsNone(context["negative"]) + self.assertIsNone(context["negative_snapshot_id"]) + self.assertEqual( + context["evidence"]["positive"]["effective"]["provenance"], "unknown" + ) + + async def test_rf2_decimal_keysets_survive_deletion_and_delayed_capture( + self, + ): + positive = self._save_prompt("positive") + low = self.capture(0, 100) + delayed_old = self.capture(1, 100, context=False) + middle = self.capture(9007199254740993, 100) + deleted = self.capture(2**63 - 1, 100) + delayed_passed = self.capture(0, 100, OTHER_SESSION, context=False) + upper = self.capture(1, 100, OTHER_SESSION) + response, first = await self.window(positive, {"limit": "2"}) + self.assertEqual(response.status, 200, first) + self.assertEqual( + [r["run_id"] for r in first["items"]], [deleted.run_id, upper.run_id] + ) + self.assertEqual(first["items"][0]["queue_item_id"], "9223372036854775807") + self.assertEqual(first["recorded_run_count"], 4) + payload = self.cursor_payload(first["next_cursor"]) + self.assertEqual( + set(payload), + { + "version", + "epoch", + "positive_id", + "raw_text_id", + "direction", + "upper", + "before", + }, + ) + self.assertEqual(payload["direction"], "older") + self.assertEqual(payload["positive_id"], str(positive)) + self.assertEqual( + payload["before"], ["100", SESSION, "9223372036854775807", deleted.run_id] + ) + with self.api.db.model.get_connection() as conn: + conn.execute( + "DELETE FROM execution_run_contexts WHERE run_id=?", (deleted.run_id,) + ) + conn.execute( + "DELETE FROM execution_context_capture WHERE run_id=?", + (deleted.run_id,), + ) + conn.execute("DELETE FROM execution_runs WHERE run_id=?", (deleted.run_id,)) + newer = self.capture(7, 101) + for start in (delayed_old, delayed_passed): + self.api.db.write_execution_contexts( + start.run_id, start.storage_epoch, bundle() + ) + _, second = await self.window( + positive, {"limit": "2", "cursor": first["next_cursor"]} + ) + self.assertEqual( + [r["run_id"] for r in second["items"]], [delayed_old.run_id, middle.run_id] + ) + self.assertEqual(second["items"][1]["queue_item_id"], "9007199254740993") + self.assertEqual(second["recorded_run_count"], 5) + _, last = await self.window( + positive, {"limit": "2", "cursor": second["next_cursor"]} + ) + self.assertEqual([r["run_id"] for r in last["items"]], [low.run_id]) + self.assertFalse(last["has_more"]) + self.assertIsNone(last["next_cursor"]) + pages = last["items"] + second["items"] + first["items"] + keys = [ + ( + int(r["observed_dequeue_at"]), + r["session_id"], + int(r["queue_item_id"]), + r["run_id"], + ) + for r in pages + ] + self.assertEqual(keys, sorted(set(keys))) + _, refreshed = await self.window(positive) + self.assertEqual(refreshed["recorded_run_count"], 6) + self.assertIn(newer.run_id, [r["run_id"] for r in refreshed["items"]]) + self.assertIn(delayed_passed.run_id, [r["run_id"] for r in refreshed["items"]]) + + async def test_cursor_authentication_strict_shape_and_domain_precede_projection( + self, + ): + positive = self._save_prompt("positive") + for i in range(3): + self.capture(i) + _, first = await self.window(positive, {"limit": "1"}) + payload = self.cursor_payload(first["next_cursor"]) + changed = [] + for key, values in { + "version": [2, True, "1"], + "epoch": [OTHER_SESSION.upper(), "x"], + "positive_id": [str(positive + 1), positive, "01", True], + "raw_text_id": ["z" * 64, "A" * 64, None], + "direction": ["newer", None], + "upper": [ + [], + ["1", SESSION, "1"], + ["01", SESSION, "1", MISSING_RUN], + [1, SESSION, "1", MISSING_RUN], + ["1", SESSION, "9223372036854775808", MISSING_RUN], + ["1", OTHER_SESSION.upper(), "1", MISSING_RUN], + ], + "before": [ + False, + ["-1", SESSION, "0", MISSING_RUN], + ["1.0", SESSION, "0", MISSING_RUN], + ["1", SESSION, True, MISSING_RUN], + ], + }.items(): + for value in values: + changed.append({**payload, key: value}) + changed.extend( + [ + {key: value for key, value in payload.items() if key != "before"}, + {**payload, "private": "path"}, + ] + ) + tokens = [ + "tampered", + "!", + "a" * 4097, + "猫", + self.signed(payload, "history-outputs"), + _encode(payload, _scope({"kind": "history-window"}), name=True), + ] + tokens += [self.signed(value) for value in changed] + token = first["next_cursor"] + tokens.append(("A" if token[0] != "A" else "B") + token[1:]) + with patch.object( + self.api.db, + "list_history_window", + side_effect=AssertionError("projection must not run"), + ): + for token in tokens: + response, body = await self.window(positive, {"cursor": token}) + self.assert_error(response, body, 400, "invalid-cursor") + with self.api.db.model.get_connection() as conn: + conn.execute("UPDATE prompts SET text='different' WHERE id=?", (positive,)) + response, body = await self.window(positive, {"cursor": first["next_cursor"]}) + self.assert_error(response, body, 400, "invalid-cursor") + + async def test_valid_cursor_cannot_move_to_other_positive_or_raw_scope(self): + positive = self._save_prompt("positive") + duplicate = self._save_prompt("same saved row with another text") + with self.api.db.model.get_connection() as conn: + conn.execute("UPDATE prompts SET text='positive' WHERE id=?", (duplicate,)) + self.capture(0) + self.capture(1) + _, first = await self.window(positive, {"limit": "1"}) + with patch.object( + self.api.db, "list_history_window", side_effect=AssertionError("projection") + ): + response, body = await self.window( + duplicate, {"cursor": first["next_cursor"]} + ) + self.assert_error(response, body, 400, "invalid-cursor") + payload = self.cursor_payload(first["next_cursor"]) + response, body = await self.window( + positive, {"cursor": self.signed({**payload, "raw_text_id": "0" * 64})} + ) + self.assert_error(response, body, 400, "invalid-cursor") + + async def test_each_read_has_one_executor_job_and_ends_snapshot_before_validation( + self, + ): + positive = self._save_prompt("positive") + start = self.capture() + fence = self.api._history_fence() + validate = fence.validate + executor = self.api._run_in_executor + jobs = [] + validated = [] + + def after_snapshot(epoch, generation): + self.assertFalse(self.api.db.model.get_connection().in_transaction) + validated.append(epoch) + validate(epoch, generation) + + async def tracked_executor(func, *args, **kwargs): + jobs.append(func) + return await executor(func, *args, **kwargs) + + with patch.object(fence, "validate", side_effect=after_snapshot): + with patch.object( + self.api, "_run_in_executor", side_effect=tracked_executor + ): + await self.window(positive) + await self.detail(start.run_id) + self.assertEqual(len(jobs), 2) + self.assertEqual(validated, [start.storage_epoch, start.storage_epoch]) + + async def test_final_storage_failure_and_invalid_serialization_cannot_succeed(self): + positive = self._save_prompt("positive") + start = self.capture() + fence = self.api._history_fence() + with patch.object( + fence, "validate", side_effect=sqlite3.OperationalError("private/path") + ): + for read in ( + lambda: self.window(positive), + lambda: self.detail(start.run_id), + ): + response, body = await read() + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + original = self.api.db.get_history_run + + def invalid_payload(run_id): + result = original(run_id) + result["contexts"][0]["positive"]["raw"] = float("nan") + return result + + with patch.object(self.api.db, "get_history_run", side_effect=invalid_payload): + response, body = await self.detail(start.run_id) + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + + async def test_cursor_decoder_recursion_failure_is_bounded_before_projection(self): + positive = self._save_prompt("positive") + raw = ( + b"x" * 32 + + b'{"scope":"wrong","key":' + + b"[" * 1100 + + b"0" + + b"]" * 1100 + + b"}" + ) + token = base64.urlsafe_b64encode(raw).decode().rstrip("=") + self.assertLess(len(token), 4096) + module = importlib.import_module("py.api.history") + original_loads = json.loads + + def supported_runtime_loads(value, *args, **kwargs): + # Python 3.10's JSON decoder raises this for the actual deep token. + if type(value) is bytes and value == raw[32:]: + raise RecursionError("nested JSON") + return original_loads(value, *args, **kwargs) + + with patch.object( + self.api.db, "list_history_window", side_effect=AssertionError("projection") + ): + with patch.object( + module.json, "loads", side_effect=supported_runtime_loads + ): + response, body = await self.window(positive, {"cursor": token}) + self.assert_error(response, body, 400, "invalid-cursor") + + async def test_epoch_change_is_409_but_tampering_remains_400(self): + positive = self._save_prompt("positive") + self.capture(0) + self.capture(1) + _, first = await self.window(positive, {"limit": "1"}) + source = str(self.comfy_dir / "restore.db") + self.assertTrue(self.api.db.model.backup_database(source)) + self.api.db.model.restore_from_file(source) + response, body = await self.window(positive, {"cursor": first["next_cursor"]}) + self.assert_error( + response, body, 409, "history-changed", {"action": "refresh-history"} + ) + response, body = await self.window( + positive, {"cursor": "bad" + first["next_cursor"]} + ) + self.assert_error(response, body, 400, "invalid-cursor") + + async def test_resume_rebuilds_the_old_bounded_range_after_many_new_runs(self): + positive = self._save_prompt("positive") + starts = [self.capture(i, i) for i in range(42)] + _, first = await self.window(positive) + self.assertIn("resume_cursor", first) + self.assertIsInstance(first["resume_cursor"], str) + self.assertLessEqual(len(first["resume_cursor"]), 4096) + _, exhausted = await self.window(positive, {"cursor": first["next_cursor"]}) + self.assertFalse(exhausted["has_more"]) + self.assertIsNone(exhausted["next_cursor"]) + self.assertIsInstance(exhausted["resume_cursor"], str) + for i in range(42, 103): + self.capture(i, i) + response, resumed = await self.window( + positive, {"resume": exhausted["resume_cursor"]} + ) + self.assertEqual(response.status, 200, resumed) + self.assertEqual(resumed["recorded_run_count"], 42) + self.assertEqual( + [run["run_id"] for run in resumed["items"]], + [start.run_id for start in starts[2:]], + ) + self.assertEqual(resumed["resume_cursor"], first["resume_cursor"]) + response, older = await self.window( + positive, {"cursor": resumed["next_cursor"]} + ) + self.assertEqual(response.status, 200, older) + self.assertEqual( + [run["run_id"] for run in older["items"]], + [start.run_id for start in starts[:2]], + ) + _, fresh = await self.window(positive) + self.assertEqual(fresh["recorded_run_count"], 103) + self.assertTrue( + set(run["run_id"] for run in fresh["items"]).isdisjoint( + start.run_id for start in starts + ) + ) + + async def test_exhausted_resume_authenticates_scope_and_distinguishes_epoch_change( + self, + ): + positive = self._save_prompt("positive") + starts = [self.capture(i, i) for i in range(3)] + _, first = await self.window(positive) + self.assertIsNone(first["next_cursor"]) + self.assertIn("resume_cursor", first) + token = first["resume_cursor"] + response, resumed = await self.window(positive, {"resume": token}) + self.assertEqual(response.status, 200, resumed) + self.assertEqual( + [run["run_id"] for run in resumed["items"]], + [start.run_id for start in starts], + ) + _, ordinary = await self.window(positive, {"limit": "1"}) + for params in ( + {"resume": ""}, + {"resume": "x" * 4097}, + {"resume": "bad" + token}, + {"cursor": token}, + {"resume": ordinary["next_cursor"]}, + ): + response, body = await self.window(positive, params) + self.assert_error(response, body, 400, "invalid-cursor") + for params in ( + {"resume": token, "cursor": ordinary["next_cursor"]}, + [("resume", token), ("resume", token)], + {"resume": token, "limit": "101"}, + {"resume": token, "unknown": "value"}, + ): + response, body = await self.window(positive, params) + self.assert_error(response, body, 400, "invalid-request") + payload = self.cursor_payload(token) + for change in ( + {"version": True}, + {"positive_id": positive}, + {"positive_id": "999"}, + {"raw_text_id": "bad"}, + {"upper": ["01", SESSION, "0", starts[0].run_id]}, + {"upper": ["0", SESSION, True, starts[0].run_id]}, + {"upper": []}, + {"before": payload["upper"]}, + {"epoch": "bad"}, + ): + signed = self.signed({**payload, **change}, "history-resume") + response, body = await self.window(positive, {"resume": signed}) + self.assert_error(response, body, 400, "invalid-cursor") + other = self._save_prompt("other positive") + response, body = await self.window(other, {"resume": token}) + self.assert_error(response, body, 400, "invalid-cursor") + with self.api.db.model.get_connection() as conn: + conn.execute("UPDATE prompts SET text='changed' WHERE id=?", (positive,)) + response, body = await self.window(positive, {"resume": token}) + self.assert_error(response, body, 400, "invalid-cursor") + source = str(self.comfy_dir / "resume-restore.db") + self.assertTrue(self.api.db.model.backup_database(source)) + self.api.db.model.restore_from_file(source) + response, body = await self.window(positive, {"resume": token}) + self.assert_error( + response, body, 409, "history-changed", {"action": "refresh-history"} + ) + + async def test_empty_history_has_no_authenticated_resume_anchor(self): + positive = self._save_prompt("positive") + response, body = await self.window(positive) + self.assertEqual(response.status, 200, body) + self.assertIn("resume_cursor", body) + self.assertIsNone(body["resume_cursor"]) + + async def test_real_control_heavy_detail_uses_actual_utf8_budget_without_mutation( + self, + ): + self._save_prompt("positive") + negative = '猫😀\0\x01\n"\\' * 1500 + start = self.capture(negative=negative) + response, body = await self.detail(start.run_id) + self.assertEqual(response.status, 200, body) + encoded = await response.read() + self.assertEqual(body["contexts"][0]["negative"]["raw"], negative) + self.assertIn("猫".encode(), encoded) + self.assertIn(b"\\u0000", encoded) + self.assertGreater(len(encoded), len(json.dumps(body, ensure_ascii=False))) + module = importlib.import_module("py.api.history") + with patch.object(module, "_RUN_MAX_BYTES", len(encoded)): + at, same = await self.detail(start.run_id) + self.assertEqual(at.status, 200) + self.assertEqual(await at.read(), encoded) + self.assertEqual(same, body) + with patch.object(module, "_RUN_MAX_BYTES", len(encoded) - 1): + above, error = await self.detail(start.run_id) + self.assert_error(above, error, 422, "response-limit") + self.assertNotIn("contexts", error) + _, after = await self.detail(start.run_id) + self.assertEqual(after, body) + + async def test_projected_detail_fixture_pins_defensive_eight_mib_encoder_boundary( + self, + ): + start = self.capture() + response, detail = await self.detail(start.run_id) + self.assertEqual(response.status, 200, detail) + + def check_projection_limit(): + # A projected-detail fixture tests the defensive transport ceiling; + # valid stored captures retain their existing one-MiB text allowance. + projected = copy.deepcopy(detail) + projected["contexts"][0]["positive"]["raw"] = "猫😀\0" + baseline = json.dumps( + projected, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ).encode() + projected["contexts"][0]["positive"]["raw"] += "x" * ( + 8 * 1024 * 1024 - len(baseline) + ) + module = importlib.import_module("py.api.history") + encoded = module._serialize_history(projected, max_bytes=8 * 1024 * 1024) + self.assertEqual(len(encoded), 8 * 1024 * 1024) + self.assertEqual(json.loads(encoded), projected) + projected["contexts"][0]["positive"]["raw"] += "x" + with self.assertRaises(module.HistoryReadError) as error: + module._serialize_history(projected, max_bytes=8 * 1024 * 1024) + self.assertEqual(error.exception.code, "response-limit") + + await self.api._run_in_executor(check_projection_limit) + + async def test_storage_failure_is_bounded_retry_without_logging_private_values( + self, + ): + positive = self._save_prompt("positive") + start = self.capture() + private = "SECRET_PROMPT /private/data/secret.db" + for method, read in ( + ("list_history_window", lambda: self.window(positive)), + ("get_history_run", lambda: self.detail(start.run_id)), + ): + for exc in ( + sqlite3.OperationalError("busy " + private), + sqlite3.DatabaseError("corrupt " + private), + OSError(private), + ): + with patch.object(self.api.db, method, side_effect=exc): + with self.assertLogs("prompt_manager.api", "WARNING") as logs: + response, body = await read() + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + self.assertNotIn(private, json.dumps(body) + " ".join(logs.output)) + + async def test_snapshot_restore_before_notification_rejects_both_public_reads(self): + positive = self._save_prompt("positive") + start = self.capture() + source = str(self.comfy_dir / "source.db") + self.assertTrue(self.api.db.model.backup_database(source)) + writer = PromptDatabase(self.api.db.model.db_path) + try: + for method, read in ( + ("list_history_window", lambda: self.window(positive)), + ("get_history_run", lambda: self.detail(start.run_id)), + ): + original = getattr(self.api.db, method) + + def during_snapshot(*args, **kwargs): + result = original(*args, **kwargs) + with patch("database.execution_history.notify_history_restore"): + writer.model.restore_from_file(source) + return result + + with patch.object(self.api.db, method, side_effect=during_snapshot): + response, body = await read() + self.assert_error( + response, + body, + 409, + "history-changed", + {"action": "refresh-history"}, + ) + finally: + writer.close_all() + self.assertEqual(len(writer.model._connections), 0) + + async def test_one_lazy_subscription_cleanup_and_no_reinitialization(self): + import database.execution_history as observers + + positive = self._save_prompt("positive") + start = self.capture() + identity = os.path.normcase(str(Path(self.api.db.model.db_path).resolve())) + self.assertNotIn(identity, observers._restore_observers) + executor = self.api._run_in_executor + + async def subscribed_before_executor(func, *args, **kwargs): + self.assertEqual(len(observers._restore_observers[identity]), 1) + return await executor(func, *args, **kwargs) + + with patch.object( + self.api, "_run_in_executor", side_effect=subscribed_before_executor + ): + await self.window(positive) + await self.detail(start.run_id) + await self.window(positive) + self.assertEqual(len(observers._restore_observers[identity]), 1) + self.api.close_history_reads() + self.api.close_history_reads() + self.assertNotIn(identity, observers._restore_observers) + response, body = await self.detail(start.run_id) + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + self.assertNotIn(identity, observers._restore_observers) + + async def test_cleanup_before_first_read_does_not_subscribe(self): + import database.execution_history as observers + + identity = str(Path(self.api.db.model.db_path).resolve()) + self.assertTrue( + callable(getattr(self.api, "close_history_reads", None)), + "API needs permanent history cleanup", + ) + self.api.close_history_reads() + response, body = await self.window(self._save_prompt("positive")) + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + self.assertNotIn(identity, observers._restore_observers) + + async def test_multipart_restore_then_read_uses_new_owner_and_current_epoch(self): + positive = self._save_prompt("positive") + start = self.capture() + _, before = await self.window(positive) + old_db = self.api.db + source = str(self.comfy_dir / "upload.db") + self.assertTrue(old_db.model.backup_database(source)) + form = FormData() + form.add_field("database_file", Path(source).read_bytes(), filename="upload.db") + response = await self.client.post("/prompt_manager/restore", data=form) + self.assertEqual(response.status, 200, await response.text()) + self.assertIsNot(self.api.db, old_db) + self.assertEqual(len(old_db.model._connections), 0) + + with patch.object( + old_db.model, "get_connection", side_effect=AssertionError("retired owner") + ): + response, after = await self.window(positive) + self.assertEqual(response.status, 200, after) + response, detail = await self.detail(start.run_id) + self.assertEqual(response.status, 200, detail) + self.assertNotEqual(before["history_epoch"], after["history_epoch"]) + self.assertEqual(detail["history_epoch"], after["history_epoch"]) + self.assertEqual(detail["run"]["storage_epoch"], start.storage_epoch) + self.assertEqual(detail["contexts"][0]["reference_epoch"], start.storage_epoch) + self.assertEqual( + detail["contexts"][0]["navigation"], + { + "positive_available": False, + "negative_available": False, + "pair_available": False, + }, + ) + self.assertEqual(len(old_db.model._connections), 0) + + async def assert_projection_releases_retired_owner( + self, route, *, acquisition=False, failure=None + ): + positive = self._save_prompt("positive") + start = self.capture() + await self.window(positive) + old_db = self.api.db + self.addCleanup(old_db.close_all) + source = self.comfy_dir / "queued-restore.db" + self.assertTrue(old_db.model.backup_database(str(source))) + upload = source.read_bytes() + reached = asyncio.Event() + release = threading.Event() if acquisition else asyncio.Event() + executor = self.api._run_in_executor + connection = old_db.model.get_connection + history = importlib.import_module("py.api.history") + serialize = history._serialize_history + fence = self.api._history_fence() + validate = fence.validate + loop = asyncio.get_running_loop() + armed = True + + async def queued(func, *args, **kwargs): + nonlocal armed + if armed and func.__name__ == "project": + armed = False + reached.set() + await asyncio.wait_for(release.wait(), 5) + return await executor(func, *args, **kwargs) + + def selected_connection(): + nonlocal armed + if armed: + armed = False + loop.call_soon_threadsafe(reached.set) + if not release.wait(5): + raise AssertionError("connection-acquisition barrier timed out") + return connection() + + @contextmanager + def failed_snapshot(expected_epoch=None): + old_db.model.get_connection() + raise sqlite3.OperationalError("snapshot unavailable") + yield + + def released_before_serialization(payload, **kwargs): + self.assertEqual(len(old_db.model._connections), 0) + return serialize(payload, **kwargs) + + def released_before_validation(epoch, generation): + self.assertEqual(len(old_db.model._connections), 0) + return validate(epoch, generation) + + with ExitStack() as patches: + patches.enter_context( + patch.object( + history, + "_serialize_history", + side_effect=released_before_serialization, + ) + ) + patches.enter_context( + patch.object(fence, "validate", side_effect=released_before_validation) + ) + if acquisition: + patches.enter_context( + patch.object( + old_db.model, "get_connection", side_effect=selected_connection + ) + ) + else: + patches.enter_context( + patch.object(self.api, "_run_in_executor", side_effect=queued) + ) + if failure == "snapshot": + patches.enter_context( + patch.object( + old_db, "recorded_history_snapshot", side_effect=failed_snapshot + ) + ) + elif failure == "lookup": + patches.enter_context( + patch.object( + old_db, + ( + "list_history_window" + if route == "window" + else "get_history_run" + ), + side_effect=sqlite3.OperationalError("lookup unavailable"), + ) + ) + read = ( + self.window(positive) + if route == "window" + else self.detail(start.run_id) + ) + pending = asyncio.create_task(read) + try: + await asyncio.wait_for(reached.wait(), 5) + form = FormData() + form.add_field("database_file", upload, filename="upload.db") + restored = await self.client.post("/prompt_manager/restore", data=form) + self.assertEqual(restored.status, 200, await restored.text()) + self.assertIsNot(self.api.db, old_db) + self.assertEqual(len(old_db.model._connections), 0) + finally: + release.set() + response, body = await asyncio.wait_for(pending, 5) + + if failure is not None: + self.assert_error( + response, body, 503, "storage-unavailable", {"action": "retry-read"} + ) + elif acquisition: + self.assert_error( + response, body, 409, "history-changed", {"action": "refresh-history"} + ) + else: + self.assertEqual(response.status, 200, body) + self.assertEqual(body["history_epoch"], self.api.db.get_execution_epoch()) + self.assertNotEqual(body["history_epoch"], start.storage_epoch) + run = body["items"][0] if route == "window" else body["run"] + self.assertEqual(run["run_id"], start.run_id) + self.assertEqual(run["storage_epoch"], start.storage_epoch) + self.assertEqual( + len(old_db.model._connections), + 0, + "completed projection retained a handle on the API-retired owner", + ) + + async def test_queued_window_across_multipart_restore_closes_retired_handle(self): + await self.assert_projection_releases_retired_owner("window") + + async def test_queued_detail_across_multipart_restore_closes_retired_handle(self): + await self.assert_projection_releases_retired_owner("detail") + + async def test_window_retirement_before_connection_acquisition_closes_handle(self): + await self.assert_projection_releases_retired_owner("window", acquisition=True) + + async def test_detail_retirement_before_connection_acquisition_closes_handle(self): + await self.assert_projection_releases_retired_owner("detail", acquisition=True) + + async def test_retired_window_handle_closes_after_snapshot_and_lookup_failures( + self, + ): + for failure in ("snapshot", "lookup"): + with self.subTest(failure=failure): + await self.assert_projection_releases_retired_owner( + "window", acquisition=True, failure=failure + ) + + async def test_retired_detail_handle_closes_after_snapshot_and_lookup_failures( + self, + ): + for failure in ("snapshot", "lookup"): + with self.subTest(failure=failure): + await self.assert_projection_releases_retired_owner( + "detail", acquisition=True, failure=failure + ) + + +class HistoryHostCleanupTests(unittest.IsolatedAsyncioTestCase): + async def test_root_registers_cleanup_on_actual_host_application(self): + from tests.test_init_package import fake_server_module, load_package + + with tempfile.TemporaryDirectory() as directory: + server = fake_server_module() + server.PromptServer.instance.app = web.Application() + with load_package( + "history_cleanup_pkg", + str(Path(directory) / "db.sqlite"), + directory, + server, + ) as (package, host): + self.assertIn(package.api.cleanup_history_reads, host.app.on_cleanup) + api = package.api + self.assertIsNone(getattr(api, "_history_read_fence", None)) + fence = api._history_fence() + generation = fence.begin() + epoch = api.db.get_execution_epoch() + host.app.freeze() + await host.app.cleanup() + with self.assertRaises(sqlite3.Error): + fence.validate(epoch, generation) + with self.assertRaises(sqlite3.Error): + api._history_fence() diff --git a/tests/test_api_history_outputs.py b/tests/test_api_history_outputs.py new file mode 100644 index 0000000..d063fb6 --- /dev/null +++ b/tests/test_api_history_outputs.py @@ -0,0 +1,1334 @@ +"""Real occurrence routes preserve receipts and fence current-file publication.""" + +import asyncio +import errno +import importlib +import json +import os +import sqlite3 +import threading +from contextlib import ExitStack +from pathlib import Path +from types import SimpleNamespace +from unittest import skipUnless +from unittest.mock import patch + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from tests.test_api_images import ImageAPITestCase, make_png +from tests.test_api_prompt_inspector import graph, png +from tests.test_execution_context_database import bundle as context_bundle +from tests.test_execution_history_database import JOB, SESSION +from utils.execution_contexts import ContextBundle +from utils.execution_history_events import make_finish, make_start +from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots +from py.config import GalleryConfig + + +class HistoryOutputAPITests(ImageAPITestCase): + async def get_application(self): + app = await super().get_application() + self.temp_dir = self.comfy_dir / "temp" + self.temp_dir.mkdir() + self.host_output = str(self.output_dir) + self.host_temp = str(self.temp_dir) + self.patches = ExitStack() + self.addCleanup(self.patches.close) + self.patches.enter_context( + patch( + "folder_paths.get_output_directory", + lambda: self.host_output, + create=True, + ) + ) + self.patches.enter_context( + patch( + "folder_paths.get_temp_directory", lambda: self.host_temp, create=True + ) + ) + self.patches.enter_context( + patch.object( + GalleryConfig, "allowed_gallery_parents", lambda: [str(self.comfy_dir)] + ) + ) + return app + + async def tearDownAsync(self): + self.api.close_history_reads() + self.patches.close() + await super().tearDownAsync() + + def ref(self, node="1", ordinal=0, filename="image.png", **kwargs): + native = "windows" if os.name == "nt" else "posix" + roots = snapshot_output_roots(native, self.host_output, self.host_temp) + return OutputReference( + node, + ordinal, + kwargs.get("folder_type", "output"), + kwargs.get("subfolder", ""), + filename, + kwargs.get("path_style", native), + kwargs.get("root_key", roots.output_key), + ) + + def capture(self, references=None, item=0, state="complete", reason=None): + start = make_start( + SESSION, ((0, JOB, {}, {}), item), self.api.db.get_execution_epoch(), item + ) + self.api.db.write_execution_start(start) + finish = make_finish( + SESSION, item, start.storage_epoch, SimpleNamespace(messages=[]), item + ) + self.api.db.write_execution_finish(finish) + if references is not None: + value = OutputBundle( + tuple(references), + state, + reason or ("reported" if references else "none_reported"), + ) + self.assertTrue( + self.api.db.write_execution_outputs_for_finish(finish, value) + ) + return start + + async def outputs(self, start, params=None): + query = {"epoch": start.storage_epoch} + if params is not None: + query = params + response = await self.client.get( + f"/prompt_manager/history/runs/{start.run_id}/outputs", params=query + ) + self.assertEqual( + response.content_type, + "application/json", + "reported-output route is missing", + ) + return response, await response.json() + + async def current(self, url, **kwargs): + response = await self.client.request(kwargs.pop("method", "GET"), url, **kwargs) + return response + + async def media(self, start): + response, body = await self.outputs(start) + self.assertEqual(response.status, 200, body) + return body["items"][0]["current_candidate"]["media"] + + def assert_error(self, response, body, status, code): + self.assertEqual(response.status, status, body) + self.assertFalse(body["success"]) + self.assertEqual(body["history_version"], 1) + self.assertEqual(body["code"], code) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertNotIn(str(self.comfy_dir), json.dumps(body)) + if body["recovery"] is not None: + self.assertIn( + body["recovery"]["action"], + ("refresh-history", "reload-outputs", "retry-read", "smaller-limit"), + ) + + def saved_state(self): + conn = self.api.db.model.get_connection() + return { + table: [ + tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY id") + ] + for table in ( + "prompts", + "prompt_pairs", + "generated_images", + "image_prompt_pairs", + ) + } + + def restore_fixture(self): + source = str(self.comfy_dir / "restore.db") + self.assertTrue(self.api.db.model.backup_database(source)) + return source + + async def test_ordered_receipts_repeat_locators_without_context_ownership(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref("1", 0), self.ref("1", 1), self.ref("2", 0)]) + contexts = ( + context_bundle("frozen A", anchor="1").contexts[0], + context_bundle("frozen B", anchor="2").contexts[0], + ) + self.api.db.write_execution_contexts( + start.run_id, + start.storage_epoch, + ContextBundle(contexts, "complete", "captured"), + ) + repeated = self.capture([self.ref()], item=1) + with patch.object( + self.api.db, + "get_execution_contexts", + side_effect=AssertionError("contexts x outputs"), + ): + response, page = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "2"} + ) + self.assertEqual(response.status, 200, page) + self.assertEqual(page["retained_occurrence_count"], 3) + self.assertEqual(page["list_status"], "complete") + self.assertEqual( + [ + (r["occurrence"]["output_node_id"], r["occurrence"]["image_ordinal"]) + for r in page["items"] + ], + [("1", 0), ("1", 1)], + ) + self.assertTrue(page["has_more"]) + self.assertLessEqual(len(page["next_cursor"]), 4096) + _, second = await self.outputs( + start, {"epoch": start.storage_epoch, "cursor": page["next_cursor"]} + ) + self.assertEqual(second["items"][0]["occurrence"]["output_node_id"], "2") + self.assertFalse(second["has_more"]) + self.assertIsNone(second["next_cursor"]) + _, other = await self.outputs(repeated) + self.assertNotEqual(other["items"][0]["occurrence"]["run_id"], start.run_id) + for receipt in page["items"] + second["items"] + other["items"]: + self.assertEqual(receipt["file_identity"], "unverified") + self.assertEqual(receipt["newly_generated"], "unknown") + self.assertEqual(receipt["context_membership"], "unknown") + self.assertNotIn("context_id", receipt) + candidate = receipt["current_candidate"] + self.assertEqual(candidate["status"], "available") + self.assertEqual(candidate["evidence_class"], "locator_candidate") + self.assertEqual(candidate["media"]["occurrence"], receipt["occurrence"]) + self.assertEqual(candidate["media"]["reference"], receipt["reference"]) + self.assertEqual(candidate["media"]["kind"], "reported-location") + for field in ("url", "thumbnail_url", "inspection_url"): + self.assertTrue( + candidate["media"][field].startswith( + "/prompt_manager/history/runs/" + f"{receipt['occurrence']['run_id']}/output/current/" + ) + ) + self.assertNotIn("root=", candidate["media"][field]) + self.assertNotIn(str(self.comfy_dir), candidate["media"][field]) + + async def test_complete_zero_incomplete_unavailable_and_not_captured_are_distinct( + self, + ): + cases = [ + ((), "complete", "none_reported", 0, "complete"), + ((self.ref(),), "partial", "limit_reached", 1, "incomplete"), + ((), "unavailable", "unavailable", 0, "incomplete"), + (None, "not_captured", None, None, "not_captured"), + ] + for item, (refs, state, reason, count, status) in enumerate(cases): + start = self.capture(refs, item, state, reason) + response, page = await self.outputs(start) + self.assertEqual(response.status, 200, page) + self.assertEqual(page["capture"]["state"], state) + self.assertEqual(page["retained_occurrence_count"], count) + self.assertEqual(page["list_status"], status) + self.assertFalse(page["has_more"]) + + async def test_epoch_limits_cursor_scope_and_header_conflict(self): + start = self.capture([self.ref("1"), self.ref("2")]) + allowed, page = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "1"} + ) + self.assertEqual(allowed.status, 200, page) + for params in ( + {}, + {"epoch": "bad"}, + {"epoch": start.storage_epoch, "limit": "0"}, + {"epoch": start.storage_epoch, "limit": "101"}, + {"epoch": start.storage_epoch, "cursor": "bad"}, + ): + response, error = await self.outputs(start, params) + self.assertEqual(response.status, 400, error) + token = page["next_cursor"] + other = self.capture([self.ref()], item=1) + response, error = await self.outputs( + other, {"epoch": start.storage_epoch, "cursor": token} + ) + self.assert_error(response, error, 400, "invalid-cursor") + finish = make_finish( + SESSION, 0, start.storage_epoch, SimpleNamespace(messages=[]), 0 + ) + self.api.db.write_execution_outputs_for_finish( + finish, + OutputBundle((self.ref(filename="other.png"),), "complete", "reported"), + ) + response, error = await self.outputs( + start, {"epoch": start.storage_epoch, "cursor": token} + ) + self.assert_error(response, error, 409, "capture-changed") + _, current = await self.outputs(start) + self.assertEqual(current["capture"]["state"], "conflicting") + self.assertEqual(len(current["items"]), 2) + source = self.restore_fixture() + self.api.db.model.restore_from_file(source) + response, error = await self.outputs(start) + self.assert_error(response, error, 409, "history-changed") + + async def test_metadata_free_malformed_and_replacement_remain_read_only(self): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + self.api.db.write_execution_contexts( + start.run_id, + start.storage_epoch, + context_bundle("Frozen-only negative", "Frozen-only positive"), + ) + frozen_outputs = self.api.db.get_execution_outputs(start.run_id) + before = self.saved_state() + for metadata in ( + {}, + {"prompt": "{malformed"}, + {"prompt": graph("Current file prompt")}, + ): + png(path, **metadata) + descriptor = await self.media(start) + response = await self.current(descriptor["url"]) + self.assertEqual(response.status, 200) + self.assertEqual(await response.read(), path.read_bytes()) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + identity = body["current_output"] + self.assertEqual(body.get("history_version"), 1) + self.assertEqual(body.get("history_epoch"), start.storage_epoch) + self.assertEqual(identity["occurrence"], descriptor["occurrence"]) + self.assertEqual(identity["reference"], descriptor["reference"]) + self.assertEqual(identity["history_epoch"], start.storage_epoch) + self.assertEqual(identity["current_scope"], descriptor["current_scope"]) + self.assertEqual(identity["location_key"], descriptor["location_key"]) + self.assertEqual(identity["file_identity"], "unverified") + self.assertEqual(body["file"]["observation"], "current-file") + self.assertNotIn("Frozen-only negative", json.dumps(body)) + self.assertNotIn("Frozen-only positive", json.dumps(body)) + self.assertEqual(self.saved_state(), before) + self.assertEqual(body["prompts"]["positive"]["text"], "Current file prompt") + self.assertEqual( + self.api.db.get_execution_outputs(start.run_id), frozen_outputs + ) + + async def test_exact_gallery_matches_do_not_choose_duplicate_owners_or_basename( + self, + ): + path = make_png(self.output_dir / "image.png") + other = self.temp_dir / path.name + make_png(other) + self._link_image(other, "Other root") + start = self.capture([self.ref()]) + _, page = await self.outputs(start) + self.assertEqual(page["items"][0]["current_candidate"]["gallery_match"], "none") + first = self._link_image(path, "first owner") + _, page = await self.outputs(start) + self.assertEqual( + page["items"][0]["current_candidate"]["gallery_image_id"], first + ) + self._link_image(path, "second owner") + descriptor = await self.media(start) + _, page = await self.outputs(start) + candidate = page["items"][0]["current_candidate"] + self.assertEqual(candidate["gallery_match"], "ambiguous") + self.assertIsNone(candidate["gallery_image_id"]) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["library"]["status"], "ambiguous") + self.assertIsNone(body["library"].get("positive_id")) + + async def test_scope_reconfiguration_invalidates_urls_without_root_index_fallback( + self, + ): + make_png(self.output_dir / "image.png") + make_png(self.temp_dir / "image.png", color=(0, 255, 0)) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + self.api._get_all_output_dirs = lambda: [self.temp_dir, self.output_dir] + for field in ("url", "thumbnail_url", "inspection_url"): + response = await self.current(descriptor[field]) + self.assert_error(response, await response.json(), 409, "candidate-stale") + current = await self.media(start) + response = await self.current(current["url"]) + self.assertEqual( + await response.read(), (self.output_dir / "image.png").read_bytes() + ) + self.api._get_all_output_dirs = lambda: [self.output_dir, self.temp_dir] + response = await self.current(current["url"]) + self.assertEqual(response.status, 200) + self.assertEqual( + await response.read(), (self.output_dir / "image.png").read_bytes() + ) + self.host_output = str(self.temp_dir) + _, page = await self.outputs(start) + self.assertEqual( + page["items"][0]["current_candidate"]["reason"], "namespace_mismatch" + ) + + async def test_occurrence_query_is_strict_and_viewer_cachebuster_is_compatible( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + allowed = await self.current(descriptor["url"] + "&_pm_viewer=123-2") + self.assertEqual(allowed.status, 200) + await allowed.read() + path = descriptor["url"].split("?")[0] + base = { + "node": "1", + "ordinal": "0", + "epoch": start.storage_epoch, + "current_scope": descriptor["current_scope"], + } + for key in base: + params = dict(base) + del params[key] + response = await self.current(path, params=params) + self.assertEqual(response.status, 400) + await response.read() + for values in ( + {"node": ""}, + {"node": "x" * 129}, + {"node": "\0"}, + {"ordinal": "256"}, + {"ordinal": "01"}, + {"current_scope": "A" * 64}, + {"current_scope": "a" * 63}, + ): + response = await self.current(path, params={**base, **values}) + self.assertEqual(response.status, 400, values) + await response.read() + response = await self.current(descriptor["url"] + "&node=1") + self.assertEqual(response.status, 400) + await response.read() + + async def test_media_ranges_head_and_existing_thumbnail_without_generation(self): + path = self.output_dir / "clip.mp4" + payload = bytes(range(256)) * 600 + path.write_bytes(payload) + start = self.capture([self.ref(filename=path.name)]) + descriptor = await self.media(start) + self.assertTrue(descriptor["is_video"]) + for header, status, expected in ( + (None, 200, payload), + ("bytes=3-7", 206, payload[3:8]), + ("bytes=-4", 206, payload[-4:]), + ("bytes=153599-", 206, payload[-1:]), + ): + response = await self.current( + descriptor["url"], headers={} if header is None else {"Range": header} + ) + self.assertEqual(response.status, status) + self.assertEqual(await response.read(), expected) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertEqual(response.headers.get("Accept-Ranges"), "bytes") + for header in ("bytes=999999-", "bytes=0-2,5-9", "bytes=bad", "bytes=-0"): + response = await self.current(descriptor["url"], headers={"Range": header}) + self.assertEqual(response.status, 416, header) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + await response.read() + response = await self.current(descriptor["url"], method="HEAD") + self.assertEqual(response.status, 200) + self.assertEqual(int(response.headers["Content-Length"]), len(payload)) + self.assertEqual(await response.read(), b"") + response = await self.current(descriptor["thumbnail_url"]) + self.assertEqual(await response.read(), payload) + preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.jpg") + response = await self.current(descriptor["thumbnail_url"]) + self.assertEqual(await response.read(), preview.read_bytes()) + + async def test_saved_lookup_failure_preserves_receipts_and_file_evidence( + self, + ): + png(self.output_dir / "image.png", prompt=graph("File evidence")) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + history = importlib.import_module("py.api.history") + with ( + self.assertLogs("prompt_manager.api", "WARNING"), + patch.object( + history, + "read_history_gallery", + side_effect=sqlite3.OperationalError("private/path"), + ), + ): + response, page = await self.outputs(start) + self.assertEqual(response.status, 200, page) + self.assertEqual(len(page["items"]), 1) + self.assertEqual(page["items"][0]["current_candidate"]["status"], "error") + self.assertNotIn("media", page["items"][0]["current_candidate"]) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["prompts"]["positive"]["text"], "File evidence") + self.assertEqual(body["library"]["status"], "unavailable") + + async def test_restore_during_candidate_stat_rejects_page(self): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + source = self.restore_fixture() + original = Path.stat + armed = True + + def stat(candidate, *args, **kwargs): + nonlocal armed + result = original(candidate, *args, **kwargs) + if candidate == path and armed: + armed = False + self.api.db.model.restore_from_file(source) + return result + + with patch.object(Path, "stat", stat): + response, body = await self.outputs(start) + self.assert_error(response, body, 409, "history-changed") + + async def test_scope_change_during_candidate_stat_rejects_page(self): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + original = Path.stat + armed = True + + def stat(candidate, *args, **kwargs): + nonlocal armed + result = original(candidate, *args, **kwargs) + if candidate == path and armed: + armed = False + self.host_output = str(self.temp_dir) + return result + + with patch.object(Path, "stat", stat): + response, body = await self.outputs(start) + self.assert_error(response, body, 409, "candidate-stale") + + async def test_restore_during_metadata_read_rejects_inspection(self): + path = png(self.output_dir / "image.png", prompt=graph()) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + source = self.restore_fixture() + original = Path.open + armed = True + handles = [] + + def opened(candidate, *args, **kwargs): + nonlocal armed + handle = original(candidate, *args, **kwargs) + if candidate == path and args and args[0] == "rb": + handles.append(handle) + if armed: + armed = False + self.api.db.model.restore_from_file(source) + return handle + + with patch.object(Path, "open", opened): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "history-changed") + self.assertTrue(handles) + self.assertTrue(all(handle.closed for handle in handles)) + + async def test_restore_during_open_closes_handle_before_error_headers(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + source = self.restore_fixture() + mod = importlib.import_module("py.api.history_current") + original = mod._open_current_target + handles = [] + + def opened(*args, **kwargs): + handle = original(*args, **kwargs) + handles.append(handle) + self.api.db.model.restore_from_file(source) + return handle + + with patch.object(mod, "_open_current_target", side_effect=opened): + response = await self.current(descriptor["url"]) + body = await response.json() + self.assert_error(response, body, 409, "history-changed") + self.assertTrue(all(handle.closed for handle in handles)) + + async def test_primary_projection_and_fence_failure_never_return_empty_success( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + self.assertEqual((await self.outputs(start))[0].status, 200) + with ( + self.assertLogs("prompt_manager.api", "WARNING"), + patch.object( + self.api.db, + "list_history_outputs", + side_effect=sqlite3.OperationalError("private"), + ), + ): + response, body = await self.outputs(start) + self.assert_error(response, body, 503, "storage-unavailable") + with ( + self.assertLogs("prompt_manager.api", "WARNING"), + patch.object( + self.api._history_fence(), + "validate", + side_effect=sqlite3.DatabaseError("private"), + ), + ): + response, body = await self.outputs(start) + self.assert_error(response, body, 503, "storage-unavailable") + + async def test_page_deduplicates_locators_and_closes_connection_before_io( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref("1", i) for i in range(105)]) + self.assertEqual((await self.outputs(start))[0].status, 200) + history = importlib.import_module("py.api.history") + original = history.resolve_current_candidate + locators = [] + + def resolved(reference, scope, gallery): + self.assertIsNone(getattr(self.api.db.model._local, "conn", None)) + locators.append(reference) + return original(reference, scope, gallery) + + with patch.object(history, "resolve_current_candidate", side_effect=resolved): + response, body = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assertEqual(response.status, 200, body) + self.assertEqual(len(body["items"]), 100) + self.assertEqual(len(locators), 1) + + async def test_portable_utf8_page_budget_recovers_every_receipt_on_native_files( + self, + ): + make_png(self.output_dir / "native-猫.png") + start = self.capture( + [self.ref("\x01\n猫" * 30, i, filename="native-猫.png") for i in range(100)] + ) + response, page = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assertEqual(response.status, 200, page) + self.assertTrue( + all( + item["current_candidate"]["status"] == "available" + for item in page["items"] + ) + ) + self.assertEqual( + page["items"][0]["reference"]["path_style"], + "windows" if os.name == "nt" else "posix", + ) + encoded = await response.read() + self.assertIn(b"\\u0001", encoded) + self.assertIn(b"%01", encoded) + module = importlib.import_module("py.api.history") + with patch.object(module, "_OUTPUTS_MAX_BYTES", len(encoded)): + response, _ = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assertEqual(response.status, 200) + with patch.object(module, "_OUTPUTS_MAX_BYTES", len(encoded) - 1): + response, error = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assert_error(response, error, 422, "response-limit") + self.assertEqual( + error["recovery"], {"action": "smaller-limit", "limit": 50} + ) + retained = [] + cursor = None + for _ in range(2): + params = {"epoch": start.storage_epoch, "limit": "50"} + if cursor: + params["cursor"] = cursor + response, smaller = await self.outputs(start, params) + self.assertEqual(response.status, 200, smaller) + retained.extend(item["occurrence"] for item in smaller["items"]) + cursor = smaller["next_cursor"] + self.assertIsNone(cursor) + self.assertEqual(retained, [item["occurrence"] for item in page["items"]]) + with patch.object(module, "_OUTPUTS_MAX_BYTES", 1): + response, error = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "1"} + ) + self.assert_error(response, error, 422, "response-limit") + self.assertIsNone(error["recovery"]) + + @skipUnless(os.name != "nt", "Control-character physical filenames require POSIX") + async def test_actual_control_heavy_page_uses_serialized_bytes_and_smaller_retry( + self, + ): + name = "\x01\n猫.png" + make_png(self.output_dir / name) + refs = [self.ref("\x01\n猫" * 40, i, filename=name) for i in range(40)] + start = self.capture(refs) + response, body = await self.outputs(start) + self.assertEqual(response.status, 200, body) + encoded = await response.read() + self.assertIn(b"\\u0001", encoded) + self.assertIn(b"%01", encoded) + module = importlib.import_module("py.api.history") + with patch.object(module, "_OUTPUTS_MAX_BYTES", len(encoded)): + response, same = await self.outputs(start) + self.assertEqual(response.status, 200, same) + with patch.object(module, "_OUTPUTS_MAX_BYTES", len(encoded) - 1): + response, error = await self.outputs(start) + self.assert_error(response, error, 422, "response-limit") + self.assertEqual( + error["recovery"], {"action": "smaller-limit", "limit": 20} + ) + response, smaller = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "20"} + ) + self.assertEqual(response.status, 200, smaller) + self.assertEqual(len(smaller["items"]), 20) + with patch.object(module, "_OUTPUTS_MAX_BYTES", 1): + response, error = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "1"} + ) + self.assert_error(response, error, 422, "response-limit") + self.assertIsNone(error["recovery"]) + + @skipUnless(os.name != "nt", "Control-character physical paths require POSIX") + async def test_legal_capture_can_exceed_one_mib_and_recover_without_lost_receipts( + self, + ): + subfolder = "/".join(["\x01" * 175] * 4) + filename = "\x02" * 20 + ".png" + make_png(self.output_dir / subfolder / filename) + start = self.capture( + [ + self.ref("\x03" * 128, i, filename, subfolder=subfolder) + for i in range(100) + ] + ) + response, error = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assert_error(response, error, 422, "response-limit") + self.assertEqual(error["recovery"], {"action": "smaller-limit", "limit": 50}) + response, page = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "50"} + ) + self.assertEqual(response.status, 200, page) + self.assertLessEqual(len(await response.read()), 1024 * 1024) + self.assertEqual(len(page["items"]), 50) + response, next_page = await self.outputs( + start, + { + "epoch": start.storage_epoch, + "limit": "50", + "cursor": page["next_cursor"], + }, + ) + self.assertEqual(response.status, 200, next_page) + self.assertEqual( + [ + r["occurrence"]["image_ordinal"] + for r in page["items"] + next_page["items"] + ], + list(range(100)), + ) + + async def test_native_alias_allowed_and_foreign_null_temp_denials_keep_receipts( + self, + ): + path = make_png(self.output_dir / "image.png") + alias = self.comfy_dir / "alias" + alias.symlink_to(self.output_dir, target_is_directory=True) + self.host_output = str(alias) + allowed = self.capture([self.ref()]) + descriptor = await self.media(allowed) + response = await self.current(descriptor["url"]) + self.assertEqual(response.status, 200) + self.assertEqual(await response.read(), path.read_bytes()) + make_png(self.temp_dir / "image.png") + temp_key = snapshot_output_roots( + "windows" if os.name == "nt" else "posix", self.host_output, self.host_temp + ).temp_key + denied = self.capture( + [ + self.ref("1", root_key=None), + self.ref( + "2", + path_style="posix" if os.name == "nt" else "windows", + root_key="a" * 64, + ), + self.ref("3", folder_type="temp", root_key=temp_key), + ], + item=1, + state="partial", + reason="root_unavailable", + ) + response, page = await self.outputs(denied) + self.assertEqual(response.status, 200, page) + self.assertEqual( + [r["current_candidate"]["reason"] for r in page["items"]], + ["root_unavailable", "foreign_path_style", "not_authorized"], + ) + self.assertTrue( + all("media" not in r["current_candidate"] for r in page["items"]) + ) + self.assertTrue(page["items"][2]["transient"]) + + async def test_unavailable_files_keep_receipts_and_current_routes_fail_closed(self): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + path.unlink() + response, page = await self.outputs(start) + self.assertEqual(page["items"][0]["current_candidate"]["reason"], "missing") + for field in ("url", "thumbnail_url", "inspection_url"): + response = await self.current(descriptor[field]) + self.assert_error(response, await response.json(), 404, "not-found") + path.mkdir() + _, page = await self.outputs(start) + self.assertEqual( + page["items"][0]["current_candidate"]["reason"], "not_regular_file" + ) + path.rmdir() + path.write_bytes(b"media") + with patch("os.access", return_value=False): + _, page = await self.outputs(start) + self.assertEqual( + page["items"][0]["current_candidate"]["reason"], "unreadable" + ) + response = await self.current(descriptor["url"]) + self.assert_error(response, await response.json(), 403, "not-authorized") + path.unlink() + outside = make_png(self.temp_dir / "private.png") + path.symlink_to(outside) + _, page = await self.outputs(start) + self.assertEqual( + page["items"][0]["current_candidate"]["reason"], "not_authorized" + ) + unsupported = self.capture([self.ref(filename="notes.txt")], item=1) + (self.output_dir / "notes.txt").symlink_to(outside) + self.api._get_all_output_dirs = lambda: [self.output_dir, self.temp_dir] + _, page = await self.outputs(unsupported) + self.assertEqual( + page["items"][0]["current_candidate"]["reason"], "unsupported_media" + ) + + async def test_scope_change_during_metadata_read_and_open_rejects_before_headers( + self, + ): + png(self.output_dir / "image.png", prompt=graph()) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + service = importlib.import_module("py.api.inspection_service") + read = service.read_inspection_file + + def changed(path): + result = read(path) + self.host_output = str(self.temp_dir) + return result + + with patch.object(service, "read_inspection_file", side_effect=changed): + response = await self.current(descriptor["inspection_url"]) + self.assert_error(response, await response.json(), 409, "candidate-stale") + self.host_output = str(self.output_dir) + mod = importlib.import_module("py.api.history_current") + opened = mod._open_current_target + handles = [] + + def changed_open(*args, **kwargs): + handle = opened(*args, **kwargs) + handles.append(handle) + self.host_output = str(self.temp_dir) + return handle + + with patch.object(mod, "_open_current_target", side_effect=changed_open): + response = await self.current(descriptor["url"]) + self.assert_error(response, await response.json(), 409, "candidate-stale") + self.assertTrue(all(handle.closed for handle in handles)) + + async def test_canonical_target_symlink_retarget_at_open_never_opens_replacement( + self, + ): + path = make_png(self.output_dir / "image.png") + outside = make_png(self.temp_dir / "private.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + mod = importlib.import_module("py.api.history_current") + opened = mod._open_current_target + + def retarget(target, *args, **kwargs): + path.unlink() + path.symlink_to(outside) + return opened(target, *args, **kwargs) + + with patch.object(mod, "_open_current_target", side_effect=retarget): + response = await self.current(descriptor["url"]) + self.assert_error(response, await response.json(), 409, "candidate-stale") + + async def test_late_open_handle_closes_when_request_is_cancelled_during_executor( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + request = make_mocked_request( + "GET", descriptor["url"], match_info={"run_id": start.run_id} + ) + mod = importlib.import_module("py.api.history_current") + history = importlib.import_module("py.api.history") + opened = mod._open_current_target + discard = history._close_prepared_result + reached, closed = asyncio.Event(), asyncio.Event() + release = threading.Event() + loop = asyncio.get_running_loop() + handles = [] + + def barrier(*args, **kwargs): + handle = opened(*args, **kwargs) + handles.append(handle) + loop.call_soon_threadsafe(reached.set) + if not release.wait(5): + handle.close() + raise AssertionError("late-open barrier timed out") + return handle + + def discarded(task): + discard(task) + closed.set() + + with ( + patch.object(mod, "_open_current_target", side_effect=barrier), + patch.object(history, "_close_prepared_result", side_effect=discarded), + ): + pending = asyncio.create_task(self.api.get_history_current_media(request)) + try: + await asyncio.wait_for(reached.wait(), 5) + pending.cancel() + with self.assertRaises(asyncio.CancelledError): + await asyncio.wait_for(pending, 5) + finally: + release.set() + await asyncio.wait_for(closed.wait(), 5) + self.assertEqual(len(handles), 1) + self.assertTrue(handles[0].closed) + + async def test_opened_handle_closes_if_stream_is_cancelled_before_headers(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + request = make_mocked_request( + "GET", descriptor["url"], match_info={"run_id": start.run_id} + ) + mod = importlib.import_module("py.api.history_current") + opened = mod._open_current_target + handles = [] + + def tracked(*args, **kwargs): + handle = opened(*args, **kwargs) + handles.append(handle) + return handle + + with ( + patch.object(mod, "_open_current_target", side_effect=tracked), + patch.object( + web.StreamResponse, "prepare", side_effect=asyncio.CancelledError + ), + ): + with self.assertRaises(asyncio.CancelledError): + await self.api.get_history_current_media(request) + self.assertEqual(len(handles), 1) + self.assertTrue(handles[0].closed) + + async def test_restore_after_final_fence_finishes_coherent_already_opened_media( + self, + ): + path = make_png(self.output_dir / "image.png") + expected = path.read_bytes() + start = self.capture([self.ref()]) + descriptor = await self.media(start) + source = self.restore_fixture() + fence = self.api._history_fence() + validate = fence.validate + mod = importlib.import_module("py.api.history_current") + close = mod._OpenedCurrentMedia.close + closed = asyncio.Event() + loop = asyncio.get_running_loop() + handles = [] + blocked_unlinks = [] + + def track_close(opened): + close(opened) + handles.append(opened.handle) + loop.call_soon_threadsafe(closed.set) + + def after(epoch, generation): + validate(epoch, generation) + self.api.db.model.restore_from_file(source) + try: + path.unlink() + except PermissionError: + if os.name != "nt": + raise + self.assertTrue(path.exists()) + blocked_unlinks.append(path) + + with ( + patch.object(fence, "validate", side_effect=after), + patch.object(mod._OpenedCurrentMedia, "close", track_close), + ): + response = await self.current(descriptor["url"]) + self.assertEqual(response.status, 200) + self.assertEqual(await response.read(), expected) + await asyncio.wait_for(closed.wait(), 5) + self.assertEqual(len(handles), 1) + self.assertTrue(handles[0].closed) + for locked_path in blocked_unlinks: + locked_path.unlink() + self.assertFalse(path.exists()) + response = await self.current(descriptor["url"]) + self.assert_error(response, await response.json(), 409, "history-changed") + + async def test_current_inspection_shares_response_limit_and_saved_display_bound( + self, + ): + path = png(self.output_dir / "image.png", prompt=graph("File evidence")) + image = self._link_image(path, "Saved text is never frozen-run text") + with self.api.db.model.get_connection() as conn: + conn.execute( + "UPDATE prompts SET category=? WHERE id=" + "(SELECT prompt_id FROM generated_images WHERE id=?)", + ("猫" * 12000, image), + ) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["library"]["status"], "limited") + self.assertEqual(body["prompts"]["positive"]["text"], "File evidence") + with patch("utils.inspection_response.MAX_RESPONSE_BYTES", 1): + response = await self.current(descriptor["inspection_url"]) + self.assert_error(response, await response.json(), 422, "response-limit") + + async def test_optional_candidate_failure_preserves_every_occurrence_without_urls( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref("1"), self.ref("2")]) + self.assertEqual((await self.outputs(start))[0].status, 200) + history = importlib.import_module("py.api.history") + with patch.object( + history, "resolve_current_candidate", side_effect=OSError("private/path") + ): + response, page = await self.outputs(start) + self.assertEqual(response.status, 200, page) + self.assertEqual(len(page["items"]), 2) + self.assertEqual(page["retained_occurrence_count"], 2) + for receipt in page["items"]: + self.assertEqual( + receipt["current_candidate"], + { + "status": "error", + "reason": "temporary_unavailable", + "evidence_class": "locator_candidate", + }, + ) + self.assertEqual(receipt["file_identity"], "unverified") + self.assertNotIn("private", json.dumps(page)) + + async def test_page_observes_at_most_one_hundred_distinct_targets(self): + for ordinal in range(105): + make_png(self.output_dir / f"image-{ordinal}.png") + start = self.capture([self.ref("1", i, f"image-{i}.png") for i in range(105)]) + observed = [] + stat = Path.stat + + def traced(path, *args, **kwargs): + if path.parent == self.output_dir and path.name.startswith("image-"): + observed.append(path.name) + return stat(path, *args, **kwargs) + + with patch.object(Path, "stat", traced): + response, page = await self.outputs( + start, {"epoch": start.storage_epoch, "limit": "100"} + ) + self.assertEqual(response.status, 200, page) + self.assertEqual(len(page["items"]), 100) + self.assertEqual(observed, [f"image-{i}.png" for i in range(100)]) + + async def test_inspection_retarget_during_read_rejects_metadata(self): + path = png(self.output_dir / "image.png", prompt=graph("Authorized")) + outside = png(self.temp_dir / "private.png", prompt=graph("Private")) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + service = importlib.import_module("py.api.inspection_service") + read = service.read_inspection_file + + def changed(target): + result = read(target) + path.unlink() + path.symlink_to(outside) + return result + + with patch.object(service, "read_inspection_file", side_effect=changed): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertNotIn("Authorized", json.dumps(body)) + self.assertNotIn("Private", json.dumps(body)) + + async def test_stale_epoch_rejects_urls_without_unbounded_saved_lookup(self): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + with patch.object( + self.api.db, + "get_image_prompt_info", + side_effect=AssertionError("unbounded convenience lookup"), + ): + descriptor = await self.media(start) + response = await self.current(descriptor["inspection_url"]) + self.assertEqual(response.status, 200) + await response.read() + source = self.restore_fixture() + self.api.db.model.restore_from_file(source) + for field in ("url", "thumbnail_url", "inspection_url"): + response = await self.current(descriptor[field]) + self.assert_error(response, await response.json(), 409, "history-changed") + + async def test_existing_authorized_preview_alias_is_allowed_and_escape_is_denied( + self, + ): + make_png(self.output_dir / "image.png") + preview = make_png( + self.output_dir / "previews" / "image_thumb.png", color=(0, 255, 0) + ) + alias = self.output_dir / "thumbnails" + alias.symlink_to(preview.parent, target_is_directory=True) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + response = await self.current(descriptor["thumbnail_url"]) + self.assertEqual(response.status, 200, await response.read()) + self.assertEqual(await response.read(), preview.read_bytes()) + make_png(self.temp_dir / "image_thumb.png") + alias.unlink() + alias.symlink_to(self.temp_dir, target_is_directory=True) + response = await self.current(descriptor["thumbnail_url"]) + self.assert_error(response, await response.json(), 403, "not-authorized") + + async def test_disappearance_before_open_is_missing_and_closes_handle( + self, + ): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + mod = importlib.import_module("py.api.history_current") + opened = mod._open_current_target + + def removed(target, *args, **kwargs): + path.unlink() + return opened(target, *args, **kwargs) + + with patch.object(mod, "_open_current_target", side_effect=removed): + response = await self.current(descriptor["url"]) + self.assert_error(response, await response.json(), 404, "not-found") + + async def test_unexpected_inspection_failure_is_bounded_and_does_not_log_paths( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + private = "SECRET_PROMPT /private/root/image.png" + with patch( + "py.api.inspection_service.read_inspection_file", + side_effect=RuntimeError(private), + ): + with self.assertLogs("prompt_manager.api", "WARNING") as logs: + response = await self.current(descriptor["inspection_url"]) + self.assertEqual(response.content_type, "application/json") + body = await response.json() + self.assert_error(response, body, 503, "storage-unavailable") + self.assertNotIn(private, json.dumps(body) + " ".join(logs.output)) + + async def test_changed_epoch_or_scope_wins_over_failed_metadata_observation(self): + png(self.output_dir / "image.png", prompt=graph()) + service = importlib.import_module("py.api.inspection_service") + read = service.read_inspection_file + for item, code in enumerate(("history-changed", "candidate-stale")): + with self.subTest(code=code): + start = self.capture([self.ref()], item=item) + descriptor = await self.media(start) + source = self.restore_fixture() + + def failed(path): + read(path) + if code == "history-changed": + self.api.db.model.restore_from_file(source) + else: + self.host_output = str(self.temp_dir) + raise RuntimeError("private observation failure") + + with patch.object(service, "read_inspection_file", side_effect=failed): + response = await self.current(descriptor["inspection_url"]) + self.assert_error(response, await response.json(), 409, code) + + async def test_current_media_open_io_failure_is_retryable_without_private_paths( + self, + ): + make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + response = await self.current(descriptor["url"]) + self.assertEqual(response.status, 200) + await response.read() + with patch( + "py.api.history_current.os.open", + side_effect=OSError(errno.EIO, "private/root"), + ): + response = await self.current(descriptor["url"]) + body = await response.json() + self.assert_error(response, body, 503, "storage-unavailable") + self.assertEqual(body["recovery"], {"action": "retry-read"}) + + async def test_inspection_rejects_directory_replacement_before_parser(self): + path = png(self.output_dir / "image.png", prompt=graph("Current portrait")) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["prompts"]["positive"]["text"], "Current portrait") + service = importlib.import_module("py.api.inspection_service") + inspect = service.inspect_current_file + read = service.read_inspection_file + reads = [] + + def replaced(target, **kwargs): + path.unlink() + path.mkdir() + return inspect(target, **kwargs) + + def traced(target): + reads.append(target) + return read(target) + + with patch("py.api.history.inspect_current_file", side_effect=replaced): + with patch.object(service, "read_inspection_file", side_effect=traced): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertEqual(reads, [], "Nonregular targets must not reach the parser") + self.assertTrue(path.is_dir()) + self.assertNotIn("Current portrait", json.dumps(body)) + + @skipUnless(os.name == "posix", "FIFO replacement is a POSIX filesystem case") + async def test_inspection_rejects_fifo_replacement_before_parser(self): + path = make_png(self.output_dir / "image.png") + start = self.capture([self.ref()]) + descriptor = await self.media(start) + response = await self.current(descriptor["inspection_url"]) + self.assertEqual(response.status, 200, await response.json()) + service = importlib.import_module("py.api.inspection_service") + inspect = service.inspect_current_file + reads = [] + + def replaced(target, **kwargs): + path.unlink() + os.mkfifo(path) + return inspect(target, **kwargs) + + def forbidden(target): + reads.append(target) + raise AssertionError("FIFO must be rejected before a metadata open") + + with patch("py.api.history.inspect_current_file", side_effect=replaced): + with patch.object(service, "read_inspection_file", side_effect=forbidden): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertEqual(reads, [], "A FIFO must never reach the blocking parser") + + async def test_inspection_rejects_unreadable_file_before_parser(self): + path = png(self.output_dir / "image.png", prompt=graph("Readable portrait")) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + service = importlib.import_module("py.api.inspection_service") + inspect = service.inspect_current_file + read = service.read_inspection_file + access = os.access + revoked = False + reads = [] + + def unreadable(target, **kwargs): + nonlocal revoked + revoked = True + return inspect(target, **kwargs) + + def current_access(target, mode, **kwargs): + if revoked and Path(target) == path: + return False + return access(target, mode, **kwargs) + + def traced(target): + reads.append(target) + return read(target) + + with patch("py.api.history.inspect_current_file", side_effect=unreadable): + with patch("os.access", side_effect=current_access): + with patch.object(service, "read_inspection_file", side_effect=traced): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertEqual(reads, [], "Unreadable targets must not reach the parser") + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["prompts"]["positive"]["text"], "Readable portrait") + + async def test_inspection_rejects_directory_replacement_after_parser(self): + path = png(self.output_dir / "image.png", prompt=graph("Current portrait")) + start = self.capture([self.ref()]) + descriptor = await self.media(start) + service = importlib.import_module("py.api.inspection_service") + read = service.read_inspection_file + + def replaced(target): + result = read(target) + path.unlink() + path.mkdir() + return result + + with patch.object(service, "read_inspection_file", side_effect=replaced): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertNotIn("Current portrait", json.dumps(body)) + + async def test_inspection_rejects_readability_revoked_after_parser(self): + path = png(self.output_dir / "image.png", prompt=graph("Readable portrait")) + before = path.read_bytes() + start = self.capture([self.ref()]) + descriptor = await self.media(start) + service = importlib.import_module("py.api.inspection_service") + read = service.read_inspection_file + access = os.access + revoked = False + + def changed(target): + nonlocal revoked + result = read(target) + revoked = True + return result + + def current_access(target, mode, **kwargs): + if revoked and Path(target) == path: + return False + return access(target, mode, **kwargs) + + with patch.object(service, "read_inspection_file", side_effect=changed): + with patch("os.access", side_effect=current_access): + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assert_error(response, body, 409, "candidate-stale") + self.assertNotIn("Readable portrait", json.dumps(body)) + self.assertEqual(path.read_bytes(), before) + response = await self.current(descriptor["inspection_url"]) + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(body["prompts"]["positive"]["text"], "Readable portrait") diff --git a/tests/test_api_images.py b/tests/test_api_images.py index 5221c82..2e5d073 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -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( diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py index a297b5a..ea6743c 100644 --- a/tests/test_api_prompt_inspector.py +++ b/tests/test_api_prompt_inspector.py @@ -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"), ), ): diff --git a/tests/test_execution_history_restore.py b/tests/test_execution_history_restore.py index f6d5326..1d93e2a 100644 --- a/tests/test_execution_history_restore.py +++ b/tests/test_execution_history_restore.py @@ -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, ): diff --git a/tests/test_history_current.py b/tests/test_history_current.py new file mode 100644 index 0000000..18589ad --- /dev/null +++ b/tests/test_history_current.py @@ -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) diff --git a/tests/test_history_read_fence.py b/tests/test_history_read_fence.py new file mode 100644 index 0000000..9e60dd5 --- /dev/null +++ b/tests/test_history_read_fence.py @@ -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) diff --git a/tests/test_history_reads.py b/tests/test_history_reads.py new file mode 100644 index 0000000..734c1f8 --- /dev/null +++ b/tests/test_history_reads.py @@ -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)) + ) diff --git a/tests/test_inspection_response.py b/tests/test_inspection_response.py index 0488abb..79c81d6 100644 --- a/tests/test_inspection_response.py +++ b/tests/test_inspection_response.py @@ -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) diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 8e87063..0c97b09 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -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/); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ceeb6ab..ce0a193 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/utils/history_read_fence.py b/utils/history_read_fence.py new file mode 100644 index 0000000..dd78c61 --- /dev/null +++ b/utils/history_read_fence.py @@ -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() diff --git a/utils/inspection_response.py b/utils/inspection_response.py index b7f5162..a7cc641 100644 --- a/utils/inspection_response.py +++ b/utils/inspection_response.py @@ -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, + ) diff --git a/web/js/api-client.js b/web/js/api-client.js index e4d4aa4..23d65fa 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -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; } diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index b3edb3f..012ddcb 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -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], diff --git a/web/js/workspace/history-media.mjs b/web/js/workspace/history-media.mjs new file mode 100644 index 0000000..387a949 --- /dev/null +++ b/web/js/workspace/history-media.mjs @@ -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()]; +} diff --git a/web/js/workspace/history-state.mjs b/web/js/workspace/history-state.mjs new file mode 100644 index 0000000..4d4fda0 --- /dev/null +++ b/web/js/workspace/history-state.mjs @@ -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});}, + }; +} diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs new file mode 100644 index 0000000..4aadedb --- /dev/null +++ b/web/js/workspace/history.mjs @@ -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}; +} diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 6efd2af..628aaad 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -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() { diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index b0c8405..7a23238 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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; } +} diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index fb3cd8a..02ed149 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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(); diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index f68d072..a3437a4 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -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); } diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 113a791..fc53cff 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -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); } } diff --git a/web/js/workspace/recorded-run.mjs b/web/js/workspace/recorded-run.mjs new file mode 100644 index 0000000..8bfb842 --- /dev/null +++ b/web/js/workspace/recorded-run.mjs @@ -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}; +} diff --git a/web/js/workspace/viewer-state.mjs b/web/js/workspace/viewer-state.mjs index 7a26a16..5a49f7d 100644 --- a/web/js/workspace/viewer-state.mjs +++ b/web/js/workspace/viewer-state.mjs @@ -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 {