* 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.
247 lines
21 KiB
JavaScript
247 lines
21 KiB
JavaScript
import {createScope, createButton} from '../shared/toolkit/core.js';
|
||
import {frozenPromptRoles} from './history-state.mjs';
|
||
import {mountPromptBlocks} from './prompt-blocks.mjs';
|
||
import {createInspectionController} from './inspection-state.mjs';
|
||
import {mountFileInspector} from './file-inspector.mjs';
|
||
import {createMediaViewer} from './media-viewer.mjs';
|
||
import {distinctCurrentMedia, occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
function facts(entries, values) {
|
||
const list = el('dl', undefined, 'recorded-run-facts');
|
||
for (const [label, value] of entries) {const row = el('div'), description = el('dd', value ?? 'Not recorded');row.append(el('dt', label), description);list.append(row);values.set(label, description);}
|
||
return list;
|
||
}
|
||
function observed(value) {
|
||
const time = Number(value);
|
||
return Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'Recorded time outside display range';
|
||
}
|
||
function outputSummary(summary) {
|
||
if (summary.state === 'not_captured') return 'Reported outputs not captured';
|
||
const count = summary.retained_occurrence_count;
|
||
return summary.list_status === 'complete' ? `${count} reported outputs · complete` : `${count} retained reported output${count === 1 ? '' : 's'} · incomplete · ${summary.state}`;
|
||
}
|
||
|
||
export function mountRecordedRun(container, {api, controller, sender, parentScope, onClose, onRestoreFocus}) {
|
||
const scope = createScope(), dispose = () => scope.dispose();
|
||
let active = null, latest = controller.state;
|
||
if (parentScope.signal.aborted) {dispose();return {open() {}, setState() {}, dispose};}
|
||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||
scope.cleanup(() => active?.close(false));
|
||
|
||
function open({runId, anchorId = null, contextId = null, trigger = document.activeElement}) {
|
||
if (scope.signal.aborted) return;
|
||
active?.close(false);
|
||
const local = createScope(), dialog = el('dialog', undefined, 'recorded-run-dialog');dialog.setAttribute('aria-label', 'Recorded run');
|
||
const header = el('header', undefined, 'recorded-run-header');
|
||
const close = createButton('×');close.setAttribute('aria-label', 'Close recorded run');close.autofocus = true;
|
||
header.append(el('h2', 'Recorded run'), close);
|
||
const message = el('p', '', 'recorded-run-status muted');message.setAttribute('role', 'status');
|
||
const retry = createButton('Retry run details'), reload = createButton('Reload recorded history');
|
||
const body = el('div', undefined, 'recorded-run-body');
|
||
const metadata = el('section');metadata.setAttribute('aria-label', 'Run evidence');
|
||
const anchors = el('div', undefined, 'recorded-run-anchors');anchors.setAttribute('role', 'group');anchors.setAttribute('aria-label', 'Recorded anchors');
|
||
const mode = el('div', undefined, 'recorded-run-mode');mode.setAttribute('role', 'group');mode.setAttribute('aria-label', 'Frozen text field');
|
||
const raw = createButton('Raw'), effective = createButton('Effective');mode.append(raw, effective);
|
||
const promptSection = el('section');promptSection.setAttribute('aria-label', 'Frozen run prompt texts');
|
||
promptSection.append(el('h3', 'Frozen prompt fields'), el('p', 'Only the selected stored field is shown. Role assignment: inferred. Field evidence is disclosed separately.', 'muted'));
|
||
const prompts = el('div');promptSection.append(prompts);
|
||
const provenance = el('details', undefined, 'recorded-provenance');provenance.append(el('summary', 'Frozen field provenance'));
|
||
const proof = el('div');provenance.append(proof);promptSection.append(provenance);
|
||
const outputs = el('section', undefined, 'recorded-outputs');outputs.setAttribute('aria-label', 'Reported outputs');
|
||
const outputState = el('p', '', 'recorded-output-status muted');outputState.setAttribute('role', 'status');
|
||
const receipts = el('ol', undefined, 'recorded-receipts');receipts.setAttribute('aria-label', 'Ordered reported output receipts');
|
||
const more = createButton('Load more reported outputs'), retryOutputs = createButton('Retry reported outputs');
|
||
outputs.append(el('h3', 'Reported outputs'), el('p', 'Host-reported locations · file identity unverified · newly generated unknown · Context membership unknown.', 'muted'), outputState, receipts, more, retryOutputs);
|
||
const current = el('section', undefined, 'recorded-current-file');current.setAttribute('aria-label', 'Current reported file');
|
||
current.append(el('h3', 'Current file at reported location'), el('p', 'Current file prompts and facts are separate from frozen run fields.', 'muted'));
|
||
const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true;
|
||
body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog);
|
||
const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter});
|
||
let closed = false, renderedDetail = null, renderedRoles = '', initialOutputQueued = false, inspected = null, viewerOpen = false;
|
||
const receiptNodes = new Map();
|
||
const evidenceValues = new Map();
|
||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||
function selectedReceipt() {
|
||
const selected = latest.selection.receipt;
|
||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||
}
|
||
function restoreReceiptFocus() {
|
||
const key = latest.selection.receipt && occurrenceKey(latest.selection.receipt);
|
||
const row = receiptNodes.get(key);(row?.view.hidden ? row.select : row?.view)?.focus({preventScroll:true});
|
||
}
|
||
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
|
||
onSelect:entry => {
|
||
if (closed || !latest.actionsEnabled) return;
|
||
inspected = occurrenceKey(entry.occurrence);controller.selectReceipt(entry.occurrence);inspection.select(entry);
|
||
}, onRefresh:refreshCurrent, onRestoreFocus:restoreReceiptFocus,
|
||
onClose:() => {viewerOpen = false;restoreReceiptFocus();},
|
||
});
|
||
let viewer = makeViewer();
|
||
function resetViewer() {viewer.dispose();viewerOpen = false;viewer = makeViewer();}
|
||
function refreshCurrent() {
|
||
if (closed) return;
|
||
resetViewer();inspection.clear();inspected = null;
|
||
if (latest.refreshRequired === 'history-changed') controller.refresh();
|
||
else controller.revalidate();
|
||
}
|
||
function showViewer(triggerButton) {
|
||
if (!latest.actionsEnabled || inspection.state.refreshRequired || closed) return;
|
||
const receipt = selectedReceipt();if (receipt?.current_candidate.status !== 'available') return;
|
||
const items = distinctCurrentMedia(latest.outputs.items, latest.selection.receipt);
|
||
viewerOpen = true;viewer.open({items, selectedKey:reportedLocationAdapter.key(receipt.current_candidate.media), trigger:triggerButton || receiptNodes.get(occurrenceKey(receipt.occurrence))?.view});
|
||
}
|
||
function chooseReceipt(receipt, triggerButton) {
|
||
if (!latest.actionsEnabled || closed) return;
|
||
inspected = occurrenceKey(receipt.occurrence);controller.selectReceipt(receipt.occurrence);
|
||
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
|
||
}
|
||
function selectReceipt(receipt) {
|
||
if (!latest.actionsEnabled || closed) return;
|
||
inspected = null;controller.selectReceipt(receipt.occurrence);inspection.clear();if (viewerOpen) resetViewer();
|
||
}
|
||
function closeRun(restore = true) {
|
||
if (closed) return;closed = true;active = null;local.dispose();
|
||
if (restore && !scope.signal.aborted) {
|
||
if (trigger?.isConnected) trigger.focus({preventScroll:true});
|
||
else onRestoreFocus?.({runId, anchorId, contextId});
|
||
onClose?.();
|
||
}
|
||
}
|
||
local.cleanup(() => {viewer.dispose();inspection.dispose();inspector.dispose();blocks.dispose();receiptNodes.clear();if (dialog.open) dialog.close();dialog.remove();});
|
||
local.listen(close, 'click', () => closeRun());local.listen(dialog, 'close', () => closeRun());
|
||
local.listen(dialog, 'cancel', event => {event.preventDefault();closeRun();});
|
||
local.listen(retry, 'click', () => controller.retryRun());
|
||
local.listen(reload, 'click', () => {
|
||
if (['capture-changed', 'candidate-stale'].includes(latest.refreshRequired)) controller.loadOutputs();
|
||
else {closeRun();controller.refresh();}
|
||
});
|
||
local.listen(raw, 'click', () => {if (latest.actionsEnabled) controller.setMode('raw');});
|
||
local.listen(effective, 'click', () => {if (latest.actionsEnabled) controller.setMode('effective');});
|
||
local.listen(anchors, 'click', event => {const button = event.target.closest('[data-select-anchor]');if (button && latest.actionsEnabled) controller.selectContext(button.dataset.selectAnchor);});
|
||
local.listen(more, 'click', () => controller.loadOutputs());local.listen(retryOutputs, 'click', () => controller.retryOutputs());
|
||
local.listen(receipts, 'click', event => {
|
||
const button = event.target.closest('[data-view-receipt],[data-select-receipt],[data-retry-receipt]');if (!button || button.disabled) return;
|
||
const key = button.dataset.viewReceipt ?? button.dataset.selectReceipt ?? button.dataset.retryReceipt;
|
||
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === key);if (!receipt) return;
|
||
if (button.hasAttribute('data-view-receipt')) chooseReceipt(receipt, button);
|
||
else if (button.hasAttribute('data-select-receipt')) selectReceipt(receipt);
|
||
else {selectReceipt(receipt);void controller.retryReceipt(receipt.occurrence);}
|
||
});
|
||
local.cleanup(inspection.subscribe(state => {
|
||
if (closed) return;
|
||
const target = selectedReceipt()?.current_candidate.media;
|
||
if (target && inspected === occurrenceKey(target.occurrence) && state.error && state.refreshRequired) {
|
||
controller.inspectionFailed(target, state);
|
||
if (inspected === null || !latest.selection.receipt) return;
|
||
}
|
||
inspector.setState(state);
|
||
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || latest.outputs.loading || state.refreshRequired;
|
||
}));
|
||
function render(next) {
|
||
if (closed || local.signal.aborted) return;
|
||
const selectedRun = next.selection.run_id === runId, detail = selectedRun ? next.run.detail : null;
|
||
const enabled = next.actionsEnabled && selectedRun;
|
||
message.setAttribute('role', next.run.error || next.refreshRequired ? 'alert' : 'status');
|
||
message.textContent = next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : next.validating ? 'Revalidating recorded run…' : next.run.error && selectedRun ? 'Recorded run details could not be loaded.' : !detail ? 'Loading recorded run…' : '';
|
||
retry.hidden = !selectedRun || !next.run.error || !!next.refreshRequired;reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
|
||
body.hidden = !detail;raw.disabled = effective.disabled = !enabled;
|
||
if (!detail) {
|
||
if (renderedDetail) {
|
||
inspected = null;inspection.clear();resetViewer();blocks.setState({actionsEnabled:false});proof.replaceChildren();current.hidden = true;
|
||
renderedDetail = null;renderedRoles = '';metadata.replaceChildren();anchors.replaceChildren();receipts.replaceChildren();receiptNodes.clear();
|
||
}
|
||
return;
|
||
}
|
||
raw.setAttribute('aria-pressed', String(next.selection.mode === 'raw'));effective.setAttribute('aria-pressed', String(next.selection.mode === 'effective'));
|
||
if (detail !== renderedDetail) {
|
||
renderedDetail = detail;const run = detail.run;
|
||
metadata.replaceChildren(facts([
|
||
['Run ID', run.run_id], ['Session ID', run.session_id], ['Queue item ID', run.queue_item_id], ['Job / correlation ID', run.job_id],
|
||
['Observed dequeue', observed(run.observed_dequeue_at)], ['Observed dequeue · exact decimal milliseconds', run.observed_dequeue_at],
|
||
['Outcome', run.outcome], ['Outcome reason', run.reason], ['Context capture', `${detail.context_capture.state} · ${detail.context_capture.reason ?? 'not captured'}`],
|
||
['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`],
|
||
['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type],
|
||
['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'],
|
||
], evidenceValues));
|
||
if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted'));
|
||
anchors.replaceChildren();
|
||
for (const context of detail.contexts) {
|
||
const button = createButton(`Anchor ${context.anchor_id} · context ${context.context_id.slice(0, 8)}`);button.dataset.selectAnchor = context.anchor_id;anchors.append(button);
|
||
}
|
||
}
|
||
for (const button of anchors.querySelectorAll('button')) {button.disabled = !enabled;button.setAttribute('aria-pressed', String(button.dataset.selectAnchor === next.selection.anchor_id));}
|
||
const context = detail.contexts.find(item => item.anchor_id === next.selection.anchor_id);
|
||
const roleKey = `${next.selection.anchor_id}:${next.selection.mode}:${enabled}:${detail.context_capture.state}`;
|
||
if (roleKey !== renderedRoles) {
|
||
renderedRoles = roleKey;
|
||
blocks.setState({prompts:context ? frozenPromptRoles(context, next.selection.mode) : undefined, actionsEnabled:enabled});
|
||
proof.replaceChildren(el('p', 'Role assignment: inferred. Sources describe each field, not blanket execution proof.', 'muted'));
|
||
if (context) for (const role of ['positive', 'negative']) {
|
||
const section = el('section');section.append(el('h4', `${role[0].toUpperCase() + role.slice(1)} fields`));
|
||
for (const field of ['raw', 'effective', 'prepend', 'append']) {
|
||
const value = context[role]?.[field] ?? null, evidence = context.evidence[role]?.[field];
|
||
section.append(el('p', `${field[0].toUpperCase() + field.slice(1)} · ${evidence?.provenance || 'unknown'} · ${evidence?.source || 'unknown'}${evidence?.reason ? ` · reason: ${evidence.reason}` : ''}`, 'muted'));
|
||
section.append(el('pre', value === null ? 'Unknown · not recorded' : value === '' ? '(explicitly empty)' : value, 'prompt-text'));
|
||
}
|
||
proof.append(section);
|
||
}
|
||
}
|
||
const output = next.outputs;
|
||
const outputEvidence = output.capture ? {...output.capture, retained_occurrence_count:output.retained_occurrence_count, list_status:output.list_status} : detail.output_summary;
|
||
evidenceValues.get('Output capture').textContent = `${outputEvidence.state} · ${outputEvidence.reason ?? 'not captured'}`;
|
||
outputState.setAttribute('role', output.error ? 'alert' : 'status');
|
||
outputState.textContent = `${outputSummary(outputEvidence)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`;
|
||
more.hidden = !output.has_more;more.disabled = output.loading || !enabled;
|
||
retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired;
|
||
const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence)));
|
||
for (const [key, record] of receiptNodes) if (!keep.has(key)) {record.node.remove();receiptNodes.delete(key);}
|
||
for (const receipt of output.items) {
|
||
const key = occurrenceKey(receipt.occurrence), candidate = receipt.current_candidate;
|
||
let record = receiptNodes.get(key);
|
||
if (!record) {
|
||
const node = el('li', undefined, 'recorded-receipt');node.dataset.receipt = key;node.dataset.outputNodeId = receipt.occurrence.output_node_id;node.dataset.imageOrdinal = String(receipt.occurrence.image_ordinal);
|
||
const description = el('div'), select = createButton('Select reported output'), view = createButton('View current file'), retry = createButton('Retry current availability');
|
||
select.dataset.selectReceipt = view.dataset.viewReceipt = retry.dataset.retryReceipt = key;
|
||
select.title = `Select output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal}`;
|
||
node.append(description, select, view, retry);receipts.append(node);record = {node, description, select, view, retry};receiptNodes.set(key, record);
|
||
}
|
||
const reference = receipt.reference;
|
||
record.description.textContent = `Output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal} · ${reference.folder_type}/${[reference.subfolder, reference.filename].filter(Boolean).join('/')} · ${reference.path_style} · root namespace ${reference.root_key ?? 'not recorded'} · ${receipt.transient ? 'Transient' : 'Non-transient locator'} · capture ${output.capture?.state || detail.output_summary.state} · Current candidate: ${candidate.status}${candidate.reason ? ` · ${candidate.reason}` : ''}`;
|
||
record.node.setAttribute('aria-current', String(next.selection.receipt !== null && key === occurrenceKey(next.selection.receipt)));
|
||
record.select.setAttribute('aria-pressed', record.node.getAttribute('aria-current'));record.select.disabled = !enabled;
|
||
record.retry.hidden = candidate.status === 'available';record.retry.disabled = !enabled || output.loading;
|
||
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || output.loading || inspection.state.refreshRequired;
|
||
}
|
||
const selected = selectedReceipt(), selectedKey = selected && occurrenceKey(selected.occurrence);
|
||
current.hidden = !selected || inspected !== selectedKey || selected.current_candidate.status !== 'available';
|
||
if (inspected !== null && inspected !== selectedKey) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
|
||
if (!enabled && viewerOpen) resetViewer();
|
||
if (enabled && !initialOutputQueued && !output.capture && !output.loading && !output.error) {
|
||
initialOutputQueued = true;
|
||
queueMicrotask(() => {
|
||
initialOutputQueued = false;
|
||
if (!closed && !local.signal.aborted && latest.selection.run_id === runId && latest.actionsEnabled && latest.run.detail && !latest.run.loading && !latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error) controller.loadOutputs();
|
||
});
|
||
}
|
||
}
|
||
active = {close:closeRun, render};
|
||
dialog.showModal();
|
||
render(latest);
|
||
const reusable = latest.actionsEnabled && latest.selection.run_id === runId && latest.run.detail && latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error;
|
||
if (reusable) {
|
||
const context = latest.run.detail.contexts.find(context => (anchorId === null || context.anchor_id === anchorId) && (contextId === null || context.context_id === contextId));
|
||
if (context && context.anchor_id !== latest.selection.anchor_id) controller.selectContext(context.anchor_id);
|
||
} else void controller.selectRun(runId, {anchorId, contextId});
|
||
}
|
||
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
|
||
return {open, setState, dispose};
|
||
}
|