Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/history-state.mjs
Vito ecd21165a7 feat(history): add chronological runs and exact output inspection (#227)
* feat(history): add bounded chronological read projections (#209)

* fix(history): validate internal continuation positions (#209)

* feat(history): expose fenced history windows and run details (#209)

* fix(history): release retired projection connections (#209)

* feat(history): resolve reported outputs through current authorization (#209)

* fix(history): reject invalid inspection targets (#209)

* feat(history): add scoped history state and current-file adapter (#209)

* fix(history): preserve slideshow and clear stale reentry actions (#209)

* feat(history): render recorded executions and frozen run details (#209)

* fix(history): recover receipts and refresh capture evidence (#209)

* test(history): verify chronological history across restore and host modes (#209)

* fix(history): enforce chronological marker visual parity (#209)

* fix(history): resolve final chronological review findings

Address #209 recovery, bounded visit restoration, exact receipt availability, and portable regression coverage.

* test(history): repair Windows fixtures and review feedback

Address #209 and PR #227 native path, cleanup, media and review-comment expectations. Document Windows portability preflight without changing production behavior.

* ci: raise the viewerjs job budget for the larger browser suite

The cross-browser workflows suite grew from 396 to 496 tests on this
branch, which pushed the whole viewerjs job to about 25.5 minutes
against the 25 minute cap set in #181. Two exact-head runs on this
branch timed out without any test failing: run 39173019283 was killed
on test 474 of 496, and run 39175125474 printed "496 passed (12.8m)"
and was then cancelled during Playwright shutdown.

Raise only the job budget. Browser projects, workers and per-test
timeouts are unchanged. Splitting the browsers into parallel jobs is
tracked separately.
2026-10-18 17:17:05 -07:00

417 lines
36 KiB
JavaScript

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});},
};
}