Compare commits

...
Author SHA1 Message Date
Vito Sansevero afc77ef4ab 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.
2026-10-18 16:45:19 -07:00
Vito Sansevero 86a3a44f75 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.
2026-10-18 16:00:43 -07:00
Vito Sansevero b27842dc69 fix(history): resolve final chronological review findings
Address #209 recovery, bounded visit restoration, exact receipt availability, and portable regression coverage.
2026-10-18 15:16:42 -07:00
Vito Sansevero c32d01e8aa fix(history): enforce chronological marker visual parity (#209) 2026-10-18 14:21:50 -07:00
Vito Sansevero 3fb608bc21 test(history): verify chronological history across restore and host modes (#209) 2026-10-18 13:51:30 -07:00
Vito Sansevero a3010e151e fix(history): recover receipts and refresh capture evidence (#209) 2026-10-18 12:53:34 -07:00
Vito Sansevero d2f449efc4 feat(history): render recorded executions and frozen run details (#209) 2026-10-18 12:33:30 -07:00
Vito Sansevero 1f62e7880a fix(history): preserve slideshow and clear stale reentry actions (#209) 2026-10-18 12:07:00 -07:00
Vito Sansevero e67b48ed52 feat(history): add scoped history state and current-file adapter (#209) 2026-10-18 11:57:16 -07:00
Vito Sansevero b070d5455f fix(history): reject invalid inspection targets (#209) 2026-10-18 11:45:09 -07:00
Vito Sansevero aa7741000a feat(history): resolve reported outputs through current authorization (#209) 2026-10-18 11:27:24 -07:00
Vito Sansevero 6e30853ed6 fix(history): release retired projection connections (#209) 2026-10-18 10:50:14 -07:00
Vito Sansevero e7c45ca946 feat(history): expose fenced history windows and run details (#209) 2026-10-18 10:50:14 -07:00
Vito Sansevero f9f54c867a fix(history): validate internal continuation positions (#209) 2026-10-18 10:50:14 -07:00
Vito Sansevero 4f82800927 feat(history): add bounded chronological read projections (#209) 2026-10-18 10:50:14 -07:00
54 changed files with 9929 additions and 232 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
viewerjs:
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
+151 -2
View File
@@ -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.
+3
View File
@@ -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
+11
View File
@@ -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)
+127
View File
@@ -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
+527
View File
@@ -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
)
+2
View File
@@ -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."""
+5
View File
@@ -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)
+748
View File
@@ -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)
+399
View File
@@ -0,0 +1,399 @@
"""Native reported locations and already-opened current media, without ownership."""
import asyncio
from dataclasses import dataclass
import errno
import hashlib
import hmac
import json
import mimetypes
import os
from pathlib import Path
import stat
from typing import BinaryIO
from aiohttp import web
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
try:
from ...utils.execution_outputs import (
OutputReference,
OutputRoots,
snapshot_output_roots,
validate_output_reference,
)
except ImportError:
from utils.execution_outputs import (
OutputReference,
OutputRoots,
snapshot_output_roots,
validate_output_reference,
)
_SECRET = os.urandom(32)
_READ_SIZE = 64 * 1024
def _opaque(value: object) -> str:
raw = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii")
return hmac.new(_SECRET, raw, hashlib.sha256).hexdigest()
@dataclass(frozen=True)
class CurrentOutputScope:
path_style: str
lexical_output: str | None
lexical_temp: str | None
roots: OutputRoots
canonical_output: Path | None
canonical_temp: Path | None
authorized_roots: tuple[Path, ...]
current_scope: str
@dataclass(frozen=True)
class ResolvedCurrentCandidate:
public: dict
canonical_target: Path | None = None
namespace: Path | None = None
gallery_root: Path | None = None
class CurrentLocationUnavailable(Exception):
"""A bounded current-file failure without the underlying private path."""
def __init__(self, reason: str, *, stale: bool = False) -> None:
self.reason = reason
self.stale = stale
super().__init__(reason)
def _host_directory(name: str) -> str | None:
try:
import folder_paths
value = getattr(folder_paths, name)()
except (ImportError, AttributeError, OSError):
return None
return value if type(value) is str else None
def snapshot_current_output_scope(api: object) -> CurrentOutputScope:
"""Bind host lexical namespaces independently of current gallery authority."""
from ..config import GalleryConfig
style = "windows" if os.name == "nt" else "posix"
output = _host_directory("get_output_directory")
temp = _host_directory("get_temp_directory")
roots = snapshot_output_roots(style, output, temp)
canonical_output = _canonical(output) if roots.output_key is not None else None
canonical_temp = _canonical(temp) if roots.temp_key is not None else None
authorized = tuple(
sorted({_canonical(path) for path in api._get_all_output_dirs()})
)
policy = []
for entry in GalleryConfig.MONITORING_DIRECTORIES:
valid, _ = GalleryConfig.validate_gallery_root(entry)
policy.append((entry if type(entry) is str else None, valid))
current_scope = _opaque(
[
"prompt-manager-current-output-v1",
style,
output,
temp,
roots.output_key,
roots.temp_key,
str(canonical_output) if canonical_output is not None else None,
str(canonical_temp) if canonical_temp is not None else None,
[str(path) for path in authorized],
sorted(
str(_canonical(path))
for path in GalleryConfig.allowed_gallery_parents()
),
sorted(policy, key=lambda item: (item[0] or "", item[1])),
sorted(str(_canonical(path)) for path in GalleryConfig.path_anchors()),
"gallery-validation-v1",
]
)
return CurrentOutputScope(
style,
output,
temp,
roots,
canonical_output,
canonical_temp,
authorized,
current_scope,
)
def _descriptor_reason(reference: dict, scope: CurrentOutputScope) -> str | None:
try:
validate_output_reference(OutputReference("current", 0, **reference))
except (TypeError, ValueError):
return "namespace_mismatch"
if reference["path_style"] != scope.path_style:
return "foreign_path_style"
folder = reference["folder_type"]
expected = scope.roots.output_key if folder == "output" else scope.roots.temp_key
if reference["root_key"] is None or expected is None:
return "root_unavailable"
if reference["root_key"] != expected:
return "namespace_mismatch"
return None
def candidate_locator(reference: dict, scope: CurrentOutputScope) -> str | None:
"""Validate the native namespace before constructing a lexical path; no IO."""
if _descriptor_reason(reference, scope) is not None:
return None
root = (
scope.lexical_output
if reference["folder_type"] == "output"
else scope.lexical_temp
)
return os.path.normcase(
os.path.normpath(
os.path.join(root, reference["subfolder"], reference["filename"])
)
)
def _authorized(target: Path, namespace: Path, scope: CurrentOutputScope) -> bool:
return target.is_relative_to(namespace) and any(
target.is_relative_to(root) for root in scope.authorized_roots
)
def _unavailable(reason: str, *, error: bool = False) -> ResolvedCurrentCandidate:
return ResolvedCurrentCandidate(
{
"status": "error" if error else "unavailable",
"reason": reason,
"evidence_class": "locator_candidate",
}
)
def resolve_current_candidate(
reference: dict, scope: CurrentOutputScope, gallery: dict
) -> ResolvedCurrentCandidate:
"""Observe only this exact current locator and its snapshot-owned association."""
reason = _descriptor_reason(reference, scope)
if reason is not None:
return _unavailable(reason)
lexical = candidate_locator(reference, scope)
namespace = (
scope.canonical_output
if reference["folder_type"] == "output"
else scope.canonical_temp
)
try:
if Path(reference["filename"]).suffix.lower() not in SERVABLE_EXTENSIONS:
return _unavailable("unsupported_media")
if namespace is None or not namespace.is_dir():
return _unavailable("root_unavailable")
target = _canonical(lexical)
if not _authorized(target, namespace, scope):
return _unavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
return _unavailable("unsupported_media")
observed = target.stat()
if not stat.S_ISREG(observed.st_mode):
return _unavailable("not_regular_file")
if not os.access(target, os.R_OK) or not observed.st_mode & 0o444:
return _unavailable("unreadable")
matching = gallery.get(lexical, {})
root = max(
(root for root in scope.authorized_roots if target.is_relative_to(root)),
key=lambda root: len(root.parts),
)
return ResolvedCurrentCandidate(
{
"status": "available",
"reason": None,
"evidence_class": "locator_candidate",
"gallery_match": matching.get("gallery_match", "none"),
"gallery_image_id": matching.get("gallery_image_id"),
"source_label": _public_path(namespace),
"relative_path": Path(
reference["subfolder"], reference["filename"]
).as_posix(),
"current_scope": scope.current_scope,
"location_key": _opaque(
["current-location-v1", scope.current_scope, str(target)]
),
},
target,
namespace,
root,
)
except FileNotFoundError:
return _unavailable("missing")
except PermissionError:
return _unavailable("unreadable")
except (OSError, ValueError):
return _unavailable("temporary_unavailable", error=True)
def _open_current_target(
target: Path, namespace: Path, scope: CurrentOutputScope
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
raise CurrentLocationUnavailable("unsupported_media")
flags = (
os.O_RDONLY
| getattr(os, "O_BINARY", 0)
| getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_NONBLOCK", 0)
)
try:
fd = os.open(target, flags)
except FileNotFoundError:
raise CurrentLocationUnavailable("missing") from None
except PermissionError:
raise CurrentLocationUnavailable("unreadable") from None
except OSError as error:
if error.errno in (errno.ELOOP, errno.ENOTDIR):
raise CurrentLocationUnavailable("not_authorized", stale=True) from None
raise CurrentLocationUnavailable("temporary_unavailable") from None
try:
observed = os.fstat(fd)
if not stat.S_ISREG(observed.st_mode):
raise CurrentLocationUnavailable("not_regular_file")
if not observed.st_mode & 0o444 or not os.access(target, os.R_OK):
raise CurrentLocationUnavailable("unreadable")
current = os.stat(target, follow_symlinks=False)
if (
_canonical(target) != target
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
or not _authorized(target, namespace, scope)
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
return os.fdopen(fd, "rb")
except FileNotFoundError:
os.close(fd)
raise CurrentLocationUnavailable("missing") from None
except PermissionError:
os.close(fd)
raise CurrentLocationUnavailable("unreadable") from None
except BaseException:
os.close(fd)
raise
def _reauthorize_current_target(
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope
) -> None:
if _canonical(
resolved.canonical_target
) != resolved.canonical_target or not _authorized(
resolved.canonical_target, resolved.namespace, scope
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
@dataclass
class _OpenedCurrentMedia:
handle: BinaryIO
size: int
content_type: str
def close(self) -> None:
self.handle.close()
def _prepare_current_media(
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope, *, thumbnail: bool
) -> _OpenedCurrentMedia:
target = resolved.canonical_target
if thumbnail:
relative = target.relative_to(resolved.gallery_root)
extension = (
".jpg"
if target.suffix.lower() in VIDEO_EXTENSIONS
else target.suffix.lower()
)
preview = (
resolved.gallery_root
/ "thumbnails"
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
)
try:
preview.stat()
except FileNotFoundError:
pass
else:
target = _canonical(preview)
handle = _open_current_target(target, resolved.namespace, scope)
try:
return _OpenedCurrentMedia(
handle,
os.fstat(handle.fileno()).st_size,
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
)
except BaseException:
handle.close()
raise
async def _current_media_response(
request: web.Request, opened: _OpenedCurrentMedia
) -> web.StreamResponse:
"""Stream a bounded single range from a previously authorized handle."""
try:
try:
selected = request.http_range
start, stop = selected.start, selected.stop
partial = request.headers.get("Range") is not None
if (
partial
and request.headers["Range"].startswith("bytes=-")
and start == 0
):
raise ValueError("Empty suffix range")
if start is not None and start < 0:
start = max(0, opened.size + start)
start = start or 0
stop = min(opened.size, stop) if stop is not None else opened.size
if partial and (start >= stop or start >= opened.size):
raise ValueError("Unsatisfiable current media range")
except ValueError:
return web.Response(
status=416,
headers={
"Cache-Control": "no-store",
"Content-Range": f"bytes */{opened.size}",
},
)
headers = {
"Cache-Control": "no-store",
"Accept-Ranges": "bytes",
"Content-Type": opened.content_type,
"Content-Length": str(stop - start),
}
if partial:
headers["Content-Range"] = f"bytes {start}-{stop - 1}/{opened.size}"
response = web.StreamResponse(status=206 if partial else 200, headers=headers)
await response.prepare(request)
if request.method != "HEAD":
opened.handle.seek(start)
remaining = stop - start
while remaining:
chunk = await asyncio.get_running_loop().run_in_executor(
None, opened.handle.read, min(_READ_SIZE, remaining)
)
if not chunk:
break
remaining -= len(chunk)
await response.write(chunk)
await response.write_eof()
return response
finally:
opened.close()
+20 -80
View File
@@ -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)
+107
View File
@@ -0,0 +1,107 @@
"""One bounded current-file inspection worker shared by physical identities."""
import os
import stat
from pathlib import Path
try:
from ...utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from ...utils.video_metadata import is_video_path
except ImportError:
from utils.prompt_inspector import (
MetadataReadError,
inspect_prompt_evidence,
read_inspection_file,
)
from utils.video_metadata import is_video_path
# Only these file types are served or inspected within an authorized directory.
IMAGE_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
}
)
VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"})
SERVABLE_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
class CurrentFileChanged(Exception):
"""File facts are invalid or changed across the bounded metadata observation."""
def _current_file_facts(path: Path) -> os.stat_result:
if path.suffix.lower() not in SERVABLE_EXTENSIONS:
raise CurrentFileChanged
try:
observed = path.stat()
except (FileNotFoundError, NotADirectoryError, PermissionError):
raise CurrentFileChanged from None
if (
not stat.S_ISREG(observed.st_mode)
or not observed.st_mode & 0o444
or not os.access(path, os.R_OK)
):
raise CurrentFileChanged
return observed
def inspect_current_file(
path: Path, *, source_label: str, saved: dict | None, library: dict
) -> dict:
before = _current_file_facts(path)
try:
inspection = read_inspection_file(path)
except MetadataReadError:
inspection = None
after = _current_file_facts(path)
fields = ("st_dev", "st_ino", "st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
if any(getattr(before, key) != getattr(after, key) for key in fields):
raise CurrentFileChanged
if inspection is None:
evidence = {
"prompts": {
role: {"status": "unresolved", "text": None, "source": None}
for role in ("positive", "negative")
},
"resolution": {
"state": "unresolved",
"sampler_id": None,
"reason": "Media metadata could not be read",
},
}
else:
role_saved = (
None if saved and saved.get("association_status") == "ambiguous" else saved
)
evidence = inspect_prompt_evidence(inspection.metadata, role_saved)
return {
"file": {
"observation": "current-file",
"source_label": source_label,
"media_type": "video" if is_video_path(path) else "image",
"extension": path.suffix.lower(),
"size": before.st_size,
"modified_time": before.st_mtime,
"format": None,
"width": None,
"height": None,
},
**evidence,
"inspection": inspection,
"library": library,
}
@@ -0,0 +1,219 @@
import { writeFile } from 'node:fs/promises';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
// Content, row counts, and image bytes come from independent synthetic datasets.
// Saved choices and actual executions are separate production sections. The concept's
// illustrative repeat dialog is compared with Recorded run for native behavior;
// frozen evidence and current-file/unknown copy intentionally describe actual data.
// Parent acceptance scopes exact comparisons to history geometry/roles/native state.
// No content is masked and the immutable concept is never rewritten for comparison.
export const canonicalDifferences = {
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
semantics: [
'Saved combinations are distinct from the additional chronological executions',
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
'Current-file candidates disclose unknown identity and context membership',
],
remainingApplicationPolish: [
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
'Shared Viewer stage background and toolbar alignment remain outside #209',
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
],
};
export async function captureCanonicalState(page, testInfo, name, selector) {
await page.evaluate(async () => {
await document.fonts.ready;
for (const image of document.images) image.loading = 'eager';
await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
});
const target = selector ? page.locator(selector).first() : page;
const path = testInfo.outputPath(`${name}.png`);
await target.screenshot({ path, animations: 'disabled' });
await testInfo.attach(name, { path, contentType: 'image/png' });
}
export async function historyGeometry(page, production) {
const selectors = production ? {
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
positiveLabel: '.library-positive-lane span',
role: '[aria-label="Saved prompt texts"] .prompt-block',
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
heading: '.library-variants h2', intro: '.library-variants > p.muted',
footer: '.library-variant-footer', all: '.library-variants button:last-child',
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
} : {
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
positiveLabel: '.positive-lane span',
prompt: '.surface .prompt-text', gallery: '.history-gallery',
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
footer: '.timeline-lower', all: '.timeline-lower button',
repeat: '.timeline .run-dot',
};
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
const nodes = [...document.querySelectorAll(selector)];
return [key, nodes.map(node => {
const style = getComputedStyle(node), before = getComputedStyle(node, '::before');
const box = node.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height,
padding: style.padding, top: style.top, left: style.left, background: style.backgroundColor, color: style.color,
border: style.borderTopWidth, borderColor: style.borderTopColor, radius: style.borderRadius,
fontSize: style.fontSize, fontFamily: style.fontFamily, lineHeight: style.lineHeight,
fontWeight: style.fontWeight, text: node.textContent, contextId: node.dataset.contextId || node.dataset.contextBranch,
runId: node.dataset.runId,
marginLeft: style.marginLeft, outline: style.outlineWidth, outlineColor: style.outlineColor,
outlineOffset: style.outlineOffset, before: { width: before.width, height: before.height,
top: before.top, left: before.left, borderLeft: before.borderLeftWidth,
borderBottom: before.borderBottomWidth, radius: before.borderBottomLeftRadius,
background: before.backgroundColor, color: before.color, borderColor: before.borderTopColor,
borderLeftColor: before.borderLeftColor, borderBottomColor: before.borderBottomColor,
fontSize: before.fontSize, fontFamily: before.fontFamily, fontWeight: before.fontWeight,
content: before.content } };
})];
})) }), selectors);
}
export async function nativeDialogState(locator) {
return locator.evaluate(node => {
const style = getComputedStyle(node), box = node.getBoundingClientRect();
return { modal: node.matches(':modal'), focusInside: node.contains(document.activeElement),
background: style.backgroundColor, color: style.color, borderColor: style.borderTopColor,
border: style.borderTopWidth, width: box.width, height: box.height,
viewportWidth: innerWidth, viewportHeight: innerHeight };
});
}
export async function exerciseCurrentPairSend(page, roles) {
const host = await page.context().newPage();
await host.goto('/prompt_manager/workspace#/overview');
try {
await host.evaluate(async () => {
const { createBridgeHost } = await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const target = { id: 'canonical-pair', label: 'Canonical destination', type: 'PromptManagerPair', roles: ['positive', 'negative'] };
window.canonicalApplied = [];
window.canonicalHost = createBridgeHost({ origin: location.origin, namespace: '/', label: 'Canonical ComfyUI', adapter: {
listTargets: () => [target],
preview: (_id, roles) => ({ preview: { target, fields: Object.entries(roles).map(([role, after]) => ({ role, before: '', after })),
composition: { prepend: '', append: '', prependConnected: false, appendConnected: false, before: '', after: roles.positive } }, snapshot: roles }),
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
} });
});
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
await sender.getByRole('button', { name: 'Preview changes', exact: true }).click();
await expect(sender).toContainText(roles.positive);await expect(sender).toContainText(roles.negative);
await sender.getByRole('button', { name: 'Apply to ComfyUI', exact: true }).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(() => window.canonicalApplied)).toEqual([roles]);
await page.keyboard.press('Escape');await expect(send).toBeFocused();
} finally {await host.evaluate(() => window.canonicalHost?.dispose());await host.close();}
}
export async function compareCanonicalHistory(reference, actual, testInfo) {
const path = testInfo.outputPath('canonical-history-geometry.json');
await writeFile(path, JSON.stringify({ reference, actual, comparisonScope: canonicalDifferences }, null, 2));
await testInfo.attach('canonical-history-geometry', { path, contentType: 'application/json' });
for (const geometry of [reference, actual]) {
expect.soft(geometry.role).toHaveLength(2);
expect.soft(geometry.prompt).toHaveLength(2);
expect.soft(geometry.heading[0].fontSize).toBe('14px');
expect.soft(geometry.intro[0].fontSize).toBe('12px');
expect.soft(geometry.footer).toHaveLength(1);
expect.soft(geometry.positiveLabel).toHaveLength(1);
expect.soft(geometry.positiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(geometry.surface[0].padding).toBe('22px');
expect.soft(geometry.surface[0].radius).toBe('7px');
expect.soft(geometry.timeline[0].padding).toBe('18px 20px');
expect.soft(geometry.positive[0].border).toBe('2px');
for (const [index, branch] of geometry.branch.entries()) {
expect.soft(branch.height).toBe(57);
expect.soft(branch.border).toBe('2px');
expect.soft(branch.marginLeft).toBe(`${38 + index * 47}px`);
expect.soft(branch.before).toMatchObject({ width: '30px', height: '25px', top: '-25px', left: '-30px', borderLeft: '2px', borderBottom: '2px', radius: '22px' });
}
}
expect.soft(actual.recordedPositiveLabel).toHaveLength(1);
expect.soft(actual.recordedPositiveLabel[0]).toMatchObject({ top: '-25px', left: '38px', fontSize: '10px' });
expect.soft(actual.recordedHeading[0].fontSize).toBe(reference.heading[0].fontSize);
expect.soft(actual.recordedCanvas[0].width).toBeGreaterThanOrEqual(actual.recordedTimeline[0].width - 42);
for (const geometry of [reference, actual]) {
expect.soft(geometry.runDialog).toMatchObject({ modal: true, focusInside: true, border: '1px' });
expect.soft(geometry.runDialog.width).toBeLessThanOrEqual(geometry.runDialog.viewportWidth);
expect.soft(geometry.runDialog.height).toBeLessThanOrEqual(geometry.runDialog.viewportHeight);
}
for (const property of ['background', 'color', 'borderColor']) expect.soft(actual.runDialog[property]).toBe(reference.runDialog[property]);
for (const [key, properties] of [
['surface', ['padding', 'background', 'border', 'borderColor', 'radius']],
['timeline', ['padding', 'background', 'border', 'borderColor', 'radius']],
['positive', ['border', 'borderColor']],
['positiveLabel', ['color', 'fontSize', 'top', 'left']],
['branch', ['height', 'border', 'borderColor', 'fontSize']],
['label', ['color', 'fontSize']],
['dot', ['background', 'borderColor', 'radius', 'outline', 'outlineColor', 'outlineOffset', 'color', 'fontSize']],
]) {
for (const property of properties) expect.soft(actual[key][0]?.[property], `${key}.${property}`).toBe(reference[key][0][property]);
}
for (const property of ['color', 'background']) expect.soft(actual.positive[0].before[property]).toBe(reference.positive[0].before[property]);
for (const [index, block] of actual.role.entries()) for (const property of ['border', 'borderColor', 'fontSize']) expect.soft(block[property]).toBe(reference.role[index][property]);
for (const [index, prompt] of actual.prompt.entries()) for (const property of ['fontSize', 'lineHeight', 'fontFamily']) expect.soft(prompt[property]).toBe(reference.prompt[index][property]);
assertRecordedHistory(reference, actual, { branchCount: 1, anchorCount: 1, repeatCount: 0 });
}
export function assertRecordedHistory(reference, actual, { branchCount, anchorCount, repeatCount }) {
expect(branchCount).toBeGreaterThan(0);
expect(reference.branch.length).toBeGreaterThanOrEqual(2);
expect(reference.dot).toHaveLength(1);
expect(reference.repeat.length).toBeGreaterThan(0);
expect.soft(actual.recordedBranch, 'chronological branches').toHaveLength(branchCount);
expect.soft(actual.recordedLabel, 'chronological labels').toHaveLength(branchCount);
expect.soft(actual.recordedAnchor, 'chronological anchors').toHaveLength(anchorCount);
expect.soft(actual.recordedRepeat, 'chronological repeats').toHaveLength(repeatCount);
expect.soft(actual.recordedMarker).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedColumn).toHaveLength(anchorCount + repeatCount);
expect.soft(actual.recordedPositive).toHaveLength(1);
for (const property of ['border', 'borderColor']) expect.soft(actual.recordedPositive[0][property], `recorded positive ${property}`).toBe(reference.positive[0][property]);
for (const property of ['color', 'background', 'width', 'height', 'fontSize']) expect.soft(actual.recordedPositive[0].before[property], `recorded positive glyph ${property}`).toBe(reference.positive[0].before[property]);
for (const [index, branch] of actual.recordedBranch.entries()) {
for (const property of ['height', 'border', 'borderColor']) expect.soft(branch[property], `recorded branch ${index} ${property}`).toBe(reference.branch[0][property]);
for (const property of ['width', 'height', 'top', 'left', 'borderLeft', 'borderBottom', 'radius', 'borderLeftColor', 'borderBottomColor']) expect.soft(branch.before[property], `recorded curve ${index} ${property}`).toBe(reference.branch[0].before[property]);
if (index) expect.soft(branch.y - actual.recordedBranch[index - 1].y, 'recorded 57px branch rhythm').toBe(reference.branch[1].y - reference.branch[0].y);
}
for (const label of actual.recordedLabel) for (const property of ['color', 'fontSize']) expect.soft(label[property], `recorded label ${property}`).toBe(reference.label[0][property]);
for (const [index, column] of actual.recordedColumn.entries()) {
expect.soft(column.left).toBe(`${38 + index * 64}px`);
if (index) expect.soft(column.x - actual.recordedColumn[index - 1].x, '64px chronological columns').toBe(64);
}
const checkMarker = (marker, canonical, anchor) => {
const visual = actual.pointerCoarse ? marker.before : marker;
const size = anchor ? 29 : 12;
expect.soft(visual.width, 'recorded marker visual width').toBe(actual.pointerCoarse ? `${size}px` : size);
expect.soft(visual.height, 'recorded marker visual height').toBe(actual.pointerCoarse ? `${size}px` : size);
for (const property of ['background', 'borderColor', 'radius']) expect.soft(visual[property], `recorded marker ${property}`).toBe(canonical[property]);
if (actual.pointerCoarse) {
expect.soft(marker.width, 'coarse marker hit width').toBeGreaterThanOrEqual(44);
expect.soft(marker.height, 'coarse marker hit height').toBeGreaterThanOrEqual(44);
expect.soft(visual.borderLeft).toBe('2px');
} else expect.soft(marker.border).toBe(canonical.border);
if (anchor) {
expect.soft(marker.text).toBe('−');
if (actual.pointerCoarse) expect.soft(visual.content).toBe('"−"');
for (const property of ['color', 'fontSize', 'fontFamily', 'fontWeight']) expect.soft(visual[property], `recorded minus ${property}`).toBe(canonical[property]);
const branch = actual.recordedBranch.find(value => value.contextId === marker.contextId);
expect(branch, 'each context anchor has its real branch').toBeTruthy();
expect.soft(Math.abs(marker.x + marker.width / 2 - branch.x)).toBeLessThanOrEqual(1);
expect.soft(Math.abs(marker.y + marker.height / 2 - branch.y)).toBeLessThanOrEqual(1);
} else expect.soft(marker.text).toBe('');
};
for (const marker of actual.recordedAnchor) checkMarker(marker, reference.dot[0], true);
for (const marker of actual.recordedRepeat) checkMarker(marker, reference.repeat[0], false);
}
+75
View File
@@ -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,
+661
View File
@@ -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();}
});
+14
View File
@@ -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);
});
+109
View File
@@ -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']) {
+538
View File
@@ -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
+77
View File
@@ -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 = [];
+18
View File
@@ -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;
}
+31
View File
@@ -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;
});
}
}
});
+20
View File
@@ -50,3 +50,23 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
scope.dispose();const other=new f.Element('div');module.mountPromptBlocks(other,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('late')}});assert.equal(other.children.length,0);
}finally{scope.dispose();f.restore();}
});
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
try{
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
assert.deepEqual(sent[1],{negative:' \n\t '});
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);assert.equal(sent.length,2);
}finally{scope.dispose();f.restore();}
});
@@ -48,3 +48,9 @@ test('metadata export preserves representation formats and omits private carrier
assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET'));
assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ');
});
test('reported location metadata export carries exact occurrence without assigning historical ownership',async()=>{
const {inspectionDownloads}=await load(),source=record();
source.identity={kind:'reported-location',key:'b'.repeat(64),path:'folder/a.png',occurrence:{run_id:'22222222-2222-2222-2222-222222222222',output_node_id:'𐀀',image_ordinal:255},history_epoch:'11111111-1111-1111-1111-111111111111',current_scope:'c'.repeat(64),reference:{folder_type:'temp',subfolder:'folder',filename:'a.png',path_style:'posix',root_key:null}};
const value=JSON.parse(inspectionDownloads(source).metadata.bodyText);assert.deepEqual(value.identity,source.identity);assert.equal(value.identity.root_index,undefined);assert.equal(value.context_id,undefined);assert.equal(value.file_identity,undefined);assert.deepEqual(value.prompts,source.prompts);
});
+23
View File
@@ -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);
});
+41
View File
@@ -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+'&current_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;
});
}
});
+278
View File
@@ -0,0 +1,278 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const {createApiClient} = createRequire(import.meta.url)('../../web/js/api-client.js');
const {extractGenerationFacts} = createRequire(import.meta.url)('../../web/js/comfy-metadata.js');
import {createInspectionController} from '../../web/js/workspace/inspection-state.mjs';
import {reportedLocationAdapter} from '../../web/js/workspace/history-media.mjs';
const m = await import('../../web/js/workspace/history-state.mjs').catch(e => {if(e.code !== 'ERR_MODULE_NOT_FOUND') throw e; return {};});
const epoch='11111111-1111-1111-1111-111111111111', session='22222222-2222-2222-2222-222222222222';
const id=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`;
const digest=n=>String(n).repeat(64), fields=['raw','effective','prepend','append'];
const proof=(provenance='recorded')=>({provenance,source:provenance==='recorded'?'runtime_return':'unknown',reason:provenance==='recorded'?null:'missing'});
function context(anchor='1',branch='a') {return {anchor_id:anchor,context_id:digest(branch),positive_raw_text_id:digest('e'),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d'),positive:{raw:'positive\n猫',effective:null,prepend:'',append:''},negative:{raw:'',effective:'negative',prepend:'',append:''},evidence:{format_version:1,role_provenance:'inferred',positive:Object.fromEntries(fields.map(f=>[f,proof(f==='effective'?'unknown':'recorded')])),negative:Object.fromEntries(fields.map(f=>[f,proof()]))},positive_prompt_id:7,negative_prompt_id:8,pair_id:9,reference_epoch:epoch,navigation:{positive_available:true,negative_available:true,pair_available:true}};}
const capture={state:'complete',reason:'captured',format_version:1};
const output={state:'complete',reason:'reported',format_version:1,source:'host_final_history',retained_occurrence_count:4,list_status:'complete'};
function run(n,branch='a') {return {run_id:id(n),session_id:session,storage_epoch:epoch,job_id:id(99),record_version:1,queue_item_id:String(n),observed_dequeue_at:String(9007199254740990n+BigInt(n)),observed_callback_at:null,executor_started_at:null,terminal_event_at:null,outcome:'observed',reason:null,diagnostics_omitted:false,diagnostics_available:false,context_capture:capture,output_summary:output,matching_anchors:[{anchor_id:'1',context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d')}]};}
function windowPage(items=[run(1),run(2),run(3,'b')],more=false) {const summaries=new Map();for(const r of items) {const a=r.matching_anchors[0];if(!summaries.has(a.context_id)) summaries.set(a.context_id,{context_id:a.context_id,positive_snapshot_id:a.positive_snapshot_id,negative_snapshot_id:a.negative_snapshot_id,negative_raw:{state:'explicit_empty',preview:'',truncated:false,evidence:[]}});summaries.get(a.context_id).negative_raw.evidence.push({run_id:r.run_id,anchor_id:'1',...proof()});}return {success:true,history_version:1,history_epoch:epoch,group:{positive_id:7,raw_text_id:digest('e'),relation:'exact_frozen_raw_text',scope_status:'matched'},recorded_run_count:3,resume_cursor:items.length?'resume-token':null,items,context_summaries:[...summaries.values()],has_more:more,next_cursor:more?'window-token':null};}
const detail=(n=1)=>({success:true,history_version:1,history_epoch:epoch,run:run(n),contexts:[context(),context('2','b')],context_capture:capture,output_summary:output});
function receipt(node='1',ordinal=0) {return {occurrence:{run_id:id(1),output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'image.png',path_style:'posix',root_key:digest('a')},file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};}
const outputs=(items=[receipt('1',0),receipt('1',1)],more=true)=>({success:true,history_version:1,history_epoch:epoch,capture:{state:'complete',reason:'reported',format_version:1,source:'host_final_history'},capture_fingerprint:digest('f'),retained_occurrence_count:4,list_status:'complete',items,has_more:more,next_cursor:more?'outputs-token':null});
function setup(restore) {const requests=[],changes=[];assert.equal(typeof m.createHistoryController,'function','history controller is exported');const c=m.createHistoryController({positiveId:7,restore,onChange:s=>changes.push(s),api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({url,signal,resolve,reject}))}});return {c,requests,changes};}
async function loaded(restore) {const s=setup(restore),p=s.c.load();s.requests.at(-1).resolve(windowPage());await p;return s;}
async function selected() {const s=await loaded(),p=s.c.selectRun(id(1));s.requests.at(-1).resolve(detail());await p;return s;}
test('chronology keeps A A B as three runs and two branches with exact decimal ordering',async()=>{const {c,requests}=await loaded();assert.deepEqual(c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);assert.equal(c.state.window.context_summaries.length,2);assert.equal(c.state.window.items[2].observed_dequeue_at,'9007199254740993');assert.equal(requests[0].url,'/prompt_manager/library/7/history?limit=40');assert.equal(c.state.window.recorded_run_count,3);c.dispose();});
test('earlier windows prepend full identities once and refresh replaces bounded count',async()=>{const s=setup();let p=s.c.load();s.requests[0].resolve(windowPage([run(2),run(3,'b')],true));await p;p=s.c.loadEarlier();assert.match(s.requests[1].url,/cursor=window-token/);s.requests[1].resolve(windowPage([run(1),run(2)],false));await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);p=s.c.refresh();s.requests[2].resolve({...windowPage([run(3)]),recorded_run_count:1});await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(3)]);assert.equal(s.c.state.window.recorded_run_count,1);s.c.dispose();});
test('malformed windows never enable actions',async()=>{for(const mutate of [p=>p.items[0].queue_item_id='01',p=>p.items[0].observed_dequeue_at='9223372036854775808',p=>p.items[0].run_id='bad',p=>p.history_epoch='bad',p=>p.group.positive_id=8,p=>p.context_summaries[0].negative_raw.preview='x'.repeat(101),p=>p.has_more=true,p=>p.next_cursor='',p=>p.items[0].matching_anchors[0].context_id='x',p=>p.items[0].observed_callback_at=1,p=>p.recorded_run_count=-1]) {const {c,requests}=setup();const p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
test('frozen roles retain explicit empty and unknown values with their own evidence',()=>{assert.equal(typeof m.frozenPromptRoles,'function');const ctx=context(),raw=m.frozenPromptRoles(ctx,'raw'),effective=m.frozenPromptRoles(ctx,'effective');assert.equal(raw.negative.status,'resolved');assert.equal(raw.negative.text,'');assert.deepEqual(raw.negative.evidence,proof());assert.equal(effective.positive.status,'unresolved');assert.equal(effective.positive.text,null);assert.deepEqual(effective.positive.evidence,proof('unknown'));assert.equal(effective.negative.text,'negative');assert.equal(raw.positive.text,'positive\n猫');assert.throws(()=>m.frozenPromptRoles(ctx,'wrong'),TypeError);});
test('detail validates exact roles evidence IDs and epoch before selection',async()=>{for(const mutate of [p=>p.history_epoch=id(4),p=>p.run.run_id=id(2),p=>p.contexts[0].negative_snapshot_id=null,p=>p.contexts[0].positive.raw=42,p=>p.contexts[0].evidence.positive.raw.source='unknown',p=>p.contexts[0].navigation.pair_available=1,p=>p.contexts[0].positive_prompt_id=0,p=>p.contexts.push(p.contexts[0])]) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error || c.state.refreshRequired);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();}});
test('dependent selections abort late run output success and errors including disposal',async()=>{const {c,requests,changes}=await loaded();const p=c.selectRun(id(1)),q=c.selectRun(id(2));assert.equal(requests[1].signal.aborted,true);requests[2].resolve(detail(2));await q;requests[1].reject({status:409,code:'history-changed'});await p;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.refreshRequired,null);const r=c.selectRun(id(1));requests[3].resolve(detail());await r;for(const change of [()=>c.selectContext('2'),()=>c.setMode('effective')]) {const a=c.loadOutputs();const request=requests.at(-1);change();assert.equal(request.signal.aborted,true);request.resolve(outputs());await a;assert.equal(c.state.outputs.items.length,0);}const a=c.loadOutputs(),request=requests.at(-1);c.dispose();const count=changes.length;request.reject({status:409,code:'history-changed'});await a;assert.equal(changes.length,count);});
test('output pagination preserves binary order and exact repeated receipt selection on retry',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:1});assert.equal(c.state.selection.receipt.image_ordinal,1);p=c.loadOutputs();const url=requests.at(-1).url;requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');p=c.retryOutputs();assert.equal(requests.at(-1).url,url);requests.at(-1).resolve(outputs([receipt('\ue000'),receipt('𐀀')],false));await p;assert.deepEqual(c.state.outputs.items.map(r=>r.occurrence.output_node_id),['1','1','\ue000','𐀀']);assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();});
test('receipt selection invalidates pending responses without losing loaded coordinates',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();const pending=requests.at(-1);c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(pending.signal.aborted,true);pending.reject({status:409,code:'capture-changed'});await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.selection.receipt.image_ordinal,0);assert.equal(c.state.refreshRequired,null);c.dispose();});
test('response-limit retries only the failed cursor at the permitted smaller limit',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:20}});await p;assert.equal(c.state.outputs.items.length,2);p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=20/);assert.match(requests.at(-1).url,/cursor=outputs-token/);requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;assert.equal(c.state.outputs.items.length,4);c.dispose();});
test('real ApiClient response recovery retries the same failed output cursor at its smaller limit',async()=>{
const calls=[];let reads=0;
const api=createApiClient(async(url,init)=>{
calls.push({url,method:init.method});
const path=new URL(url,'http://localhost');
if(path.pathname.endsWith('/history'))return Response.json(windowPage());
if(!path.pathname.endsWith('/outputs'))return Response.json(detail());
reads++;
if(reads===1)return Response.json(outputs());
if(reads===2)return Response.json({success:false,history_version:1,code:'response-limit',error:'Reported outputs exceed the response budget',recovery:{action:'smaller-limit',limit:20}},{status:422});
return Response.json(outputs([receipt('2',0),receipt('2',1)],false));
});
const c=m.createHistoryController({api,positiveId:7});
try {
await c.load();await c.selectRun(id(1));await c.loadOutputs();
c.selectReceipt(receipt('1',1).occurrence);const retained=c.state.outputs.items;
await c.loadOutputs();
assert.deepEqual(c.state.outputs.error.recovery,{action:'smaller-limit',limit:20});
assert.equal(c.state.outputs.items,retained);assert.equal(c.state.selection.receipt.image_ordinal,1);
await c.retryOutputs();
const pages=calls.filter(call=>call.url.includes('/outputs?')).map(call=>new URL(call.url,'http://localhost'));
assert.deepEqual(pages.map(url=>url.searchParams.get('limit')),['40','40','20']);
assert.deepEqual(pages.map(url=>url.searchParams.get('cursor')),[null,'outputs-token','outputs-token']);
assert.deepEqual(c.state.outputs.items.map(item=>[item.occurrence.output_node_id,item.occurrence.image_ordinal]),[['1',0],['1',1],['2',0],['2',1]]);
assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(c.state.outputs.has_more,false);
assert.ok(calls.every(call=>call.method==='GET'));
} finally {c.dispose();}
});
test('history change clears every actionable object and revalidates parent independently',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'history-changed',recovery:{action:'refresh-history'}});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);assert.equal(c.state.run.detail,null);assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.actionsEnabled,false);assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.parent.detail.positive.id,7);assert.equal(c.state.refreshLabel,'Reload recorded history');c.dispose();});
test('capture and candidate changes clear candidates and reload output page one',async()=>{for(const code of ['capture-changed','candidate-stale']) {const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:409,code});await p;assert.equal(c.state.outputs.items.length,0);assert.equal(c.state.actionsEnabled,false);assert.doesNotMatch(requests.at(-1).url,/cursor=/);requests.at(-1).resolve({...outputs([],false),retained_occurrence_count:0});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.outputs.error,null);c.dispose();}});
test('reentry validates epoch before actions while restore accepts only harmless intent',async()=>{const {c,requests}=await loaded({search:'猫',scrollTop:120,mode:'effective',state:{window:windowPage()},pairId:9});assert.equal(c.state.intent.search,'猫');assert.equal(c.state.selection.mode,'effective');const p=c.revalidate();assert.equal(c.state.actionsEnabled,false);requests.at(-1).resolve({...windowPage(),history_epoch:id(4)});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();});
test('invalid caller identifiers cannot request history or select nonexistent contexts and receipts',async()=>{assert.equal(typeof m.createHistoryController,'function');for(const positiveId of [0,'7',NaN,9007199254740992]) assert.throws(()=>m.createHistoryController({positiveId,api:{}}),TypeError);const {c,requests}=await selected();await assert.rejects(c.selectRun('bad'),TypeError);assert.throws(()=>c.selectContext('missing'),TypeError);assert.throws(()=>c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0}),TypeError);assert.equal(requests.length,2);c.dispose();});
test('malformed output coordinates capture fingerprints evidence and continuations fail closed',async()=>{for(const mutate of [p=>p.items[0].occurrence.image_ordinal=256,p=>p.items[0].occurrence.run_id=id(2),p=>p.capture_fingerprint='bad',p=>p.has_more=false,p=>p.history_epoch=id(4),p=>p.items[0].file_identity='verified',p=>p.items[0].reference.root_key='bad',p=>p.items[0].current_candidate.url='/fake',p=>p.items[0].occurrence.output_node_id='\ud800']) {const {c,requests}=await selected();const p=c.loadOutputs(),data=outputs();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.outputs.error || c.state.refreshRequired);assert.equal(c.state.outputs.items.length,0);c.dispose();}});
test('restored identifiers select only matching freshly validated run and context',async()=>{const {c,requests}=setup({runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputs:outputs(),receipt:receipt().occurrence});assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.items.length,0);let p=c.load();requests[0].resolve(windowPage());await p;assert.equal(requests.length,2);assert.equal(c.state.run.loading,true);requests[1].resolve(detail());await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.selection.receipt,null);c.dispose();});
test('new run selection invalidates a pending reentry epoch check and its late error',async()=>{const {c,requests}=await selected();const validation=c.revalidate(),old=requests.at(-1),next=c.selectRun(id(2));assert.equal(old.signal.aborted,true);requests.at(-1).resolve(detail(2));await next;old.reject({status:409,code:'history-changed'});await validation;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.run.detail.run.run_id,id(2));assert.equal(c.state.refreshRequired,null);assert.equal(c.state.validating,false);c.dispose();});
test('explicit detail limit errors stay bounded read failures and retry only the detail',async()=>{const {c,requests}=await loaded();let p=c.selectRun(id(1),{anchorId:'2',contextId:digest('b')});requests.at(-1).reject({status:422,code:'response-limit'});await p;assert.equal(c.state.run.error.code,'response-limit');assert.equal(c.state.window.items.length,3);assert.equal(c.state.refreshRequired,null);p=c.retryRun();assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?epoch=/);requests.at(-1).resolve(detail());await p;assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.run.error,null);assert.equal(requests.length,3);c.dispose();});
test('repeated stale output responses stop automatic recovery and retain explicit reload state',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'candidate-stale'});await p;assert.equal(requests.length,4);requests.at(-1).reject({status:409,code:'candidate-stale'});await new Promise(resolve=>setImmediate(resolve));assert.equal(requests.length,4,'recovery must not poll indefinitely');assert.equal(c.state.refreshRequired,'candidate-stale');assert.equal(c.state.outputs.error.code,'candidate-stale');c.dispose();});
test('known and unknown frozen evidence cannot contradict their field or declared role provenance',async()=>{for(const mutate of [p=>p.contexts[0].evidence.role_provenance='recorded',p=>p.contexts[0].evidence.negative.raw.reason='missing',p=>p.contexts[0].evidence.positive.effective.reason=null,p=>p.run.outcome='conflicting',p=>p.run.reason='terminal_error']) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);c.dispose();}});
test('contradictory page counts and unrelated summary evidence are rejected before showing branches',async()=>{for(const mutate of [p=>{p.items=[];p.has_more=true;p.next_cursor='more';},p=>p.context_summaries[0].negative_raw.evidence[0].run_id=id(88),p=>p.context_summaries[0].positive_snapshot_id=digest('f')]){const {c,requests}=setup(),p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);c.dispose();}const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0)],false));await p;assert.ok(c.state.outputs.error);assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');c.dispose();});
function availableReceipt(currentScope,node='1',ordinal=0) {
const r=receipt(node,ordinal),locationKey=currentScope==='a'?digest('b'):digest('9');
const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:digest(currentScope)}),base=`/prompt_manager/history/runs/${id(1)}/output/current`;
const media={kind:'reported-location',occurrence:r.occurrence,reference:r.reference,history_epoch:epoch,current_scope:digest(currentScope),location_key:locationKey,filename:'image.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};
return {...r,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:digest(currentScope),location_key:locationKey,relative_path:'image.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media}};
}
test('every revalidation emission clears old available candidates and selected receipt before enabling actions',async()=>{
const {c,requests,changes}=await selected();let p=c.loadOutputs();
requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(c.state.actionsEnabled,true);
const first=changes.length,validation=c.revalidate();assert.equal(c.state.validating,true);assert.equal(c.state.actionsEnabled,false);
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
const pending=requests.at(-1);assert.match(pending.url,/\/outputs\?/);assert.equal(c.state.outputs.loading,true);
const assertSafe=state=>{if(state.actionsEnabled){assert.equal(state.selection.receipt,null,'an enabled emission cannot retain the pre-validation receipt');assert.ok(state.outputs.items.every(r=>r.current_candidate.media.current_scope!==digest('a')),'an enabled emission cannot expose old current scope');}};
try {
changes.slice(first).forEach(assertSafe);
pending.resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await validation;
changes.slice(first).forEach(assertSafe);assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));
assert.equal(c.state.selection.receipt,null);assert.equal(c.state.validating,false);assert.equal(c.state.actionsEnabled,true);
} finally {c.dispose();}
});
test('pending mode-independent detail keeps the current mode and derives its exact effective roles without another GET',async()=>{
const {c,requests}=await loaded(),pending=c.selectRun(id(1)),read=requests.at(-1);
c.setMode('effective');assert.equal(read.signal.aborted,false);assert.equal(requests.length,2);
read.resolve(detail());await pending;
assert.equal(c.state.selection.mode,'effective');const ctx=c.state.run.detail.contexts.find(ctx=>ctx.anchor_id===c.state.selection.anchor_id);
const roles=m.frozenPromptRoles(ctx,c.state.selection.mode);assert.equal(roles.negative.text,'negative');assert.equal(roles.positive.text,null);assert.equal(roles.positive.status,'unresolved');assert.deepEqual(roles.positive.evidence,proof('unknown'));
assert.equal(requests.length,2);assert.equal(c.state.run.error,null);c.dispose();
});
test('active inspection history failure clears every emitted owning state for HTTP and stale-echo errors',async()=>{
for(const transport of ['http','echo']) {
const {c,requests,changes}=await selected();let pending=c.loadOutputs();
const item=availableReceipt('a');requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
const target=item.current_candidate.media,client=createApiClient(async()=>transport==='http'
? Response.json({success:false,code:'history-changed',error:'Recorded history changed',recovery:{action:'refresh-history'}},{status:409})
: Response.json({success:true,history_version:1,inspection_version:1,history_epoch:id(8)}));
const inspection=createInspectionController({api:client,adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
const remove=inspection.subscribe(state=>c.inspectionFailed?.(target,state)),first=changes.length;
try {
await inspection.select(target);
assert.equal(c.state.refreshRequired,'history-changed');
for(const state of changes.slice(first)) {
assert.equal(state.actionsEnabled,false);assert.deepEqual(state.window.items,[]);assert.equal(state.window.history_epoch,null);
assert.equal(state.run.detail,null);assert.equal(state.selection.run_id,null);assert.equal(state.selection.anchor_id,null);assert.equal(state.selection.context_id,null);assert.equal(state.selection.receipt,null);
assert.equal(state.window.next_cursor,null);assert.equal(state.outputs.next_cursor,null);assert.deepEqual(state.outputs.items,[]);
}
assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));
assert.equal(c.state.parent.detail.positive.text,'saved');assert.equal(c.state.actionsEnabled,false);
} finally {remove();inspection.dispose();c.dispose();}
}
});
test('candidate and capture inspection failures keep their narrower recovery and obsolete targets cannot invalidate selection',async()=>{
for(const code of ['candidate-stale','capture-changed']) {
const {c,requests}=await selected();let pending=c.loadOutputs();const item=availableReceipt('a');
requests.at(-1).resolve({...outputs([item],false),retained_occurrence_count:1});await pending;c.selectReceipt(item.occurrence);
c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code});
assert.equal(c.state.refreshRequired,code);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.run.detail.run.run_id,id(1));assert.equal(c.state.selection.receipt,null);assert.deepEqual(c.state.outputs.items,[]);
assert.match(requests.at(-1).url,/\/outputs\?/);requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
c.selectReceipt(item.occurrence);c.inspectionFailed?.(item.current_candidate.media,{error:true,refreshRequired:true,code:'history-changed'});
assert.equal(c.state.refreshRequired,null,'old candidate scope cannot invalidate the fresh receipt');assert.equal(c.state.selection.run_id,id(1));
c.dispose();
}
});
test('targeted unavailable receipt retry replaces only its bounded page and keeps all exact visit selections',async()=>{
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt('2',1).occurrence);
const earlier=c.state.outputs.items.slice(0,2),readCount=requests.length;
p=c.retryReceipt?.(receipt('2',1).occurrence);
assert.equal(requests.length,readCount+1,'retry must read the successful page that owns this occurrence');
assert.match(requests.at(-1).url,/limit=40&cursor=outputs-token/);
requests.at(-1).resolve(outputs([receipt('2',0),availableReceipt('9','2',1)],false));await p;
assert.deepEqual(c.state.outputs.items.slice(0,2),earlier);assert.equal(c.state.outputs.items.length,4);
assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.outputs.next_cursor,null);
const before=requests.length;await c.loadOutputs();assert.equal(requests.length,before,'availability retry cannot invent another page');c.dispose();
});
test('targeted retry retains the receipt on read failure and suppresses late availability after a newer selection',async()=>{
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt(receipt().occurrence);
p=c.retryReceipt?.(receipt().occurrence);assert.equal(requests.length,4);
requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
p=c.retryOutputs();const pending=requests.at(-1);c.selectReceipt(receipt('1',1).occurrence);assert.equal(pending.signal.aborted,true);
pending.resolve(outputs([availableReceipt('9'),receipt('1',1)]));await p;
assert.equal(c.state.outputs.items[0].current_candidate.status,'unavailable');assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();
});
test('targeted page retry honors a smaller budget and seeks only within that already loaded receipt page',async()=>{
const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;
p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;c.selectReceipt(receipt('2',1).occurrence);
p=c.retryReceipt?.(receipt('2',1).occurrence);assert.equal(requests.length,5);
requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'smaller-limit',limit:1}});await p;
p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=1&cursor=outputs-token/);
requests.at(-1).resolve({...outputs([receipt('2',0)],true),next_cursor:'target-next'});await new Promise(resolve=>setImmediate(resolve));
assert.match(requests.at(-1).url,/limit=1&cursor=target-next/);requests.at(-1).resolve(outputs([availableReceipt('9','2',1)],false));await p;
assert.equal(c.state.outputs.items.length,4);assert.equal(c.state.outputs.items[3].current_candidate.status,'available');assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.selection.receipt.image_ordinal,1);assert.equal(requests.length,7);c.dispose();
});
test('same-epoch refresh restores exact selected context mode and receipt only from fresh reads',async()=>{
const {c,requests,changes}=await selected();let p=c.loadOutputs();requests.at(-1).resolve({...outputs([availableReceipt('a')],false),retained_occurrence_count:1});await p;
c.selectContext('2');c.setMode('effective');c.selectReceipt(receipt().occurrence);const first=changes.length;
p=c.refresh();requests.at(-1).resolve(windowPage());await p;
assert.match(requests.at(-1).url,/\/history\/runs\//,'refresh must revalidate the selected run');assert.equal(c.state.actionsEnabled,false);
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
assert.match(requests.at(-1).url,/\/outputs\?/);assert.equal(c.state.actionsEnabled,false);
assert.ok(changes.slice(first).every(state=>!state.actionsEnabled),'old objects must never be briefly enabled');
requests.at(-1).resolve({...outputs([availableReceipt('9')],false),retained_occurrence_count:1});await new Promise(resolve=>setImmediate(resolve));
assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt().occurrence);
assert.equal(c.state.outputs.items[0].current_candidate.media.current_scope,digest('9'));assert.equal(c.state.actionsEnabled,true);c.dispose();
});
test('fresh exact group membership keeps an older selected run outside a refreshed newest prefix',async()=>{
for(const member of [true,false]) {
const {c,requests}=await selected();c.selectContext('2');c.setMode('effective');
const p=c.refresh();requests.at(-1).resolve({...windowPage([run(60),run(61)]),recorded_run_count:61});await p;
assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?/);
const fresh=detail();if(!member)fresh.contexts.forEach(context=>context.positive_raw_text_id=digest('f'));
requests.at(-1).resolve(fresh);await new Promise(resolve=>setImmediate(resolve));
assert.deepEqual(c.state.window.items.map(run=>run.run_id),[id(60),id(61)]);
if(member) {assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.actionsEnabled,true);}
else {assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);assert.ok(c.state.run.error);assert.equal(c.state.selection.run_id,null,'an incompatible saved selection must be discarded');}
c.dispose();
}
});
test('reentry restores exactly the prior bounded timeline pages before enabling the older selection',async()=>{
const restored={historyEpoch:epoch,resumeCursor:'old-upper-resume',loadedCount:80,runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputCount:4,receipt:receipt('2',1).occurrence,scrollLeft:731,scrollTop:95};
const {c,requests,changes}=setup(restored),p=c.load();assert.match(requests.at(-1).url,/resume=old-upper-resume/);
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/cursor=window-token/);
requests.at(-1).resolve({...windowPage(Array.from({length:40},(_,i)=>run(1+i))),recorded_run_count:80});await p;
assert.equal(c.state.window.items.length,80);assert.equal(c.state.actionsEnabled,false);assert.match(requests.at(-1).url,/\/runs\//);
requests.at(-1).resolve(detail());await new Promise(resolve=>setImmediate(resolve));
requests.at(-1).resolve(outputs([receipt('1',0),receipt('1',1),receipt('2',0),receipt('2',1)],false));await new Promise(resolve=>setImmediate(resolve));
assert.equal(requests.length,4);assert.equal(c.state.selection.run_id,id(1));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.mode,'effective');assert.deepEqual(c.state.selection.receipt,receipt('2',1).occurrence);
assert.equal(c.state.intent.scrollLeft,731);assert.equal(c.state.intent.scrollTop,95);assert.equal(c.state.actionsEnabled,true);
assert.ok(changes.slice(0,-1).every(state=>!state.actionsEnabled));c.dispose();
});
test('refresh epoch change clears old selection before independent parent revalidation',async()=>{
const {c,requests}=await selected();let p=c.refresh();requests.at(-1).resolve({...windowPage(),history_epoch:id(8)});await p;
assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.selection.run_id,null);assert.equal(c.state.window.resume_cursor,null);assert.equal(c.state.run.detail,null);assert.deepEqual(c.state.outputs.items,[]);assert.equal(c.state.actionsEnabled,false);
requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();
});
test('obsolete inspection errors after another exact receipt and disposal cannot reach the owning history',async()=>{
const {c,requests,changes}=await selected();const first=availableReceipt('a'),second=availableReceipt('9','2',0);let p=c.loadOutputs();
requests.at(-1).resolve({...outputs([first,second],false),retained_occurrence_count:2});await p;
const pending=[];let target=first.current_candidate.media;
const inspection=createInspectionController({api:{get:()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))},adapter:reportedLocationAdapter,extractFacts:extractGenerationFacts});
const remove=inspection.subscribe(state=>c.inspectionFailed(target,state));
c.selectReceipt(first.occurrence);p=inspection.select(target);
target=second.current_candidate.media;c.selectReceipt(second.occurrence);const next=inspection.select(target);
pending[1].reject({status:503,code:'storage-unavailable'});await next;const before=changes.length;
pending[0].reject({status:409,code:'history-changed'});await p;
assert.equal(changes.length,before);assert.equal(c.state.refreshRequired,null);assert.deepEqual(c.state.selection.receipt,second.occurrence);
p=inspection.retry();inspection.dispose();c.dispose();const count=changes.length;pending[2].reject({status:409,code:'history-changed'});await p;
assert.equal(changes.length,count);remove();
});
test('malformed resume and raw membership scalars cannot enable restored actions',async()=>{
for(const mutate of [page=>page.resume_cursor='',page=>page.resume_cursor='x'.repeat(4097),page=>page.resume_cursor=42,page=>page.resume_cursor=null]) {
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load(),page=windowPage();mutate(page);requests.at(-1).resolve(page);await p;
assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);assert.equal(c.state.run.detail,null);assert.equal(requests.length,1);c.dispose();
}
for(const value of [undefined,null,'bad',42]) {
const {c,requests}=await loaded(),p=c.selectRun(id(1)),fresh=detail();fresh.contexts[0].positive_raw_text_id=value;requests.at(-1).resolve(fresh);await p;
assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();
}
});
test('a resumed page success or error cannot replace a later run choice',async()=>{
for(const outcome of ['success','error']) {
const {c,requests}=setup({historyEpoch:epoch,resumeCursor:'old-range',loadedCount:80,runId:id(1)}),p=c.load();
requests[0].resolve({...windowPage(Array.from({length:40},(_,i)=>run(41+i)),true),recorded_run_count:80});await new Promise(resolve=>setImmediate(resolve));
const old=requests[1],next=c.selectRun(id(41));assert.equal(old.signal.aborted,true);requests[2].resolve(detail(41));await next;
if(outcome==='success')old.resolve({...windowPage(Array.from({length:40},(_,i)=>run(i+1))),recorded_run_count:80});else old.reject({status:409,code:'history-changed'});
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
}
});
@@ -324,3 +324,20 @@ test('invalid attempted selections abort pending reads and suppress late results
controller.dispose();
}
});
test('explicit rooted adapter preserves the strict default identity and download contract', () => {
assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported');
const a = module.rootedFileAdapter, entry = image();
assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA);
assert.equal(a.inspectionPath(entry), module.inspectPath(entry));
assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry));
assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts));
assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError);
});
test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => {
const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts});
const selected={...image(),ignoredCallback(){}};
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
});
+126
View File
@@ -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();}
});
+9
View File
@@ -108,3 +108,12 @@ test('slideshow cannot start on video one entry or media error', () => {
}
const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose();
});
test('explicit occurrence session retains video pause and timer invalidation with location keys',async()=>{
const history=await import('../../web/js/workspace/history-media.mjs');
const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64);
const items=['c','d'].map((key,index)=>{const query=new URLSearchParams({node:String(index),ordinal:'0',epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;return {kind:'reported-location',occurrence:{run_id:run,output_node_id:String(index),image_ordinal:0},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},history_epoch:epoch,current_scope:scope,location_key:key.repeat(64),filename:'a.png',is_video:index===1,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};});
const timers=[],pending=new Set();let callbacks=0;
const view=session({adapter:history.reportedLocationAdapter,items,initialKey:'c'.repeat(64),onChange:()=>callbacks++,setTimer:fn=>{timers.push(fn);pending.add(fn);return fn;},clearTimer:fn=>pending.delete(fn)});
view.play();view.ready('c'.repeat(64));assert.equal(pending.size,1);timers[0]();assert.equal(view.state.key,'d'.repeat(64));assert.equal(view.state.phase,'paused-video');view.stop();view.navigate(-1);view.ready('c'.repeat(64));assert.equal(view.state.requested,false);view.dispose();const count=callbacks;timers[0]();view.ready('d'.repeat(64));assert.equal(callbacks,count);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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(
+2 -2
View File
@@ -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"),
),
):
+35
View File
@@ -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,
):
+511
View File
@@ -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)
+203
View File
@@ -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)
+915
View File
@@ -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))
)
+42
View File
@@ -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)
+2 -2
View File
@@ -28,10 +28,10 @@ test('execution script discovers only the new matrix and preserves all 78 stock
const executions = list('tools/vendor/playwright.execution.config.mjs');
assert.match(executions, /\[classic-start\]/);
assert.match(executions, /\[nodes2-start\]/);
assert.match(executions, /Total: 42 tests in 2 files/);
assert.match(executions, /Total: 46 tests in 2 files/);
for (const project of ['classic-start', 'nodes2-start']) {
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 13);
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
}
assert.doesNotMatch(executions, /prompt-manager.spec/);
+118 -94
View File
@@ -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,
+80
View File
@@ -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()
+48 -5
View File
@@ -237,10 +237,9 @@ def _library_projection(library: dict) -> dict:
return result
def encode_inspection_response(
def _encode_inspection(
*,
image: dict,
root_scope: str,
identity: dict,
file: dict,
prompts: dict,
resolution: dict,
@@ -252,8 +251,7 @@ def encode_inspection_response(
response = {
"success": True,
"inspection_version": 1,
"root_scope": root_scope,
"image": image,
**identity,
"file": _file_projection(file, inspection),
"prompts": prompts,
"resolution": resolution,
@@ -277,3 +275,48 @@ def encode_inspection_response(
if len(body) > MAX_RESPONSE_BYTES:
raise ValueError("Inspection response exceeds limits")
return body
def encode_inspection_response(
*,
image: dict,
root_scope: str,
file: dict,
prompts: dict,
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
) -> bytes:
"""Preserve the existing rooted wire and its bounded metadata projection."""
return _encode_inspection(
identity={"root_scope": root_scope, "image": image},
file=file,
prompts=prompts,
resolution=resolution,
inspection=inspection,
library=library,
)
def encode_history_inspection_response(
*,
current_output: dict,
file: dict,
prompts: dict,
resolution: dict,
inspection: "InspectionRead | None",
library: dict,
) -> bytes:
"""Use occurrence identity with the same current-file evidence projection."""
return _encode_inspection(
identity={
"history_version": 1,
"history_epoch": current_output["history_epoch"],
"current_output": current_output,
},
file=file,
prompts=prompts,
resolution=resolution,
inspection=inspection,
library=library,
)
+15 -3
View File
@@ -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;
}
+8 -2
View File
@@ -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],
+91
View File
@@ -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()];
}
+416
View File
@@ -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});},
};
}
+221
View File
@@ -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};
}
+30 -15
View File
@@ -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() {
+76
View File
@@ -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; }
}
+64 -10
View File
@@ -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();
+5 -5
View File
@@ -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);
}
+14 -2
View File
@@ -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);
}
}
+246
View File
@@ -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};
}
+7 -8
View File
@@ -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 {