* 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.
586 lines
21 KiB
Python
586 lines
21 KiB
Python
"""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 _hints, _values, 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"
|
|
)
|
|
# Stale-hint rows a newest-pair lookup inspects before it reports none.
|
|
_PAIR_RUN_CANDIDATES = 16
|
|
_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_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
|
|
"""The newest matching run whose current saved-pair hint names `pair_id`.
|
|
|
|
None when the positive is not an eligible saved prompt or the pair is
|
|
not one of its pairs; ``{"run": None}`` when no recorded run names it.
|
|
Only a hint captured in this epoch and still resolving to the same pair
|
|
counts, as in ``get_history_run``.
|
|
"""
|
|
conn = self._history_connection()
|
|
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 or (
|
|
conn.execute(
|
|
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
|
|
(pair_id, positive_id),
|
|
).fetchone()
|
|
is None
|
|
):
|
|
return None
|
|
if saved["text"] is None:
|
|
return {"run": None}
|
|
epoch = self.get_execution_epoch()
|
|
candidates = conn.execute(
|
|
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
|
|
"c.positive_snapshot_id,c.negative_snapshot_id "
|
|
"FROM execution_runs r JOIN execution_run_contexts rc "
|
|
"ON rc.run_id=r.run_id JOIN execution_contexts c "
|
|
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
|
|
"ON s.snapshot_id=c.positive_snapshot_id "
|
|
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
|
|
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
|
|
(
|
|
content_digest("text", (saved["text"],)),
|
|
pair_id,
|
|
epoch,
|
|
_PAIR_RUN_CANDIDATES,
|
|
),
|
|
).fetchall()
|
|
for row in candidates:
|
|
positive = _values(conn, row["positive_snapshot_id"])
|
|
negative = _values(conn, row["negative_snapshot_id"])
|
|
if positive is None or positive["raw"] != saved["text"]:
|
|
continue
|
|
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
|
|
if hints[2] == pair_id:
|
|
return {
|
|
"run": {
|
|
key: row[key] for key in ("run_id", "anchor_id", "context_id")
|
|
}
|
|
}
|
|
return {"run": None}
|
|
|
|
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
|
|
)
|