* feat(history): concept heading and summary The Prompt history page takes the approved mock's heading and summary surface (docs/concepts/workspace/app.js renderHistory a591-598, #249 F1): - Heading description is "Prompt history · one positive, one shared gallery"; the actions stay "← Prompt library", "Edit metadata" and button.send "Send to ComfyUI" (aria-label "Send prompt pair to ComfyUI: <title>"), on one row at 1600. Send is disabled only while the selected pair's roles are loading (D24). - The summary is article.surface > span.chip category, the positive and selected negative prompt blocks, then .card-meta "N tracked runs · N images · N negative variants". Tracked runs come from the recorded history window's recorded_run_count (hidden until a read completes, kept while a refresh is in flight); images and variants from the saved positive's image_count and variant_count. - The summary's copy/send actions are named after the prompt, as in the concept ("Copy positive prompt: <title>"); mountPromptBlocks gains setTitle so an in-place metadata edit renames them without a remount. - mountRecordedHistory gains an optional onRecordedCount callback. Selector mapping: - .library-surface (history summary) -> .library-history article.surface - .library-surface .library-chip -> article.surface > span.chip - "Copy|Send <role> prompt[ to ComfyUI]" in "Saved prompt texts" -> "...: <title>" - heading p.muted "One positive prompt. Reusable negative variants. One shared gallery." -> "Prompt history · one positive, one shared gallery" - Unchanged: region "Saved prompt texts", .library-variants, .library-shared-gallery, .recorded-history (still .library-surface). Tests: workspace-history "history heading actions stay on one row at 1600 and are enabled after load" and "summary meta uses recorded runs" (both failed before), a prompt-blocks setTitle unit, and the mapped selectors in workspace-visual, workspace-library-editor and the canonical comparison. * feat(history): concept negative variants panel The history's Negative variants panel is the concept's (app.js a598, style.css 1059-1154): section.surface.timeline-panel with the panel heading (h2, p.small "Choose a red dot to change the negative and filter this gallery."), row actions Compare negatives (aria-pressed; its section is Task F3), + Add negative and the approved Show archived; a .timeline of .positive-lane and .variant-lane rows (--lane-offset 38 + 47i) with the "−" .variant-dot, "<name> · N runs" and a .run-dot when the pair's run_count > 1; and .timeline-lower with the legend and All images for this positive. D26: All images for this positive opens the concept dialog with the embedded, bounded (Load more) media gallery instead of filtering the shared gallery. The run dot opens Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the pair's newest linked image. Backing data: - library variants carry prompt_pairs.run_count; - GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest reads the newest run in the positive's exact raw-text scope whose current-epoch saved-pair hint still resolves to that pair (the get_history_run hint rule), inside the fenced history snapshot. - workspace-dialogs gains openContentDialog (onClose releases the body). - mountRecordedHistory returns openRun. D45 (new deviations): - a dot is named "Select negative <title>" when no other loaded lane has that title, else "Select negative variant N", so names stay unique; - the bounded "N of M saved negative variants" count stays a visually hidden status; "Saved combinations, not an execution timeline." is gone; - archived lanes (#235) are concept lanes marked .library-variant-archived at .6 emphasis with their Restore button; coarse dots keep a 47px target; the heading, legend row and All images button wrap at 390px and 200% zoom. Retired CSS: .library-branches, .library-positive-lane, .library-variant-lane, .library-variant-dot, .library-variants, .library-panel-heading, .library-variant-footer, .library-variant-summary. Selector mapping: - .library-variants -> .library-history .timeline-panel (region "Saved negative variants" unchanged) - .library-branches -> .timeline; .library-positive-lane -> .positive-lane - .library-variant-lane -> .variant-lane; .library-variant-dot -> .variant-dot - .library-variant-archived and .library-variant-restore unchanged - .library-variant-footer / button:last-child -> .timeline-lower / button - .library-variants > p.muted -> .timeline-panel .panel-heading .small - "Select negative variant N" -> "Select negative <title>" for titled pairs (history, native library, inspector, visual and real-host fixtures); untitled pairs unchanged - lane text "· N linked image(s)" -> "· N run(s)" - All images for this positive: aria-pressed filter -> dialog "All images for this positive"; tests that used it for the whole gallery select the lane linking the image, or read the dialog - variant panel row actions gain Compare negatives first Tests: new workspace-history "variants panel matches the concept", "run dot appears only for pairs with more than one run and opens its run", "all images dialog lists every image for the positive"; new workspace-library-editor "show archived and restore still work" (all four failed before the change). Python: variants carry run_count, newest pair run (DB and route). Unit: content dialog. The history fixture counts two canonical pairs and links one image to Clean geometry. * feat(history): compare negative variants Compare negatives now opens the concept's comparison (app.js a599-616, style.css 1155-1179) as a section.surface right after the timeline panel: h2 "Compare negative variants", p "Baseline compared with <name>. Choose another red dot to change the comparison.", a .compare-grid of two .compare-item negative blocks (baseline, selected), p.small "Added terms", then span.diff-added "+ term" per added term or p.small "No added terms.". Choosing another red dot updates the same section in place; pressing Compare negatives again removes it. D25: the baseline is the oldest active variant; with no selection the selected side falls back to it, as the concept's v does. The pure addedTerms(base, selected) in history-state.mjs is the concept's rule: comma split, trim, drop empty terms, exact case-sensitive difference that keeps the selected order and repeats. D46 (new deviations): - lanes are newest first, so the baseline is the lowest-id active variant, not the first lane; while the variant page has more, the oldest is not loaded and the section says "Load more negative variants to reach the baseline." instead of guessing; - the two blocks are named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats on the page; - Compare negatives is disabled when there is no active variant. Selector mapping: none changed; the section is new (.library-history .timeline-panel + section.surface). Tests: unit "added terms split, trim and exact-diff like the concept"; workspace-history "compare toggles, follows the selected dot and stacks at 390" and "compare waits for the page holding the oldest active negative" (all failed before the change). * test(history): guard the newest-pair route and record D45/D46 TESTING.md lists the Prompt history page's spec decisions (D24-D26) and its new deviations D45 and D46 beside D23 and D40-D44, notes under D46 that production opens on the newest lane while the concept opens on its baseline, adds GET /prompt_manager/library/{positive_id}/history/pairs/ {pair_id}/newest to the history route list with its guards, and documents the detail variants' run_count. Tests: the newest-pair read skips a stale reference_epoch row, stops at 16 stale-hint candidates (17 newer stale rows hide an older valid run and report {run: null}), and rejects a negative-only or deleted positive; the route answers 404 for an ineligible positive, 503 storage-unavailable for storage failures without leaking details, and 409 history-changed for a restore during the snapshot. * fix(history): run-dot touch target, unique dot names and dialog cleanup - D45: under pointer: coarse the 12px run dot gets a 44px ::after target, and while a run dot shows the lane text wraps within 139px before it. - D45: dot names compare trimmed titles and never use a title that reads like a fallback "variant N", so names stay unique. - All images and Repeat run share one dialog-scope slot: each open replaces the previous scope instead of adding a cleanup per open. - A run-dot lookup that settles after the view went stale opens nothing. - Retire the unused .library-main .library-chip rule and the stale "Saved combinations" comparison note. Tests (workspace-history, Chromium and touch): the coarse run-dot target and lane width; a failed newest lookup shows the toast and Repeat run; Recorded run that cannot open (recorded history failed) falls back to Repeat run; an archived pair's Repeat run reads archive=archived; Compare negatives is disabled with no active variant; dot names stay unique. * fix(workspace): one inert handle for aborted history and prompt-block mounts An aborted parent no longer gets a separate never-called stub per method: mountRecordedHistory returns its hoisted openRun (it answers false before reading later state), and mountPromptBlocks returns one shared no-op for update, setState, setTitle and dispose. This keeps the history.mjs and prompt-blocks.mjs function-coverage floors (98) without lowering them. Tests: test:coverage (Chromium and touch, 922 workflows) passes for all 61 files; it failed before on history.mjs 94.44 and prompt-blocks.mjs 93.75 function coverage.
786 lines
29 KiB
Python
786 lines
29 KiB
Python
"""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/library/{positive_id}/history/pairs/{pair_id}/newest"
|
|
)(self.get_newest_pair_run)
|
|
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_newest_pair_run(self, request: web.Request) -> web.Response:
|
|
"""The pair's newest recorded run for the history run dot (#249 D26)."""
|
|
_query(request)
|
|
if request.query:
|
|
raise ValueError("Invalid newest pair run query")
|
|
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
|
|
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
|
|
fence = self._history_fence()
|
|
db = self.db
|
|
|
|
def project() -> bytes:
|
|
generation = fence.begin()
|
|
try:
|
|
with db.recorded_history_snapshot() as epoch:
|
|
result = db.get_newest_pair_run(positive_id, pair_id)
|
|
finally:
|
|
db.model.close()
|
|
if result is None:
|
|
fence.validate(epoch, generation)
|
|
raise HistoryReadError(404, "not-found")
|
|
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)
|