* 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.
92 lines
7.6 KiB
JavaScript
92 lines
7.6 KiB
JavaScript
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()];
|
|
}
|