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.
This commit is contained in:
Vito
2026-10-18 17:17:05 -07:00
committed by GitHub
parent 10197026be
commit ecd21165a7
54 changed files with 9929 additions and 232 deletions
+15 -3
View File
@@ -15,11 +15,23 @@
"use strict";
class ApiError extends Error {
constructor(status, message, code = null) {
constructor(status, message, code = null, recovery = null) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null;
this.recovery = null;
if (recovery && typeof recovery === "object" && !Array.isArray(recovery)) {
const keys = Object.keys(recovery);
if (keys.length === 1 && keys[0] === "action" &&
["refresh-history", "reload-outputs", "retry-read"].includes(recovery.action)) {
this.recovery = { action: recovery.action };
} else if (keys.length === 2 && keys.includes("action") && keys.includes("limit") &&
recovery.action === "smaller-limit" && Number.isInteger(recovery.limit) &&
recovery.limit >= 1 && recovery.limit <= 100) {
this.recovery = { action: "smaller-limit", limit: recovery.limit };
}
}
}
}
@@ -63,11 +75,11 @@
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
if (!response.ok) {
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code);
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery);
}
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
if (data && typeof data === "object" && data.success === false) {
throw new ApiError(response.status, serverMessage || "Request failed", data.code);
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery);
}
return data;
}
+8 -2
View File
@@ -101,7 +101,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
if (state.error && state.refreshRequired) {
if (onRefresh) {
const refresh = createButton('Refresh image collection');
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
} else if (state.error && onRetry) {
@@ -161,7 +161,13 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
inner.append(explanation);
}
const provenance = disclosure('File & provenance');
provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted'));
provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted'));
if (record.identity.kind === 'reported-location') {
const occurrence = record.identity.occurrence;
provenance.append(definitionList([
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
], 'file-occurrence'));
}
if (file) provenance.append(definitionList([
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
+91
View File
@@ -0,0 +1,91 @@
import {projectInspectionPayload} from './inspection-state.mjs';
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
function scalar(value, bytes, nonempty = false) {
if (typeof value !== 'string' || (nonempty && !value) || value.includes('\0') || /[\ud800-\udfff]/u.test(value) || new TextEncoder().encode(value).length > bytes) throw new TypeError('Invalid reported location scalar');
}
function coordinate(value) {
if (!object(value) || !uuid(value.run_id) || !Number.isSafeInteger(value.image_ordinal) || value.image_ordinal < 0 || value.image_ordinal > 255) throw new TypeError('Invalid output occurrence');
scalar(value.output_node_id, 512, true);
if ([...value.output_node_id].length > 128) throw new TypeError('Invalid output node');
}
function reference(value) {
if (!object(value) || !['output', 'temp'].includes(value.folder_type) || !['posix', 'windows'].includes(value.path_style) || !(value.root_key === null || digest(value.root_key))) throw new TypeError('Invalid output reference');
scalar(value.filename, 1024, true); scalar(value.subfolder, 2048);
const windows = value.path_style === 'windows', separators = windows ? /[/\\]/ : /\//;
if (['.', '..'].includes(value.filename) || separators.test(value.filename) || value.subfolder.startsWith('/') ||
(windows && (/^[\\/]/.test(value.subfolder) || /:/.test(value.filename + value.subfolder))) ||
(value.subfolder && value.subfolder.split(separators).some(part => !part || part === '.' || part === '..'))) throw new TypeError('Unsafe output reference');
if (128 + new TextEncoder().encode(value.folder_type + value.path_style + value.subfolder + value.filename + (value.root_key || '')).length > 4096) throw new TypeError('Output reference exceeds limit');
}
export function occurrenceKey(occurrence) {
coordinate(occurrence);
return JSON.stringify([occurrence.run_id, occurrence.output_node_id, occurrence.image_ordinal]);
}
function validateTarget(target) {
if (!object(target) || target.kind !== 'reported-location' || !uuid(target.history_epoch) || !digest(target.current_scope) || !digest(target.location_key) || typeof target.is_video !== 'boolean') throw new TypeError('Invalid reported location');
coordinate(target.occurrence); reference(target.reference);
if (target.filename !== target.reference.filename) throw new TypeError('Mismatched reported filename');
}
function urlFor(target, field, endpoint) {
const location = globalThis.location || globalThis.window?.location;
const base = new URL(location?.href || 'http://localhost/');
const resolve = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (value => value);
if (typeof target[field] !== 'string' || !target[field]) throw new TypeError('Missing reported media URL');
const value = resolve(target[field]), url = new URL(value, base);
const expected = new URL(resolve(`/prompt_manager/history/runs/${target.occurrence.run_id}/output/current/${endpoint}`), base);
const identity = {node:target.occurrence.output_node_id, ordinal:String(target.occurrence.image_ordinal), epoch:target.history_epoch, current_scope:target.current_scope};
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== base.origin || url.username || url.password || url.hash ||
url.pathname !== expected.pathname || /\/(?:\.|%2e){1,2}(?:\/|%2f)/i.test(value) ||
[...url.searchParams.keys()].some(key => !Object.hasOwn(identity, key) && key !== '_pm_viewer') || url.searchParams.getAll('_pm_viewer').length > 1 ||
Object.entries(identity).some(([key, expected]) => url.searchParams.getAll(key).length !== 1 || url.searchParams.get(key) !== expected)) throw new TypeError('Invalid reported media identity');
return value;
}
function validate(target) {
validateTarget(target);
urlFor(target, 'url', 'media'); urlFor(target, 'inspection_url', 'inspection'); urlFor(target, 'thumbnail_url', 'thumbnail');
}
function changed(code) {
return Object.assign(new Error('Current recorded location changed; reload before using this file.'), {status:409, refreshRequired:true, code, recovery:{action:code === 'history-changed' ? 'refresh-history' : 'reload-outputs'}});
}
function sameReference(a, b) {
return object(a) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => a[key] === b[key]);
}
export function toHistoryInspectionRecord(result, target, extractFacts) {
validate(target);
if (!object(result) || result.success !== true || result.history_version !== 1 || result.inspection_version !== 1) throw new TypeError('Invalid history inspection');
if (result.history_epoch !== target.history_epoch) throw changed('history-changed');
const current = result.current_output;
if (!object(current) || current.history_epoch !== target.history_epoch || current.current_scope !== target.current_scope || current.location_key !== target.location_key ||
!object(current.occurrence) || ['run_id', 'output_node_id', 'image_ordinal'].some(key => current.occurrence[key] !== target.occurrence[key]) || !sameReference(current.reference, target.reference)) throw changed('candidate-stale');
if (current.observation !== 'current-file' || current.evidence_class !== 'locator_candidate' || current.file_identity !== 'unverified' || current.newly_generated !== 'unknown' || current.context_membership !== 'unknown' || current.transient !== (target.reference.folder_type === 'temp')) throw new TypeError('Invalid occurrence evidence');
const path = [target.reference.subfolder, target.reference.filename].filter(Boolean).join('/');
const identity = {kind:'reported-location', key:target.location_key, path:target.reference.path_style === 'windows' ? path.replaceAll('\\', '/') : path,
occurrence:{...target.occurrence}, history_epoch:target.history_epoch, current_scope:target.current_scope, reference:{...target.reference}};
return projectInspectionPayload(result, identity, {url:urlFor(target, 'url', 'media'), filename:target.filename}, extractFacts);
}
export const reportedLocationAdapter = Object.freeze({
validate,
key(target) {validateTarget(target); return target.location_key;},
scope(target) {validateTarget(target); return `${target.history_epoch}:${target.current_scope}`;},
inspectionPath(target) {validate(target); return target.inspection_url;},
originalDownload(target) {validate(target); return {url:urlFor(target, 'url', 'media'), filename:target.filename};},
toRecord:toHistoryInspectionRecord,
});
/** Only loaded, currently available locations enter a frozen Viewer opening. */
export function distinctCurrentMedia(receipts, selectedOccurrence) {
if (!Array.isArray(receipts)) throw new TypeError('Invalid loaded receipts');
const selected = selectedOccurrence === null || selectedOccurrence === undefined ? null : occurrenceKey(selectedOccurrence);
const locations = new Map();
for (const receipt of receipts) {
if (receipt?.current_candidate?.status !== 'available') continue;
const target = receipt.current_candidate.media;
validate(target);
if (occurrenceKey(receipt.occurrence) !== occurrenceKey(target.occurrence) || !sameReference(receipt.reference, target.reference)) throw new TypeError('Mismatched receipt media');
if (!locations.has(target.location_key) || occurrenceKey(target.occurrence) === selected) locations.set(target.location_key, target);
}
return [...locations.values()];
}
+416
View File
@@ -0,0 +1,416 @@
import {occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
const id = value => Number.isSafeInteger(value) && value > 0;
const count = value => Number.isSafeInteger(value) && value >= 0;
const fields = ['raw', 'effective', 'prepend', 'append'];
const times = ['queue_item_id', 'observed_dequeue_at', 'observed_callback_at', 'executor_started_at', 'terminal_event_at'];
function requireValue(valid, message = 'Invalid recorded history response') {if (!valid) throw new TypeError(message);}
function text(value, maxBytes, maxChars = Infinity) {
return typeof value === 'string' && [...value].length <= maxChars && !/[\ud800-\udfff]/u.test(value) && new TextEncoder().encode(value).length <= maxBytes;
}
function node(value) {return text(value, 512, 128) && value.length > 0 && !value.includes('\0');}
function decimal(value) {return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,18})$/.test(value) && BigInt(value) <= 9223372036854775807n;}
function freeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
return value;
}
function envelope(value, epoch = null) {
requireValue(object(value) && value.success === true && value.history_version === 1 && uuid(value.history_epoch));
if (epoch !== null && value.history_epoch !== epoch) throw Object.assign(new Error('Recorded history changed'), {status:409, code:'history-changed'});
}
function pagination(value, previousCursor) {
requireValue(typeof value.has_more === 'boolean' && (value.next_cursor === null || (text(value.next_cursor, 4096) && value.next_cursor.length > 0)) &&
value.has_more === (value.next_cursor !== null) && (!value.has_more || value.next_cursor !== previousCursor));
}
function capture(value, output = false) {
requireValue(object(value) && ['complete', 'partial', 'ambiguous', 'unavailable', 'conflicting', 'not_captured'].includes(value.state) &&
(value.reason === null || text(value.reason, 128)) && (value.state === 'not_captured' ? value.format_version === null : value.format_version === 1));
if (output) requireValue(!['ambiguous'].includes(value.state) && (value.state === 'not_captured' ? value.source === null : value.source === 'host_final_history'));
}
function summary(value) {
capture(value, true);
requireValue((value.retained_occurrence_count === null || (count(value.retained_occurrence_count) && value.retained_occurrence_count <= 256)) &&
value.list_status === (value.state === 'not_captured' ? 'not_captured' : value.state === 'complete' ? 'complete' : 'incomplete') &&
(value.state === 'not_captured') === (value.retained_occurrence_count === null));
}
function evidence(value) {
requireValue(object(value) && ['recorded', 'inferred', 'unknown'].includes(value.provenance) &&
({recorded:['runtime_return', 'host_cache_output'], inferred:['graph_literal', 'graph_semantics'], unknown:['unknown']})[value.provenance].includes(value.source) &&
(value.provenance === 'unknown' ? ['unavailable', 'unsupported', 'ambiguous', 'limit_reached', 'missing'].includes(value.reason) : value.reason === null));
}
function anchor(value) {
requireValue(object(value) && node(value.anchor_id) && digest(value.context_id) &&
(value.positive_snapshot_id === null || digest(value.positive_snapshot_id)) && (value.negative_snapshot_id === null || digest(value.negative_snapshot_id)));
}
function validateRun(value, window = false) {
requireValue(object(value) && ['run_id', 'session_id', 'job_id', 'storage_epoch'].every(key => uuid(value[key])) && value.record_version === 1);
for (const key of times) requireValue((!['queue_item_id', 'observed_dequeue_at'].includes(key) && value[key] === null) || decimal(value[key]));
requireValue(['observed', 'succeeded', 'failed', 'interrupted', 'unknown'].includes(value.outcome) &&
(value.reason === null || text(value.reason, 128)) && typeof value.diagnostics_omitted === 'boolean');
const reasons = {succeeded:['terminal_success'], failed:['terminal_error'], interrupted:['terminal_interrupted'], unknown:['missing_terminal', 'conflicting_terminal', 'invalid_evidence', 'evidence_limit']};
requireValue(value.outcome === 'observed' ? value.reason === null && value.observed_callback_at === null : value.observed_callback_at !== null && reasons[value.outcome].includes(value.reason));
if (window) {
requireValue(typeof value.diagnostics_available === 'boolean' && Array.isArray(value.matching_anchors) && value.matching_anchors.length <= 16);
value.matching_anchors.forEach(anchor); capture(value.context_capture); summary(value.output_summary);
requireValue(new Set(value.matching_anchors.map(a => a.anchor_id)).size === value.matching_anchors.length);
}
}
function compareRun(a, b) {
for (const key of ['observed_dequeue_at', 'session_id', 'queue_item_id', 'run_id']) {
const av = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(a[key]) : a[key];
const bv = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(b[key]) : b[key];
if (av !== bv) return av < bv ? -1 : 1;
}
return 0;
}
function validateWindow(value, positiveId, cursor, epoch) {
envelope(value, epoch); pagination(value, cursor);
requireValue(value.items?.length ? text(value.resume_cursor, 4096) && value.resume_cursor.length > 0 : value.resume_cursor === null);
requireValue(object(value.group) && value.group.positive_id === positiveId && (value.group.raw_text_id === null || digest(value.group.raw_text_id)) &&
value.group.relation === 'exact_frozen_raw_text' && ['uncapturable_text', 'no_matches', 'matched'].includes(value.group.scope_status) &&
count(value.recorded_run_count) && Array.isArray(value.items) && value.items.length <= 100 && value.recorded_run_count >= value.items.length && Array.isArray(value.context_summaries));
value.items.forEach(run => validateRun(run, true));
requireValue(new Set(value.items.map(r => r.run_id)).size === value.items.length && value.items.every((r, i) => i === 0 || compareRun(value.items[i - 1], r) < 0));
const branches = new Set();
for (const s of value.context_summaries) {
requireValue(object(s) && digest(s.context_id) && !branches.has(s.context_id) && digest(s.positive_snapshot_id) && (s.negative_snapshot_id === null || digest(s.negative_snapshot_id)));
branches.add(s.context_id);
const raw = s.negative_raw;
requireValue(object(raw) && ['unknown', 'explicit_empty', 'known'].includes(raw.state) && typeof raw.truncated === 'boolean' && Array.isArray(raw.evidence) &&
(raw.state === 'unknown' ? raw.preview === null && !raw.truncated : text(raw.preview, 512, 100) && (raw.state === 'explicit_empty' ? raw.preview === '' && !raw.truncated : raw.preview !== '')));
for (const e of raw.evidence) {requireValue(uuid(e.run_id) && node(e.anchor_id)); evidence(e);}
}
requireValue((!value.has_more || value.items.length > 0) && value.items.every(r => r.matching_anchors.every(a => branches.has(a.context_id))));
for (const s of value.context_summaries) {
const matching = value.items.flatMap(r => r.matching_anchors.filter(a => a.context_id === s.context_id).map(a => ({...a, run_id:r.run_id})));
requireValue(matching.length > 0 && matching.every(a => a.positive_snapshot_id === s.positive_snapshot_id && a.negative_snapshot_id === s.negative_snapshot_id) &&
s.negative_raw.evidence.length === matching.length && new Set(s.negative_raw.evidence.map(e => JSON.stringify([e.run_id, e.anchor_id]))).size === matching.length &&
s.negative_raw.evidence.every(e => matching.some(a => a.run_id === e.run_id && a.anchor_id === e.anchor_id)));
}
}
function validateContext(value) {
anchor(value);
requireValue(uuid(value.reference_epoch) && object(value.evidence) && value.evidence.format_version === 1 && value.evidence.role_provenance === 'inferred');
requireValue(value.positive_raw_text_id === null ? value.positive?.raw == null : digest(value.positive_raw_text_id) && typeof value.positive?.raw === 'string');
for (const role of ['positive', 'negative']) {
const side = value[role], proof = value.evidence[role];
requireValue((side === null) === (value[`${role}_snapshot_id`] === null));
if (side === null) {requireValue(proof === null); continue;}
requireValue(object(side) && object(proof));
for (const field of fields) {
requireValue(side[field] === null || text(side[field], 65536)); evidence(proof[field]);
requireValue((side[field] === null) === (proof[field].provenance === 'unknown'));
}
}
requireValue(['positive_prompt_id', 'negative_prompt_id', 'pair_id'].every(key => value[key] === null || id(value[key])) && object(value.navigation) &&
['positive_available', 'negative_available', 'pair_available'].every(key => typeof value.navigation[key] === 'boolean'));
}
function validateDetail(value, runId, epoch) {
envelope(value, epoch); validateRun(value.run); capture(value.context_capture); summary(value.output_summary);
requireValue(value.run.run_id === runId && Array.isArray(value.contexts) && value.contexts.length <= 16);
value.contexts.forEach(validateContext);
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
}
export function frozenPromptRoles(context, mode) {
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
return Object.fromEntries(['positive', 'negative'].map(role => {
const value = context[role]?.[mode] ?? null, proof = context.evidence[role]?.[mode] ?? null;
return [role, {status:value === null ? 'unresolved' : 'resolved', text:value, source:proof?.provenance === 'recorded' ? 'execution' : proof?.provenance === 'inferred' ? 'graph-inferred' : null, evidence:proof,
message:value === null ? 'This frozen field was not recorded.' : undefined}];
}));
}
function validateReference(r) {
requireValue(object(r) && ['output', 'temp'].includes(r.folder_type) && ['posix', 'windows'].includes(r.path_style) && (r.root_key === null || digest(r.root_key)) &&
text(r.filename, 1024) && r.filename && !['.', '..'].includes(r.filename) && text(r.subfolder, 2048) && !r.filename.includes('\0') && !r.subfolder.includes('\0'));
const split = r.path_style === 'windows' ? /[/\\]/ : /\//;
requireValue(!split.test(r.filename) && !r.subfolder.startsWith('/') && (!r.subfolder || r.subfolder.split(split).every(p => p && p !== '.' && p !== '..')) &&
(r.path_style !== 'windows' || (!r.subfolder.startsWith('\\') && !/:/.test(r.filename + r.subfolder))));
}
function compareCoordinate(a, b) {
const aa = new TextEncoder().encode(a.output_node_id), bb = new TextEncoder().encode(b.output_node_id);
for (let i = 0; i < Math.min(aa.length, bb.length); i++) if (aa[i] !== bb[i]) return aa[i] - bb[i];
return aa.length - bb.length || a.image_ordinal - b.image_ordinal;
}
function validateOutputs(value, runId, epoch, cursor, previous) {
envelope(value, epoch); pagination(value, cursor); capture(value.capture, true);
summary({...value.capture, retained_occurrence_count:value.retained_occurrence_count, list_status:value.list_status});
requireValue(digest(value.capture_fingerprint) && Array.isArray(value.items) && value.items.length <= 100 && (value.retained_occurrence_count ?? 0) >= value.items.length);
if (cursor !== null && previous.capture_fingerprint !== value.capture_fingerprint) throw Object.assign(new Error('Output capture changed'), {status:409, code:'capture-changed'});
for (const r of value.items) {
occurrenceKey(r.occurrence); validateReference(r.reference);
requireValue(r.occurrence.run_id === runId && r.file_identity === 'unverified' && r.newly_generated === 'unknown' && r.context_membership === 'unknown' && r.transient === (r.reference.folder_type === 'temp'));
const c = r.current_candidate;
requireValue(object(c) && ['available', 'unavailable', 'error'].includes(c.status) && c.evidence_class === 'locator_candidate');
if (c.status === 'available') {
reportedLocationAdapter.validate(c.media);
requireValue(c.reason === null && c.current_scope === c.media.current_scope && c.location_key === c.media.location_key && c.media.history_epoch === epoch &&
occurrenceKey(c.media.occurrence) === occurrenceKey(r.occurrence) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => c.media.reference[key] === r.reference[key]) && text(c.relative_path, 4096) && text(c.source_label, 4096) &&
['none', 'unique', 'ambiguous'].includes(c.gallery_match) && (c.gallery_image_id === null || id(c.gallery_image_id)));
} else requireValue(text(c.reason, 128) && c.reason.length > 0 && !['media', 'url', 'thumbnail_url', 'inspection_url'].some(key => Object.hasOwn(c, key)));
}
requireValue(value.items.every((r, i) => i === 0 || compareCoordinate(value.items[i - 1].occurrence, r.occurrence) < 0));
if (cursor !== null && previous.items.length && value.items.length) requireValue(compareCoordinate(previous.items.at(-1).occurrence, value.items[0].occurrence) < 0);
}
const emptyWindow = () => ({items:[], context_summaries:[], group:null, recorded_run_count:0, next_cursor:null, resume_cursor:null, has_more:false, history_epoch:null, loading:false, error:null});
const emptyRun = () => ({detail:null, loading:false, error:null});
const emptyOutputs = () => ({items:[], capture:null, capture_fingerprint:null, retained_occurrence_count:null, list_status:null, next_cursor:null, has_more:false, loading:false, error:null});
const emptySelection = mode => ({run_id:null, anchor_id:null, context_id:null, mode, receipt:null});
/** Fixed run positions; an off-screen focused column remains in semantic order. */
export function visibleHistoryColumns(count, left, width, focusedIndex = -1) {
requireValue(Number.isSafeInteger(count) && count >= 0, 'Invalid history column count');
if (count <= 120) return Array.from({length:count}, (_, index) => index);
const start = Math.max(0, Math.floor((Number.isFinite(left) ? Math.max(0, left) : 0) / 64) - 3);
const right = (Number.isFinite(left) ? Math.max(0, left) : 0) + (Number.isFinite(width) ? Math.max(1, width) : 1);
const end = Math.min(count - 1, Math.ceil(right / 64) - 1 + 3);
const result = [];
for (let index = start; index <= end; index++) result.push(index);
if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0 && focusedIndex < count && !result.includes(focusedIndex)) {
result.push(focusedIndex);result.sort((a, b) => a - b);
}
return result;
}
export function createHistoryController({api, positiveId, onChange = () => {}, restore}) {
requireValue(id(positiveId), 'Invalid saved positive ID');
const offset = value => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0;
const intent = {search:typeof restore?.search === 'string' ? restore.search : '', scrollTop:offset(restore?.scrollTop), scrollLeft:offset(restore?.scrollLeft),
anchor:uuid(restore?.anchor?.runId) && digest(restore?.anchor?.contextId) && Number.isFinite(restore.anchor.x) && Number.isFinite(restore.anchor.y) ? {runId:restore.anchor.runId, contextId:restore.anchor.contextId, x:restore.anchor.x, y:restore.anchor.y} : null};
let state = freeze({window:emptyWindow(), run:emptyRun(), outputs:emptyOutputs(), selection:emptySelection(restore?.mode === 'effective' ? 'effective' : 'raw'),
parent:{detail:null, loading:false, error:null}, intent, refreshRequired:null, refreshLabel:'', validating:false, actionsEnabled:false});
let disposed = false, failedOutput = null, runOptions = null;
const receiptReads = new Map();
let restoreHint = uuid(restore?.runId) && (restore.anchorId === undefined || restore.anchorId === null || node(restore.anchorId)) && (restore.contextId === undefined || restore.contextId === null || digest(restore.contextId)) ? {runId:restore.runId, anchorId:restore.anchorId ?? null, contextId:restore.contextId ?? null} : null;
const restoreEpoch = uuid(restore?.historyEpoch) ? restore.historyEpoch : null;
let restoreRange = restoreEpoch && text(restore?.resumeCursor, 4096) && restore.resumeCursor && id(restore?.loadedCount) ? {cursor:restore.resumeCursor, pages:Math.ceil(restore.loadedCount / 40), epoch:restoreEpoch} : null;
if (restoreHint) {
restoreHint.epoch = restoreEpoch;restoreHint.outputCount = count(restore?.outputCount) && restore.outputCount <= 256 ? restore.outputCount : 0;restoreHint.outputsLoaded = restore?.outputsLoaded === true;
try {if (restoreEpoch && restoreHint.outputCount && restore?.receipt?.run_id === restoreHint.runId) {occurrenceKey(restore.receipt);restoreHint.receipt = {...restore.receipt};}} catch { /* Invalid selection intent cannot become a receipt. */ }
}
const requests = {window:null, run:null, outputs:null, parent:null, validation:null};
const generations = {window:0, run:0, outputs:0, parent:0, validation:0};
function cancel(slot) {generations[slot]++; requests[slot]?.abort(); requests[slot] = null;}
function begin(slot) {cancel(slot); const request = requests[slot] = new AbortController(), generation = generations[slot]; return {signal:request.signal, current:() => !disposed && requests[slot] === request && generations[slot] === generation};}
function emit(patch) {
if (disposed) return;
const next = {...state, ...patch};
next.actionsEnabled = !!next.window.history_epoch && !next.refreshRequired && !next.validating && !next.window.loading && !next.window.error && !next.run.loading && !next.run.error;
state = freeze(next); onChange(state);
}
function clearOutputs() {cancel('outputs'); failedOutput = null; receiptReads.clear(); return emptyOutputs();}
function clearDependents() {cancel('run'); return {run:emptyRun(), outputs:clearOutputs(), selection:emptySelection(state.selection.mode)};}
async function validateParent() {
const request = begin('parent'); emit({parent:{detail:null, loading:true, error:null}});
try {
const detail = await api.get(`/prompt_manager/library/${positiveId}`, {signal:request.signal});
if (!request.current()) return;
requireValue(object(detail) && object(detail.positive) && detail.positive.id === positiveId && typeof detail.positive.text === 'string');
emit({parent:{detail:structuredClone(detail), loading:false, error:null}});
} catch (error) {if (request.current()) emit({parent:{detail:null, loading:false, error}});}
}
function recover(error) {
if (error?.status !== 409 || !['history-changed', 'capture-changed', 'candidate-stale'].includes(error.code)) return false;
if (error.code === 'history-changed') {
restoreHint = null;restoreRange = null;runOptions = null;
cancel('window'); cancel('validation');
emit({...clearDependents(), window:emptyWindow(), validating:false, refreshRequired:error.code, refreshLabel:'Reload recorded history'});
void validateParent();
} else {
if (state.refreshRequired === error.code) {emit({outputs:{...state.outputs, loading:false, error}}); return true;}
emit({outputs:clearOutputs(), selection:{...state.selection, receipt:null}, refreshRequired:error.code, refreshLabel:'Reload reported outputs'});
void fetchOutputs(null, 40);
}
return true;
}
function mergeSummaries(earlier, later, items) {
const map = new Map();
for (const s of [...earlier, ...later]) {
const previous = map.get(s.context_id);
const evidence = new Map([...(previous?.negative_raw.evidence || []), ...s.negative_raw.evidence].map(e => [JSON.stringify([e.run_id, e.anchor_id]), e]));
map.set(s.context_id, {...s, negative_raw:{...s.negative_raw, evidence:[...evidence.values()]}});
}
const ordered = new Map(); for (const r of items) for (const a of r.matching_anchors) ordered.set(a.context_id, map.get(a.context_id));
return [...ordered.values()];
}
async function fetchWindow(earlier = false, {owner = null, expectedEpoch = null, resume = null} = {}) {
if (disposed || (earlier && (state.window.loading || !state.window.has_more || state.refreshRequired || (state.validating && !owner)))) return false;
const cursor = earlier ? state.window.next_cursor : null, epoch = earlier ? state.window.history_epoch : expectedEpoch;
const previous = state.window, request = begin('window');
emit({window:{...state.window, loading:true, error:null}});
const query = new URLSearchParams({limit:'40'});if (cursor !== null) query.set('cursor', cursor);else if (resume !== null) query.set('resume', resume);
try {
const value = await api.get(`/prompt_manager/library/${positiveId}/history?${query}`, {signal:request.signal});
if (!request.current() || (owner && !owner.current())) return false;
validateWindow(value, positiveId, cursor, epoch);
requireValue(!earlier || value.group.raw_text_id === previous.group.raw_text_id);
const page = structuredClone(value), merged = new Map();
for (const r of [...page.items, ...(earlier ? previous.items : [])]) merged.set(r.run_id, r);
const items = [...merged.values()].sort(compareRun);
emit({window:{...page, items, context_summaries:mergeSummaries(page.context_summaries, earlier ? previous.context_summaries : [], items), loading:false, error:null}});
return true;
} catch (error) {if (request.current() && (!owner || owner.current()) && !recover(error)) emit({window:{...state.window, loading:false, error}});return false;}
}
function selectedIntent() {
return state.selection.run_id ? {runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, epoch:state.window.history_epoch,
outputCount:state.outputs.items.length, outputsLoaded:state.outputs.capture !== null, receipt:state.selection.receipt && {...state.selection.receipt}} : null;
}
async function restoreSelection(hint, owner) {
await selectRun(hint.runId, {...hint, restoring:true});
if (!owner.current()) return;
if (!state.run.detail && (state.run.error?.status === 404 || state.run.error instanceof TypeError)) {
emit({validating:false, selection:emptySelection(state.selection.mode)});return;
}
if (state.run.detail && (hint.outputsLoaded || hint.outputCount)) {
const pages = Math.max(1, Math.ceil(hint.outputCount / 40));
for (let page = 0; page < pages; page++) {
await fetchOutputs(state.outputs.next_cursor, 40);
if (!owner.current()) return;
if (!state.outputs.capture || state.outputs.error || state.refreshRequired || !state.outputs.has_more || state.outputs.items.length >= hint.outputCount) break;
}
}
if (!owner.current()) return;
const receipt = hint.receipt && state.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(hint.receipt));
emit({validating:false, selection:{...state.selection, receipt:receipt ? {...receipt.occurrence} : null}});
}
async function loadHistory(useVisit = false) {
if (disposed) return;
const hint = selectedIntent() || (useVisit ? restoreHint : null), range = useVisit ? restoreRange : null;
const expectedEpoch = state.window.history_epoch || range?.epoch || hint?.epoch || null, owner = begin('validation');
cancel('window');emit({...clearDependents(), window:emptyWindow(), validating:true, refreshRequired:null, refreshLabel:''});
const pages = range?.pages || 1;
for (let page = 0; page < pages; page++) {
const loaded = await fetchWindow(page > 0, {owner, expectedEpoch, resume:page === 0 ? range?.cursor ?? null : null});
if (!owner.current()) return;
if (!loaded) {emit({validating:false});return;}
if (!state.window.has_more) break;
}
restoreHint = null;restoreRange = null;
if (hint && (hint.epoch === state.window.history_epoch || state.window.items.some(run => run.run_id === hint.runId))) void restoreSelection(hint, owner);
else emit({validating:false});
}
async function selectRun(runId, {anchorId = null, contextId = null, restoring = false} = {}) {
if (disposed) return;
requireValue(uuid(runId) && (anchorId === null || node(anchorId)) && (contextId === null || digest(contextId)), 'Invalid run selection');
requireValue(state.window.history_epoch !== null && !state.refreshRequired, 'Load recorded history before selecting a run');
if (!restoring) {cancel('validation');cancel('window');}
runOptions = {runId, anchorId, contextId};
const request = begin('run'), epoch = state.window.history_epoch;
emit({validating:restoring, window:{...state.window, loading:false}, run:{...emptyRun(), loading:true}, outputs:clearOutputs(), selection:{...emptySelection(state.selection.mode), run_id:runId}});
try {
const value = await api.get(`/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}`, {signal:request.signal});
if (!request.current()) return;
validateDetail(value, runId, epoch);
const detail = structuredClone(value);
requireValue(digest(state.window.group?.raw_text_id) && detail.contexts.some(context => context.positive_raw_text_id === state.window.group.raw_text_id), 'Recorded run is outside this positive history');
const selected = detail.contexts.find(c => (anchorId === null || c.anchor_id === anchorId) && (contextId === null || c.context_id === contextId));
requireValue((anchorId === null && contextId === null) || !!selected, 'Recorded context is unavailable');
emit({run:{detail, loading:false, error:null}, selection:{...state.selection, anchor_id:selected?.anchor_id ?? null, context_id:selected?.context_id ?? null}});
} catch (error) {if (request.current() && !recover(error)) emit({run:{detail:null, loading:false, error}});}
}
function cancelDependentRead() {cancel('outputs'); emit({outputs:{...state.outputs, loading:false}});}
async function fetchOutputs(cursor, limit) {
if (disposed || !state.run.detail || state.refreshRequired === 'history-changed') return;
const runId = state.selection.run_id, epoch = state.window.history_epoch, previous = state.outputs, request = begin('outputs');
const attempt = {cursor, limit};
emit({outputs:{...previous, loading:true, error:null}});
const query = new URLSearchParams({epoch, limit:String(limit)}); if (cursor !== null) query.set('cursor', cursor);
try {
const value = await api.get(`/prompt_manager/history/runs/${runId}/outputs?${query}`, {signal:request.signal});
if (!request.current()) return;
validateOutputs(value, runId, epoch, cursor, previous);
const page = structuredClone(value), items = cursor === null ? page.items : [...previous.items, ...page.items];
requireValue(items.length <= 256 && (page.retained_occurrence_count ?? 0) >= items.length && (page.has_more ? page.items.length > 0 && (page.retained_occurrence_count ?? 0) > items.length : (page.retained_occurrence_count ?? 0) === items.length));
if (cursor === null) receiptReads.clear();
const read = {cursor, limit, keys:page.items.map(item => occurrenceKey(item.occurrence))};
for (const key of read.keys) receiptReads.set(key, read);
failedOutput = null;
emit({outputs:{...page, items, loading:false, error:null}, refreshRequired:null, refreshLabel:''});
} catch (error) {
if (!request.current() || recover(error)) return;
failedOutput = attempt;
if (error?.status === 422 && error.code === 'response-limit' && error.recovery?.action === 'smaller-limit' && Number.isSafeInteger(error.recovery.limit) && error.recovery.limit >= 1 && error.recovery.limit < limit) failedOutput = {cursor, limit:error.recovery.limit};
emit({outputs:{...previous, loading:false, error}});
}
}
async function retryReceipt(occurrence) {
if (disposed || state.refreshRequired || state.validating || state.outputs.loading || !state.run.detail) return;
const key = occurrenceKey(occurrence), read = receiptReads.get(key);
requireValue(!!read, 'Reported output is unavailable');
const previous = state.outputs, request = begin('outputs'), runId = state.selection.run_id, epoch = state.window.history_epoch;
let cursor = read.cursor, limit = failedOutput?.receipt && occurrenceKey(failedOutput.receipt) === key ? failedOutput.limit : read.limit;
emit({outputs:{...previous, loading:true, error:null}});
try {
const replacements = new Map();
// A reduced response budget may need several reads, bounded by this one loaded page.
for (let offset = 0; offset < read.keys.length;) {
const query = new URLSearchParams({epoch, limit:String(limit)});if (cursor !== null) query.set('cursor', cursor);
const value = await api.get(`/prompt_manager/history/runs/${runId}/outputs?${query}`, {signal:request.signal});
if (!request.current()) return;
validateOutputs(value, runId, epoch, cursor, {...previous, items:[]});
if (value.capture_fingerprint !== previous.capture_fingerprint) throw Object.assign(new Error('Output capture changed'), {status:409, code:'capture-changed'});
const page = structuredClone(value);
requireValue(page.items.length > 0 && page.items.length <= read.keys.length - offset);
for (const [index, item] of page.items.entries()) {
const itemKey = occurrenceKey(item.occurrence), old = previous.items.find(item => occurrenceKey(item.occurrence) === itemKey);
requireValue(itemKey === read.keys[offset + index] && old && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(field => old.reference[field] === item.reference[field]));
replacements.set(itemKey, item);
}
offset += page.items.length;
if (replacements.has(key)) break;
requireValue(page.has_more);cursor = page.next_cursor;
}
requireValue(replacements.has(key));failedOutput = null;
emit({outputs:{...previous, items:previous.items.map(item => replacements.get(occurrenceKey(item.occurrence)) || item), loading:false, error:null}});
} catch (error) {
if (!request.current() || recover(error)) return;
if (error?.status === 422 && error.code === 'response-limit' && error.recovery?.action === 'smaller-limit' && Number.isSafeInteger(error.recovery.limit) && error.recovery.limit >= 1 && error.recovery.limit < limit) limit = error.recovery.limit;
failedOutput = {receipt:{...occurrence}, limit};
emit({outputs:{...previous, loading:false, error}});
}
}
async function revalidate() {
if (disposed || !state.window.history_epoch || state.refreshRequired) return;
const request = begin('validation'), epoch = state.window.history_epoch, runId = state.selection.run_id;
cancel('window'); cancel('run'); cancel('outputs');
emit({validating:true, window:{...state.window, loading:false}, run:{...state.run, loading:false}, outputs:{...state.outputs, loading:false}});
try {
const value = await api.get(runId ? `/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}` : `/prompt_manager/library/${positiveId}/history?limit=40`, {signal:request.signal});
if (!request.current()) return;
if (runId) validateDetail(value, runId, epoch); else validateWindow(value, positiveId, null, epoch);
const hadOutputs = state.outputs.capture !== null;
emit({validating:false, ...(runId ? {run:{detail:structuredClone(value), loading:false, error:null}} : {}),
...(hadOutputs ? {outputs:clearOutputs(), selection:{...state.selection, receipt:null}} : {})});
if (hadOutputs) return fetchOutputs(null, 40);
} catch (error) {if (request.current() && !recover(error)) emit({validating:false, refreshRequired:'revalidation-failed', refreshLabel:'Reload recorded history', window:{...state.window, error}});}
}
return {
get state() {return state;}, load:() => loadHistory(true), refresh:() => {restoreRange = null;return loadHistory();}, loadEarlier:() => fetchWindow(true), selectRun,
selectContext(anchorId) {
if (disposed) return;
requireValue(node(anchorId), 'Invalid context anchor'); const c = state.run.detail?.contexts.find(c => c.anchor_id === anchorId); requireValue(!!c, 'Recorded context is unavailable');
cancelDependentRead(); emit({selection:{...state.selection, anchor_id:c.anchor_id, context_id:c.context_id}});
},
setMode(mode) {if (disposed) return; requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); if (mode === state.selection.mode) return; cancelDependentRead(); emit({selection:{...state.selection, mode}});},
loadOutputs() {if (disposed || state.outputs.loading || (state.outputs.capture !== null && !state.outputs.has_more)) return; return fetchOutputs(state.outputs.next_cursor, 40);},
selectReceipt(occurrence) {
if (disposed) return;
const key = occurrenceKey(occurrence); requireValue(!state.refreshRequired && !state.validating && state.outputs.items.some(r => occurrenceKey(r.occurrence) === key), 'Reported output is unavailable');
cancel('outputs'); emit({outputs:{...state.outputs, loading:false}, selection:{...state.selection, receipt:{...occurrence}}});
},
retryRun() {if (!disposed && !state.refreshRequired && runOptions) return selectRun(runOptions.runId, runOptions);},
retryReceipt,
retryOutputs() {if (!disposed && !state.refreshRequired && failedOutput) return failedOutput.receipt ? retryReceipt(failedOutput.receipt) : fetchOutputs(failedOutput.cursor, failedOutput.limit);},
inspectionFailed(target, error) {
if (disposed || state.validating || state.refreshRequired || !error?.error || !error.refreshRequired || !state.selection.receipt) return;
const selected = occurrenceKey(state.selection.receipt), candidate = state.outputs.items.find(item => occurrenceKey(item.occurrence) === selected)?.current_candidate;
if (candidate?.status !== 'available' || occurrenceKey(target.occurrence) !== selected || target.history_epoch !== state.window.history_epoch ||
target.current_scope !== candidate.media.current_scope || target.location_key !== candidate.media.location_key) return;
recover({status:409, code:error.code, recovery:error.recovery});
},
revalidate,
dispose() {if (disposed) return; disposed = true; Object.keys(requests).forEach(cancel); failedOutput = null; runOptions = null; state = freeze({...state, ...clearDependents(), window:emptyWindow(), parent:{detail:null, loading:false, error:null}, actionsEnabled:false});},
};
}
+221
View File
@@ -0,0 +1,221 @@
import {createScope, createButton} from '../shared/toolkit/core.js';
import {createHistoryController, visibleHistoryColumns} from './history-state.mjs';
import {mountRecordedRun} from './recorded-run.mjs';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function preview(summary) {
const raw = summary.negative_raw;
return raw.state === 'unknown' ? 'Negative unknown' : raw.state === 'explicit_empty' ? 'Explicitly empty negative' : `${raw.preview}${raw.truncated ? '…' : ''}`;
}
function distinction(summary) {
const sources = [...new Set(summary.negative_raw.evidence.map(e => e.source))];
return `Frozen composition · ${sources.join(', ') || 'unknown evidence'}`;
}
function observedLabel(value) {
const time = Number(value);
return `Observed dequeue ${value} ms (${Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'outside display range'})`;
}
// New nodes use ordinary insertion; connected moves preserve browser state where supported.
function place(parent, node, before) {
if (node === before || (node.parentNode === parent && node.nextSibling === before)) return;
if (typeof parent.moveBefore === 'function' && parent.isConnected && node.isConnected && parent.ownerDocument === node.ownerDocument) parent.moveBefore(node, before);
else parent.insertBefore(node, before);
}
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
const scope = createScope(), dispose = () => scope.dispose();
if (parentScope.signal.aborted) {dispose();return {dispose};}
parentScope.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
const section = el('section', undefined, 'library-surface recorded-history');
section.setAttribute('aria-label', 'Recorded executions');
section.append(el('h2', 'Recorded executions'), el('p', 'same positive raw text · frozen contexts can differ in composition and evidence.', 'muted'));
const count = el('p', '', 'history-count muted');count.setAttribute('role', 'status');
const disclosure = el('details', undefined, 'history-scope');disclosure.append(el('summary', 'Recorded history scope'));
const scopeText = el('p', '', 'muted');disclosure.append(scopeText);
const toolbar = el('div', undefined, 'history-toolbar');
const earlier = createButton('Load earlier runs'), refresh = createButton('Refresh history'), retry = createButton('Retry recorded history');
const reload = createButton('Reload recorded history'), selected = createButton('View selected run');selected.hidden = true;
toolbar.append(earlier, refresh, retry, reload, selected);
const status = el('p', '', 'history-status muted');status.setAttribute('role', 'status');
const timeline = el('div', undefined, 'history-timeline');timeline.setAttribute('aria-label', 'Chronological branch timeline');
const canvas = el('div', undefined, 'history-canvas');
const edge = el('span', undefined, 'history-earlier-edge');edge.setAttribute('aria-hidden', 'true');
const positive = el('div', undefined, 'history-positive-lane');positive.append(el('span', 'Positive raw text · unchanged'));positive.setAttribute('aria-hidden', 'true');
const branches = el('div', undefined, 'history-context-branches');branches.setAttribute('aria-hidden', 'true');
const positions = el('ol', undefined, 'history-run-list');positions.setAttribute('aria-label', 'Recorded run positions');
canvas.append(edge, positive, branches, positions);timeline.append(canvas);
section.append(count, disclosure, toolbar, status, timeline, el('p', 'Green + positive · red − negative · small dot repeated frozen context. Colors identify prompt roles.', 'history-legend muted'));
container.append(section);
let state, runView, frame = 0, observer, items = null, summaries = [], indexes = new Map(), contexts = new Map();
let initialized = false, focusedRun = null, activeParent = null;
let restorePosition = typeof visit.resumeCursor === 'string' && Number.isFinite(visit.scrollLeft) && visit.scrollLeft >= 0;
const columns = new Map(), branchNodes = new Map();
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
render(next);runView?.setState(next);
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
}});
state = controller.state;
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
function restoreMarker(selection) {
const index = indexes.get(selection?.runId || state.selection.run_id);
if (index === undefined) {refresh.focus({preventScroll:true});return;}
focusedRun = items[index].run_id;renderColumns();
const column = columns.get(focusedRun);
const button = [...column.node.querySelectorAll('button')].find(node => node.dataset.contextId === (selection?.contextId || state.selection.context_id)) || column.node.querySelector('button');
button?.focus({preventScroll:true});
}
function captureAnchor() {
const bounds = timeline.getBoundingClientRect(), active = document.activeElement;
let button = timeline.contains(active) && active.matches('[data-run-id]') ? active : null;
if (!button) for (const column of columns.values()) {
const box = column.node.getBoundingClientRect();
if (box.right > bounds.left + 1 && box.left < bounds.right) {button = column.node.querySelector('button');break;}
}
if (!button) return null;
const box = button.getBoundingClientRect();
return {run:button.dataset.runId, context:button.dataset.contextId, x:box.left - bounds.left, y:box.top - bounds.top};
}
function restoreAnchor(anchor) {
const column = columns.get(anchor?.run);
const button = column && [...column.buttons.values()].find(node => node.dataset.contextId === anchor.context);
if (!button) return;
const box = button.getBoundingClientRect(), bounds = timeline.getBoundingClientRect();
timeline.scrollLeft += box.left - bounds.left - anchor.x;
timeline.scrollTop += box.top - bounds.top - anchor.y;
}
function columnFor(run) {
const node = el('li', undefined, 'history-run-column');node.dataset.historyRun = run.run_id;
const position = el('span', '', 'history-position');position.setAttribute('aria-hidden', 'true');node.append(position);
return {node, position, buttons:new Map()};
}
function renderColumns() {
if (scope.signal.aborted || !items) return;
const active = document.activeElement;
const focused = indexes.get(timeline.contains(active) ? active.closest('[data-history-run]')?.dataset.historyRun : focusedRun || state.selection.run_id) ?? -1;
const visible = visibleHistoryColumns(items.length, Math.max(0, timeline.scrollLeft - 38), timeline.clientWidth, focused);
const keep = new Set(visible.map(index => items[index].run_id));
for (const [id, column] of columns) if (!keep.has(id)) {column.node.remove();columns.delete(id);}
let before = positions.firstChild;
for (const index of visible) {
const run = items[index];let column = columns.get(run.run_id);
if (!column) {column = columnFor(run);columns.set(run.run_id, column);}
const {node, buttons} = column;
node.style.insetInlineStart = `${38 + index * 64}px`;node.value = index + 1;
node.setAttribute('aria-posinset', String(index + 1));node.setAttribute('aria-setsize', String(items.length));
column.position.textContent = String(index + 1);
const anchors = new Map();for (const anchor of run.matching_anchors) if (!anchors.has(anchor.context_id)) anchors.set(anchor.context_id, anchor);
for (const anchor of anchors.values()) {
let button = buttons.get(anchor.context_id);
if (!button) {
button = createButton('', 'history-run-marker');button.dataset.runId = run.run_id;button.dataset.anchorId = anchor.anchor_id;button.dataset.contextId = anchor.context_id;
buttons.set(anchor.context_id, button);node.append(button);
}
const context = contexts.get(anchor.context_id), first = context.first === index;
button.classList.toggle('history-context-anchor', first);
button.style.insetBlockStart = `${80 + context.row * 57}px`;
button.textContent = first ? '−' : '';
const label = `Recorded run ${index + 1} of ${items.length} loaded · ${observedLabel(run.observed_dequeue_at)} · Outcome ${run.outcome} · ${preview(context.summary)} · context ${anchor.context_id.slice(0, 8)} · anchor ${anchor.anchor_id}`;
button.setAttribute('aria-label', label);button.title = label;
button.setAttribute('aria-pressed', String(state.selection.run_id === run.run_id && state.selection.context_id === anchor.context_id));
// Disabling a focused native button drops focus during a prepend read.
button.setAttribute('aria-disabled', String(!state.actionsEnabled));
}
place(positions, node, before);before = node.nextSibling;
}
if (timeline.contains(active) && active.isConnected && document.activeElement !== active) active.focus({preventScroll:true});
}
function render(next) {
if (scope.signal.aborted) return;
const anchor = initialized && items !== next.window.items ? captureAnchor() : null;
state = next;
const window = next.window;
if (items !== window.items) {
items = window.items;indexes = new Map(items.map((run, index) => [run.run_id, index]));
const firsts = new Map();
for (const [index, run] of items.entries()) for (const anchor of run.matching_anchors) if (!firsts.has(anchor.context_id)) firsts.set(anchor.context_id, index);
summaries = [...window.context_summaries].sort((a, b) => firsts.get(a.context_id) - firsts.get(b.context_id) || (a.context_id < b.context_id ? -1 : 1));
contexts = new Map(summaries.map((summary, row) => [summary.context_id, {summary, row, first:firsts.get(summary.context_id)}]));
canvas.style.inlineSize = `${Math.max(520, 76 + items.length * 64)}px`;
canvas.style.blockSize = `${Math.max(150, 115 + summaries.length * 57)}px`;
const keep = new Set(contexts.keys());
for (const [id, node] of branchNodes) if (!keep.has(id)) {node.remove();branchNodes.delete(id);}
let before = branches.firstChild;
for (const [id, context] of contexts) {
let branch = branchNodes.get(id);
if (!branch) {branch = el('div', undefined, 'history-context-branch');branch.dataset.contextBranch = id;branch.append(el('span', '', 'history-context-label'));branchNodes.set(id, branch);}
branch.style.insetBlockStart = `${80 + context.row * 57}px`;branch.style.insetInlineStart = `${38 + context.first * 64}px`;
branch.firstChild.textContent = `${preview(context.summary)} · ${id.slice(0, 8)} · ${distinction(context.summary)}`;
place(branches, branch, before);before = branch.nextSibling;
}
if (anchor) {
const index = indexes.get(anchor.run);
if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x);
}
}
count.textContent = window.group ? `${window.recorded_run_count} distinct recorded runs as of this read · ${window.items.length} loaded positions` : '';
const group = window.group;
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status');
status.textContent = next.validating ? 'Revalidating recorded history…' : window.loading ? 'Loading recorded history…' : window.error ? 'Recorded history could not be loaded.' : next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : !items.length ? 'No matching recorded executions.' : window.has_more ? 'Newest bounded window · displayed in chronological order.' : 'All matching recorded runs in this window are loaded.';
if (next.run.error && !next.selection.run_id && !next.refreshRequired) status.textContent += ' The previous recorded selection could not be restored.';
const outside = !!next.selection.run_id && !indexes.has(next.selection.run_id);
if (outside && !next.validating && !next.refreshRequired) status.textContent += ` Selected run ${next.selection.run_id} is outside this loaded timeline window.`;
selected.hidden = !outside;selected.disabled = next.validating || window.loading || !!next.refreshRequired;
earlier.hidden = !window.has_more;earlier.disabled = window.loading || !!next.refreshRequired;earlier.setAttribute('aria-disabled', String(earlier.disabled));
refresh.disabled = window.loading || next.validating;retry.hidden = !window.error || !!next.refreshRequired;
reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
timeline.hidden = !items.length;
let initialAnchor = null;
if (!initialized && items.length && !window.loading && !next.validating && !next.refreshRequired) {
timeline.scrollLeft = restorePosition ? state.intent.scrollLeft : canvas.offsetWidth;
timeline.scrollTop = restorePosition ? state.intent.scrollTop : 0;
if (restorePosition && state.intent.anchor) initialAnchor = {run:state.intent.anchor.runId, context:state.intent.anchor.contextId, x:state.intent.anchor.x, y:state.intent.anchor.y};
initialized = true;restorePosition = false;
}
if (next.refreshRequired === 'history-changed') {initialized = false;restorePosition = false;}
renderColumns();if (initialAnchor || anchor) restoreAnchor(initialAnchor || anchor);
}
const schedule = () => {if (!frame) frame = requestAnimationFrame(() => {frame = 0;renderColumns();});};
scope.listen(timeline, 'scroll', schedule);scope.listen(window, 'resize', schedule);
scope.listen(positions, 'focusin', event => {focusedRun = event.target.closest('[data-history-run]')?.dataset.historyRun || focusedRun;});
scope.listen(positions, 'click', event => {
const button = event.target.closest('button[data-run-id]');if (!button || !state.actionsEnabled) return;
runView.open({runId:button.dataset.runId, anchorId:button.dataset.anchorId, contextId:button.dataset.contextId, trigger:button});
});
scope.listen(positions, 'keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !state.actionsEnabled) return;
const current = event.target.closest('[data-history-run]');if (!current) return;
const index = indexes.get(current.dataset.historyRun), next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : index + (event.key === 'ArrowLeft' ? -1 : 1);
if (next < 0 || next >= items.length) return;
event.preventDefault();focusedRun = items[next].run_id;renderColumns();
const button = columns.get(focusedRun).node.querySelector('button');button.focus({preventScroll:true});button.scrollIntoView({block:'nearest', inline:'nearest'});renderColumns();
});
scope.listen(earlier, 'click', () => controller.loadEarlier());scope.listen(refresh, 'click', () => {initialized = false;restorePosition = false;controller.refresh();});
scope.listen(selected, 'click', () => {
if (!selected.disabled && state.selection.run_id) runView.open({runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id, trigger:selected});
});
scope.listen(retry, 'click', () => controller.load());scope.listen(reload, 'click', () => state.refreshRequired === 'history-changed' || state.refreshRequired === 'revalidation-failed' ? controller.refresh() : controller.loadOutputs());
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {if (entries.some(entry => entry.isIntersecting) && state.actionsEnabled && state.window.has_more) controller.loadEarlier();}, {root:timeline});observer.observe(edge);
}
scope.listen(window, 'pagehide', dispose);
scope.cleanup(() => {
const anchor = captureAnchor();
Object.assign(visit, {mode:state.selection.mode, runId:state.selection.run_id, anchorId:state.selection.anchor_id, contextId:state.selection.context_id,
historyEpoch:state.window.history_epoch, resumeCursor:state.window.resume_cursor, loadedCount:state.window.items.length,
outputCount:state.outputs.items.length, outputsLoaded:state.outputs.capture !== null, receipt:state.selection.receipt && {...state.selection.receipt},
scrollLeft:timeline.scrollLeft, scrollTop:timeline.scrollTop, anchor:anchor && {runId:anchor.run, contextId:anchor.context, x:anchor.x, y:anchor.y}});
observer?.disconnect();cancelAnimationFrame(frame);runView.dispose();controller.dispose();columns.clear();branchNodes.clear();section.remove();
});
render(state);
void controller.load();
return {dispose};
}
+30 -15
View File
@@ -151,10 +151,16 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
if (!legacy && result.inspection_version !== 1) throw new TypeError('Unsupported inspection version');
if (!legacy && result.success !== true) throw new TypeError('Invalid inspection success status');
if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity');
return projectInspectionPayload(result,
{ kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
downloadFor(image, !legacy || scope !== null), extractFacts, legacy);
}
/** Shared bounded payload projection; adapters validate identity before entering here. */
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) {
const record = {
identity: { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
generation: unavailableFacts(), historyTarget: null, download: downloadFor(image, !legacy || scope !== null),
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
generation: unavailableFacts(), historyTarget: null, download,
};
if (legacy) return record;
const resolution = result.resolution;
@@ -170,7 +176,12 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
return record;
}
export function createInspectionLoader(api, onChange, { extractFacts } = {}) {
export const rootedFileAdapter = Object.freeze({
validate: validateIdentity, key: imageKey, scope: descriptorScope,
inspectionPath: inspectPath, originalDownload: originalMediaDownload, toRecord: toInspectionRecord,
});
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
let request = null, disposed = false;
return {
async load(image) {
@@ -179,24 +190,27 @@ export function createInspectionLoader(api, onChange, { extractFacts } = {}) {
const current = request = new AbortController();
onChange({ loading: true });
try {
const result = await api.get(inspectPath(image), { signal: current.signal });
const result = await api.get(adapter.inspectionPath(image), { signal: current.signal });
if (disposed || request !== current) return;
const record = toInspectionRecord(result, image, extractFacts);
const record = adapter.toRecord(result, image, extractFacts);
onChange({ prompts: record.prompts, record });
} catch (error) {
if (disposed || request !== current) return;
if (error?.status === 409 && error.code === 'source-changed') error = new InspectionSourceChangedError();
const refreshRequired = error instanceof InspectionSourceChangedError;
onChange({ error: true, refreshRequired, message: refreshRequired ? SOURCE_CHANGED : 'Could not read details from this file.' });
const historyRecovery = adapter !== rootedFileAdapter && error?.status === 409 && ['history-changed', 'candidate-stale', 'capture-changed'].includes(error.code);
const refreshRequired = error instanceof InspectionSourceChangedError || error?.refreshRequired === true || historyRecovery;
onChange({ error: true, refreshRequired, code: error?.code ?? null, recovery: error?.recovery ?? null,
message: error instanceof InspectionSourceChangedError ? SOURCE_CHANGED : refreshRequired ?
error.code === 'history-changed' ? 'Recorded history changed. Reload recorded history.' : 'Current output locations changed. Reload reported outputs.' : 'Could not read details from this file.' });
}
},
dispose() { disposed = true; request?.abort(); request = null; },
};
}
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false });
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
export function createInspectionController({ api, extractFacts }) {
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) {
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
let attempted, hasAttempt = false;
const listeners = new Set();
@@ -205,7 +219,7 @@ export function createInspectionController({ api, extractFacts }) {
state = Object.freeze({ ...emptyState(), ...update });
for (const listener of listeners) listener(state);
}
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts });
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
let loader = makeLoader();
function read(image) {
pending = loader.load(image);
@@ -217,14 +231,15 @@ export function createInspectionController({ api, extractFacts }) {
if (disposed) return;
let selected = null;
try {
const scope = validateIdentity(image), key = imageKey(image);
if (current?.key === key && current.scope === scope) return pending;
selected = { key, scope };
adapter.validate(image);
const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image);
if (current?.key === key && current.scope === scope && current.path === path) return pending;
selected = { key, scope, path };
} catch {
// The loader publishes validation errors after invalidating the previous source.
}
loader.dispose(); loader = makeLoader(); current = selected;
attempted = object(image) ? { ...image } : image; hasAttempt = true;
attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true;
return read(attempted);
},
retry() {
+76
View File
@@ -46,3 +46,79 @@ main.library-main { --library-positive: #73dda9; }
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
/* Role colors and branch geometry follow the approved history surface. */
main.library-main { --library-negative: #ff8c98; }
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
.library-main .library-branches { overflow-x: auto; position: relative; }
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
.library-main .library-variants .library-branches { margin-block: 0; }
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
.history-scope summary { cursor: pointer; }
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; }
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); color: #38151b; font-size: 12px; }
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-border); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
.recorded-run-dialog::backdrop { background: #0009; }
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
.recorded-run-header h2 { margin: 0; font-size: 20px; }
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
.recorded-provenance summary { cursor: pointer; }
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
.recorded-outputs { margin-block: 24px; }
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
.recorded-receipt > div { margin-block-end: 10px; }
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
@media (max-width: 700px) {
.library-main .library-surface { padding: 22px; }
.recorded-run-dialog { padding: 16px; }
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
}
@media (pointer: coarse) {
.recorded-history :where(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); }
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
}
@media (forced-colors: active) {
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
+64 -10
View File
@@ -5,9 +5,11 @@ import {mountImages} from './images.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
import {mountRecordedHistory} from './history.mjs';
let shelfVisit=null;
const variantSelections=new Map();
const historyVisits=new Map();
function el(tag,text,className) {
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
}
@@ -242,40 +244,92 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
}
detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text);
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
const summary=el('article',undefined,'library-surface');summary.append(el('span',detail.positive.category || 'Uncategorized','library-chip'));
const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip);
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
const all=createButton('All images for this positive');
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
const branches=el('div',undefined,'library-branches');branches.append(el('p','+ Positive prompt · unchanged','library-positive-lane'));
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
const choices=[];
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true;
contentScope.listen(more,'click',()=>loader.loadMore());contentScope.listen(retry,'click',()=>loader.loadMore());
contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();});
updateBranches=state=>{
const known=new Set(choices.map(choice=>choice.variant.id));
for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) {
const lane=el('div',undefined,'library-variant-lane');const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id));
lane.append(button,el('span',`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`));branches.append(lane);choices.push({button,variant});
const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label});
contentScope.listen(button,'click',()=>select(variant.id));
}
for(const [index,choice] of choices.entries()) {
choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant;
const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`;
choice.button.disabled=savedReadState!=='current';
}
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants));retry.hidden=!state.variantsError;
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError;
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';
variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':'';
};
updateBranches(state);
if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted'));
const note=el('p','Saved combinations, not an execution timeline.','muted');variants.append(branches,variantCount,variantError,more,retry,note,all);
const note=el('p','Saved combinations, not an execution timeline.','muted');
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
variants.append(branches,variantError,more,retry,variantFooter);
const context=el('p','','library-variant-context');context.setAttribute('role','status');
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;
body.append(subtitle,summary,variants,gallery);
const recorded=el('div',undefined,'library-recorded-container');
body.append(subtitle,summary,variants,recorded,gallery);
function setParentState(parent) {
if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true;
if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';}
else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;}
else {
const fresh=parent.detail;
if(fresh.positive.id!==positiveId || fresh.positive.text!==ownerText) {
savedReadState='stale';parentStatus.textContent='Saved Library owner changed. This view retains the previous prompt and gallery. Reload the saved Library view to inspect current data.';reloadSaved.hidden=false;
} else {
const observed=new Map(fresh.variants.map(variant=>[variant.id,variant]));const loaded=new Set(detail.variants.map(variant=>variant.id));const additional=fresh.variants.some(variant=>!loaded.has(variant.id));let changed=false,unobserved=false;
const variants=detail.variants.map(variant=>{
const current=observed.get(variant.id);
if(!current) {unobserved=true;return variant;}
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
return {...variant,...current};
});
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
savedReadState=changed || unobserved || additional?'stale':'current';
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
reloadSaved.hidden=savedReadState==='current';
}
}
updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});
}
async function retrySavedParent() {
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
try {
const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal});
if(contentScope.signal.aborted || generation!==parentGeneration) return;
if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details');
setParentState({detail:fresh});
} catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});}
}
contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
}});
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope});
function select(pairId) {
if(galleryScope && activePairId===pairId) return;activePairId=pairId;
variantSelections.set(positiveId,pairId);blocks.update(libraryPromptRoles(detail,pairId));
if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId;
variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true});
for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`;
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
+5 -5
View File
@@ -1,6 +1,6 @@
import {createScope, createButton} from '../shared/toolkit/core.js';
import {createViewerSession} from './viewer-state.mjs';
import {originalMediaDownload} from './inspection-state.mjs';
import {rootedFileAdapter} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
let viewerId = 0;
@@ -17,7 +17,7 @@ function element(tag, className, text) {
return node;
}
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose}) {
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
@@ -33,7 +33,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
const session = createViewerSession({items,initialKey:selectedKey,onChange:(state,selected) => {
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
update(state,selected);
}});
@@ -109,7 +109,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
const mediaLocal = mediaScope, key = session.state.key;
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
let original;
try { original = originalMediaDownload(selected); }
try { original = adapter.originalDownload(selected); }
catch { session.failed(key);return; }
const url = new URL(original.url,window.location.href);
url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`);
@@ -193,7 +193,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
if (position.textContent !== label) position.textContent = label;
if (currentKey !== state.key) {
currentKey = state.key;title.textContent = entry.filename;
try { originalDownload = originalMediaDownload(entry); } catch { originalDownload = null; }
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
onSelect?.(entry);
rebuild(entry);
}
+14 -2
View File
@@ -18,6 +18,12 @@ const statuses = {
ambiguous: 'Multiple possible prompts. Source could not be determined.',
unresolved: 'Prompt could not be resolved.',
};
const fieldSources = {
runtime_return: 'Runtime return',
host_cache_output: 'Host cache output',
graph_literal: 'Inferred graph literal',
graph_semantics: 'Inferred graph semantics',
};
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -39,6 +45,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
scope.listen(container, 'click', async event => {
if (!actionsEnabled) return;
const send = event.target.closest('[data-send-role]');
if (send) {
const role = send.dataset.sendRole;
@@ -57,10 +64,11 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
}
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
});
let roles = {};
let roles = {}, actionsEnabled = true;
function render(state) {
if (scope.signal.aborted) return;
roles = {};
actionsEnabled = state.actionsEnabled !== false;
container.replaceChildren();
if (state.loading || state.error) {
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
@@ -83,7 +91,8 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
heading.append(el('h3', title));
block.append(heading);
if (Object.hasOwn(roles, role)) {
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
const proof = record.evidence;
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
else {
const text = el('pre', roles[role], 'prompt-text');
@@ -93,11 +102,13 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
const actions = el('div', undefined, 'prompt-actions');
const send = createSendButton(`Send ${role} prompt`);
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;
const copy = createButton('', 'ca-icon-button');
copy.setAttribute('aria-label', `Copy ${role} prompt`);
copy.title = `Copy ${role} prompt`;
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
actions.append(copy, send);
heading.append(actions);
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
@@ -107,6 +118,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
}
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
pair.disabled = !actionsEnabled;
container.append(pair);
}
}
+246
View File
@@ -0,0 +1,246 @@
import {createScope, createButton} from '../shared/toolkit/core.js';
import {frozenPromptRoles} from './history-state.mjs';
import {mountPromptBlocks} from './prompt-blocks.mjs';
import {createInspectionController} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
import {createMediaViewer} from './media-viewer.mjs';
import {distinctCurrentMedia, occurrenceKey, reportedLocationAdapter} from './history-media.mjs';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function facts(entries, values) {
const list = el('dl', undefined, 'recorded-run-facts');
for (const [label, value] of entries) {const row = el('div'), description = el('dd', value ?? 'Not recorded');row.append(el('dt', label), description);list.append(row);values.set(label, description);}
return list;
}
function observed(value) {
const time = Number(value);
return Number.isSafeInteger(time) && time <= 8640000000000000 ? new Date(time).toISOString() : 'Recorded time outside display range';
}
function outputSummary(summary) {
if (summary.state === 'not_captured') return 'Reported outputs not captured';
const count = summary.retained_occurrence_count;
return summary.list_status === 'complete' ? `${count} reported outputs · complete` : `${count} retained reported output${count === 1 ? '' : 's'} · incomplete · ${summary.state}`;
}
export function mountRecordedRun(container, {api, controller, sender, parentScope, onClose, onRestoreFocus}) {
const scope = createScope(), dispose = () => scope.dispose();
let active = null, latest = controller.state;
if (parentScope.signal.aborted) {dispose();return {open() {}, setState() {}, dispose};}
parentScope.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
scope.cleanup(() => active?.close(false));
function open({runId, anchorId = null, contextId = null, trigger = document.activeElement}) {
if (scope.signal.aborted) return;
active?.close(false);
const local = createScope(), dialog = el('dialog', undefined, 'recorded-run-dialog');dialog.setAttribute('aria-label', 'Recorded run');
const header = el('header', undefined, 'recorded-run-header');
const close = createButton('×');close.setAttribute('aria-label', 'Close recorded run');close.autofocus = true;
header.append(el('h2', 'Recorded run'), close);
const message = el('p', '', 'recorded-run-status muted');message.setAttribute('role', 'status');
const retry = createButton('Retry run details'), reload = createButton('Reload recorded history');
const body = el('div', undefined, 'recorded-run-body');
const metadata = el('section');metadata.setAttribute('aria-label', 'Run evidence');
const anchors = el('div', undefined, 'recorded-run-anchors');anchors.setAttribute('role', 'group');anchors.setAttribute('aria-label', 'Recorded anchors');
const mode = el('div', undefined, 'recorded-run-mode');mode.setAttribute('role', 'group');mode.setAttribute('aria-label', 'Frozen text field');
const raw = createButton('Raw'), effective = createButton('Effective');mode.append(raw, effective);
const promptSection = el('section');promptSection.setAttribute('aria-label', 'Frozen run prompt texts');
promptSection.append(el('h3', 'Frozen prompt fields'), el('p', 'Only the selected stored field is shown. Role assignment: inferred. Field evidence is disclosed separately.', 'muted'));
const prompts = el('div');promptSection.append(prompts);
const provenance = el('details', undefined, 'recorded-provenance');provenance.append(el('summary', 'Frozen field provenance'));
const proof = el('div');provenance.append(proof);promptSection.append(provenance);
const outputs = el('section', undefined, 'recorded-outputs');outputs.setAttribute('aria-label', 'Reported outputs');
const outputState = el('p', '', 'recorded-output-status muted');outputState.setAttribute('role', 'status');
const receipts = el('ol', undefined, 'recorded-receipts');receipts.setAttribute('aria-label', 'Ordered reported output receipts');
const more = createButton('Load more reported outputs'), retryOutputs = createButton('Retry reported outputs');
outputs.append(el('h3', 'Reported outputs'), el('p', 'Host-reported locations · file identity unverified · newly generated unknown · Context membership unknown.', 'muted'), outputState, receipts, more, retryOutputs);
const current = el('section', undefined, 'recorded-current-file');current.setAttribute('aria-label', 'Current reported file');
current.append(el('h3', 'Current file at reported location'), el('p', 'Current file prompts and facts are separate from frozen run fields.', 'muted'));
const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true;
body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog);
const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter});
let closed = false, renderedDetail = null, renderedRoles = '', initialOutputQueued = false, inspected = null, viewerOpen = false;
const receiptNodes = new Map();
const evidenceValues = new Map();
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
function selectedReceipt() {
const selected = latest.selection.receipt;
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
}
function restoreReceiptFocus() {
const key = latest.selection.receipt && occurrenceKey(latest.selection.receipt);
const row = receiptNodes.get(key);(row?.view.hidden ? row.select : row?.view)?.focus({preventScroll:true});
}
const makeViewer = () => createMediaViewer({container, parentScope:local, inspectionController:inspection, sender, adapter:reportedLocationAdapter,
onSelect:entry => {
if (closed || !latest.actionsEnabled) return;
inspected = occurrenceKey(entry.occurrence);controller.selectReceipt(entry.occurrence);inspection.select(entry);
}, onRefresh:refreshCurrent, onRestoreFocus:restoreReceiptFocus,
onClose:() => {viewerOpen = false;restoreReceiptFocus();},
});
let viewer = makeViewer();
function resetViewer() {viewer.dispose();viewerOpen = false;viewer = makeViewer();}
function refreshCurrent() {
if (closed) return;
resetViewer();inspection.clear();inspected = null;
if (latest.refreshRequired === 'history-changed') controller.refresh();
else controller.revalidate();
}
function showViewer(triggerButton) {
if (!latest.actionsEnabled || inspection.state.refreshRequired || closed) return;
const receipt = selectedReceipt();if (receipt?.current_candidate.status !== 'available') return;
const items = distinctCurrentMedia(latest.outputs.items, latest.selection.receipt);
viewerOpen = true;viewer.open({items, selectedKey:reportedLocationAdapter.key(receipt.current_candidate.media), trigger:triggerButton || receiptNodes.get(occurrenceKey(receipt.occurrence))?.view});
}
function chooseReceipt(receipt, triggerButton) {
if (!latest.actionsEnabled || closed) return;
inspected = occurrenceKey(receipt.occurrence);controller.selectReceipt(receipt.occurrence);
inspection.select(receipt.current_candidate.media);showViewer(triggerButton);
}
function selectReceipt(receipt) {
if (!latest.actionsEnabled || closed) return;
inspected = null;controller.selectReceipt(receipt.occurrence);inspection.clear();if (viewerOpen) resetViewer();
}
function closeRun(restore = true) {
if (closed) return;closed = true;active = null;local.dispose();
if (restore && !scope.signal.aborted) {
if (trigger?.isConnected) trigger.focus({preventScroll:true});
else onRestoreFocus?.({runId, anchorId, contextId});
onClose?.();
}
}
local.cleanup(() => {viewer.dispose();inspection.dispose();inspector.dispose();blocks.dispose();receiptNodes.clear();if (dialog.open) dialog.close();dialog.remove();});
local.listen(close, 'click', () => closeRun());local.listen(dialog, 'close', () => closeRun());
local.listen(dialog, 'cancel', event => {event.preventDefault();closeRun();});
local.listen(retry, 'click', () => controller.retryRun());
local.listen(reload, 'click', () => {
if (['capture-changed', 'candidate-stale'].includes(latest.refreshRequired)) controller.loadOutputs();
else {closeRun();controller.refresh();}
});
local.listen(raw, 'click', () => {if (latest.actionsEnabled) controller.setMode('raw');});
local.listen(effective, 'click', () => {if (latest.actionsEnabled) controller.setMode('effective');});
local.listen(anchors, 'click', event => {const button = event.target.closest('[data-select-anchor]');if (button && latest.actionsEnabled) controller.selectContext(button.dataset.selectAnchor);});
local.listen(more, 'click', () => controller.loadOutputs());local.listen(retryOutputs, 'click', () => controller.retryOutputs());
local.listen(receipts, 'click', event => {
const button = event.target.closest('[data-view-receipt],[data-select-receipt],[data-retry-receipt]');if (!button || button.disabled) return;
const key = button.dataset.viewReceipt ?? button.dataset.selectReceipt ?? button.dataset.retryReceipt;
const receipt = latest.outputs.items.find(item => occurrenceKey(item.occurrence) === key);if (!receipt) return;
if (button.hasAttribute('data-view-receipt')) chooseReceipt(receipt, button);
else if (button.hasAttribute('data-select-receipt')) selectReceipt(receipt);
else {selectReceipt(receipt);void controller.retryReceipt(receipt.occurrence);}
});
local.cleanup(inspection.subscribe(state => {
if (closed) return;
const target = selectedReceipt()?.current_candidate.media;
if (target && inspected === occurrenceKey(target.occurrence) && state.error && state.refreshRequired) {
controller.inspectionFailed(target, state);
if (inspected === null || !latest.selection.receipt) return;
}
inspector.setState(state);
for (const receipt of receiptNodes.values()) receipt.view.disabled = !latest.actionsEnabled || latest.outputs.loading || state.refreshRequired;
}));
function render(next) {
if (closed || local.signal.aborted) return;
const selectedRun = next.selection.run_id === runId, detail = selectedRun ? next.run.detail : null;
const enabled = next.actionsEnabled && selectedRun;
message.setAttribute('role', next.run.error || next.refreshRequired ? 'alert' : 'status');
message.textContent = next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : next.validating ? 'Revalidating recorded run…' : next.run.error && selectedRun ? 'Recorded run details could not be loaded.' : !detail ? 'Loading recorded run…' : '';
retry.hidden = !selectedRun || !next.run.error || !!next.refreshRequired;reload.hidden = !next.refreshRequired;reload.textContent = next.refreshLabel || 'Reload recorded history';
body.hidden = !detail;raw.disabled = effective.disabled = !enabled;
if (!detail) {
if (renderedDetail) {
inspected = null;inspection.clear();resetViewer();blocks.setState({actionsEnabled:false});proof.replaceChildren();current.hidden = true;
renderedDetail = null;renderedRoles = '';metadata.replaceChildren();anchors.replaceChildren();receipts.replaceChildren();receiptNodes.clear();
}
return;
}
raw.setAttribute('aria-pressed', String(next.selection.mode === 'raw'));effective.setAttribute('aria-pressed', String(next.selection.mode === 'effective'));
if (detail !== renderedDetail) {
renderedDetail = detail;const run = detail.run;
metadata.replaceChildren(facts([
['Run ID', run.run_id], ['Session ID', run.session_id], ['Queue item ID', run.queue_item_id], ['Job / correlation ID', run.job_id],
['Observed dequeue', observed(run.observed_dequeue_at)], ['Observed dequeue · exact decimal milliseconds', run.observed_dequeue_at],
['Outcome', run.outcome], ['Outcome reason', run.reason], ['Context capture', `${detail.context_capture.state} · ${detail.context_capture.reason ?? 'not captured'}`],
['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`],
['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type],
['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'],
], evidenceValues));
if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted'));
anchors.replaceChildren();
for (const context of detail.contexts) {
const button = createButton(`Anchor ${context.anchor_id} · context ${context.context_id.slice(0, 8)}`);button.dataset.selectAnchor = context.anchor_id;anchors.append(button);
}
}
for (const button of anchors.querySelectorAll('button')) {button.disabled = !enabled;button.setAttribute('aria-pressed', String(button.dataset.selectAnchor === next.selection.anchor_id));}
const context = detail.contexts.find(item => item.anchor_id === next.selection.anchor_id);
const roleKey = `${next.selection.anchor_id}:${next.selection.mode}:${enabled}:${detail.context_capture.state}`;
if (roleKey !== renderedRoles) {
renderedRoles = roleKey;
blocks.setState({prompts:context ? frozenPromptRoles(context, next.selection.mode) : undefined, actionsEnabled:enabled});
proof.replaceChildren(el('p', 'Role assignment: inferred. Sources describe each field, not blanket execution proof.', 'muted'));
if (context) for (const role of ['positive', 'negative']) {
const section = el('section');section.append(el('h4', `${role[0].toUpperCase() + role.slice(1)} fields`));
for (const field of ['raw', 'effective', 'prepend', 'append']) {
const value = context[role]?.[field] ?? null, evidence = context.evidence[role]?.[field];
section.append(el('p', `${field[0].toUpperCase() + field.slice(1)} · ${evidence?.provenance || 'unknown'} · ${evidence?.source || 'unknown'}${evidence?.reason ? ` · reason: ${evidence.reason}` : ''}`, 'muted'));
section.append(el('pre', value === null ? 'Unknown · not recorded' : value === '' ? '(explicitly empty)' : value, 'prompt-text'));
}
proof.append(section);
}
}
const output = next.outputs;
const outputEvidence = output.capture ? {...output.capture, retained_occurrence_count:output.retained_occurrence_count, list_status:output.list_status} : detail.output_summary;
evidenceValues.get('Output capture').textContent = `${outputEvidence.state} · ${outputEvidence.reason ?? 'not captured'}`;
outputState.setAttribute('role', output.error ? 'alert' : 'status');
outputState.textContent = `${outputSummary(outputEvidence)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`;
more.hidden = !output.has_more;more.disabled = output.loading || !enabled;
retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired;
const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence)));
for (const [key, record] of receiptNodes) if (!keep.has(key)) {record.node.remove();receiptNodes.delete(key);}
for (const receipt of output.items) {
const key = occurrenceKey(receipt.occurrence), candidate = receipt.current_candidate;
let record = receiptNodes.get(key);
if (!record) {
const node = el('li', undefined, 'recorded-receipt');node.dataset.receipt = key;node.dataset.outputNodeId = receipt.occurrence.output_node_id;node.dataset.imageOrdinal = String(receipt.occurrence.image_ordinal);
const description = el('div'), select = createButton('Select reported output'), view = createButton('View current file'), retry = createButton('Retry current availability');
select.dataset.selectReceipt = view.dataset.viewReceipt = retry.dataset.retryReceipt = key;
select.title = `Select output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal}`;
node.append(description, select, view, retry);receipts.append(node);record = {node, description, select, view, retry};receiptNodes.set(key, record);
}
const reference = receipt.reference;
record.description.textContent = `Output node ${receipt.occurrence.output_node_id} · image ordinal ${receipt.occurrence.image_ordinal} · ${reference.folder_type}/${[reference.subfolder, reference.filename].filter(Boolean).join('/')} · ${reference.path_style} · root namespace ${reference.root_key ?? 'not recorded'} · ${receipt.transient ? 'Transient' : 'Non-transient locator'} · capture ${output.capture?.state || detail.output_summary.state} · Current candidate: ${candidate.status}${candidate.reason ? ` · ${candidate.reason}` : ''}`;
record.node.setAttribute('aria-current', String(next.selection.receipt !== null && key === occurrenceKey(next.selection.receipt)));
record.select.setAttribute('aria-pressed', record.node.getAttribute('aria-current'));record.select.disabled = !enabled;
record.retry.hidden = candidate.status === 'available';record.retry.disabled = !enabled || output.loading;
record.view.hidden = candidate.status !== 'available';record.view.disabled = !enabled || output.loading || inspection.state.refreshRequired;
}
const selected = selectedReceipt(), selectedKey = selected && occurrenceKey(selected.occurrence);
current.hidden = !selected || inspected !== selectedKey || selected.current_candidate.status !== 'available';
if (inspected !== null && inspected !== selectedKey) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();}
if (!enabled && viewerOpen) resetViewer();
if (enabled && !initialOutputQueued && !output.capture && !output.loading && !output.error) {
initialOutputQueued = true;
queueMicrotask(() => {
initialOutputQueued = false;
if (!closed && !local.signal.aborted && latest.selection.run_id === runId && latest.actionsEnabled && latest.run.detail && !latest.run.loading && !latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error) controller.loadOutputs();
});
}
}
active = {close:closeRun, render};
dialog.showModal();
render(latest);
const reusable = latest.actionsEnabled && latest.selection.run_id === runId && latest.run.detail && latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error;
if (reusable) {
const context = latest.run.detail.contexts.find(context => (anchorId === null || context.anchor_id === anchorId) && (contextId === null || context.context_id === contextId));
if (context && context.anchor_id !== latest.selection.anchor_id) controller.selectContext(context.anchor_id);
} else void controller.selectRun(runId, {anchorId, contextId});
}
function setState(state) {if (scope.signal.aborted) return;latest = state;active?.render(state);}
return {open, setState, dispose};
}
+7 -8
View File
@@ -1,18 +1,17 @@
import {imageKey} from './images-state.mjs';
import {inspectPath} from './inspection-state.mjs';
import {rootedFileAdapter} from './inspection-state.mjs';
/** A viewer visits only the descriptors loaded by its opening query. */
export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) {
export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, adapter = rootedFileAdapter}) {
if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required');
const keys = new Set();
const loaded = Object.freeze(items.map(item => {
inspectPath(item);
const key = imageKey(item);
adapter.validate(item);
const key = adapter.key(item);
if (keys.has(key)) throw new TypeError('Duplicate viewer identity');
keys.add(key);
return Object.freeze({...item});
return Object.freeze({...item, ...(adapter !== rootedFileAdapter && item.occurrence ? {occurrence:Object.freeze({...item.occurrence}), reference:Object.freeze({...item.reference})} : {})});
}));
const index = loaded.findIndex(item => imageKey(item) === initialKey);
const index = loaded.findIndex(item => adapter.key(item) === initialKey);
if (index < 0) throw new TypeError('Selected media is not loaded');
let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false});
let timer = null, deadline = 0;
@@ -42,7 +41,7 @@ export function createViewerSession({items, initialKey, onChange = () => {}, set
if (loaded.length === 1) return;
cancel();
const next = (state.index + direction + loaded.length) % loaded.length;
publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
publish({index:next,key:adapter.key(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
}
publish({});
return {