Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/file-inspector.mjs
T
Vito 048d599386 fix(inspect): bound inspection work, not graph size (#244)
* test(inspect): add synthetic inspection parity fixtures and legacy characterization

* test(inspect): pin test-time parity vectors and keep fixtures fully synthetic

* fix(inspect): bound resolver work instead of graph size

* fix(inspect): bound shared-node and stripped-text work in the inspection profile

Keep one memo per resolution whose entries carry their unfolded visit cost;
a memo hit charges that cost to the current candidate's allowance only.
Cache node validation and charge input keys, stripped characters and copied
provenance entries to a whole-resolution work budget before doing the work.
The default profile is unchanged.

* fix(inspect): keep memoized depth honest across inspection candidates

A memo entry now records its height below the reusing depth; a hit raises
depth-limit exactly where a fresh walk would, so each candidate's outcome
matches its anchored result and does not depend on trace or node order.
The default profile is unchanged.

* test(inspect): pin memo height propagation

Three samplers share one chain, each later one hanging below a memo hit.
The third candidate must report depth-limit in both trace orders and when
anchored; removing the memo-hit height propagation fails this test. The
provenance test now asserts a plain subset.

* fix(inspect): tolerate ComfyUI node metadata quirks in projections

Projections now take a kind. Each prompt node keeps exactly class_type and
inputs; _meta, is_changed and other node keys are validated and dropped, so
"is_changed": [NaN] no longer marks the whole prompt graph malformed. A
nonfinite value inside inputs, anywhere in a workflow, or in a retained
carrier member projects to null and is never reported as a number. The
carrier applies the prompt/workflow kinds to its members. Exact numbers are
unchanged (Decimal spelling, unsafe integers as strings) and duplicate keys
are still malformed. The 500-node gate is replaced by a 20,000-node backstop
(workflow count includes subgraph definitions). Raw records are untouched.

The f14 inspection vector's facts become known; default vectors are unchanged.

* fix(inspect): budget metadata per record so large workflows never hide prompts

PNG inspection no longer counts every text chunk against one shared 2 MiB
budget. Role records (prompt, parameters, the pair execution record) keep a
2 MiB budget together, encoded and decoded, and exceeding it still fails the
whole read. A workflow has its own 4 MiB budget: a chunk over it is skipped
by seek, and a compressed one stops inflating at budget + 1. Only the
workflow is then reported as limited (record format "limited", no raw text,
workflow_graph limited); prompts, facts and the prompt graph are unaffected.

Each text chunk's keyword is read first (at most 80 bytes). Chunks that are
not retained are skipped by length without being read or checksummed, so
memory stays bounded. Byte-identical duplicates of a retained record are
accepted and do not consume budget; differing duplicates still raise
"Duplicate prompt metadata", and a duplicate of a limited workflow stays
limited. The chunk-count limit is unchanged.

InspectionRead gains limited_records (empty by default). The reader is only
used by the inspection routes, so ingestion, scan and import are unchanged.

Vectors: f04-inspection (2.5 MB workflow, within the new workflow budget)
and f15-inspection (identical duplicate chunks) now read and resolve, with
the same outcome and facts as f05-inspection. Default vectors are unchanged.

* fix(inspect): keep prompt evidence longest under the response budget

* fix(inspect): bound identical duplicate chunk reads

* fix(inspect): fail closed when role duplicates exceed their budget

* fix(inspect): use the bounded inspection profile end to end

Inspection routes resolve with INSPECTION_PROFILE and expose reason_code
and node on the resolution; scan, live monitor and admin import keep the
default profile (pinned by spies over the real resolver on f03).

JS facts drop the entries and workflow node-count gates; per-lookup visit
sets and the server's node ceiling bound the work. The validator accepts
the textless `limited` workflow format, follows the workflow-first
response omission order, and checks the closed reason-code vocabulary.
The file inspector labels limited records and disables their workflow
download.

vectors.json: 12 inspection vectors gain their reason codes; f03 facts
become known and f01 facts ambiguous. Default vectors are unchanged. A
new parity test asserts vectors.json facts through the workspace JS.

* chore(tools): add read-only inspection corpus tool

tools/inspection_corpus.py runs the inspection route pipeline and the
workspace JS facts of a chosen code tree over a media folder, read-only,
and reports the #240 invariants H1 to H8 with aggregate counts. Rows hold
statuses and SHA-256 digests of resolved role texts, never file names or
prompt text unless --with-paths is given. Outputs inside the root or a code
tree are refused.

The facts-over-vectors test now fails instead of skipping when CI is set
and Node.js is missing.

* docs(testing): document bounded inspection work

* fix(tools): fail the corpus run when facts or inspection did not run

Rows whose facts were never computed (no or exiting Node.js) now fail H3,
and a new harness line fails on any tool exception or missing facts, so a
run where nothing was measured can no longer exit 0. Unreadable files stay
inspector outcomes. A missing --node binary is refused, an exited harness
is reaped, H8 has a 50 ms floor for tiny baselines, and JSONL rows are
sorted by id.

* docs(inspect): correct ceiling wording and bandit annotations

Describe the two 20,000-node ceilings (prompt entries, workflow UI plus
subgraph nodes) in TESTING.md, and use bare nosec ids so Bandit stops
warning about unparsed test names.

* test(inspect): make parity observation tuples explicit
2026-10-19 09:54:29 -07:00

251 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createScope, createButton } from '../shared/toolkit/core.js';
import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { createSendButton } from './prompt-sender.mjs';
import { createWorkspaceIcon } from './icons.mjs';
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
const CONTAINER_WORKFLOW = 'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.';
const LIMITED_WORKFLOW = 'Workflow exceeds inspection limits. Download the original file to inspect it externally.';
const REPRESENTATIONS = {
'original-text': 'Original embedded text',
'reserialized-json': 'Reserialized container JSON; numeric spelling/precision may differ',
invalid: 'Invalid container record; text is unavailable',
limited: 'Record exceeds inspection limits; text is unavailable',
};
const TEXTLESS = new Set(['invalid', 'limited']);
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'};
function safeFilename(value) {
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
}
/** Export only the bounded public evidence, never normalized graphs or container carriers. */
export function inspectionDownloads(record) {
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
const stem = name.replace(/\.[^.]+$/, '') || 'file';
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
const metadata = record?.metadata;
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
if (!metadata || metadata.status === 'limited') return result;
const raw = {}, record_formats = {}, representations = {};
for (const key of RAW_KEYS) {
if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) {
record_formats[key] = metadata.record_formats[key];
representations[key] = REPRESENTATIONS[record_formats[key]];
if (!TEXTLESS.has(record_formats[key]) && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key];
}
}
const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
generation:record.generation, library:record.library,
metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations,
scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}};
result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)};
if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW;
else if (record_formats.workflow === 'limited') result.workflow.reason = LIMITED_WORKFLOW;
else if (record_formats.workflow === 'original-text' && metadata.workflow_graph?.status === 'parsed' && typeof raw.workflow === 'string') {
result.workflow = {...result.workflow, available:true, reason:null, bodyText:raw.workflow};
} else result.workflow.reason = 'A valid original workflow JSON record is not available in this file.';
return result;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function childScope(parent) {
const scope = createScope(), dispose = () => scope.dispose();
parent.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parent.signal.removeEventListener('abort', dispose));
if (parent.signal.aborted) scope.dispose();
return scope;
}
function disclosure(label) {
const node = el('details'); node.append(el('summary', label)); return node;
}
function definitionList(entries, className) {
const list = el('dl', undefined, className);
for (const [label, value] of entries) {
const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row);
}
return list;
}
function factText(fact) {
if (fact?.status === 'known' && typeof fact.value === 'string') return fact.value;
if (fact?.status === 'ambiguous') return 'Ambiguous';
if (fact?.status === 'not-recorded') return 'Not recorded';
return fact?.reason === 'projection-limited' ? 'Unavailable within inspection limits' : 'Unresolved';
}
function formatBytes(value) {
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
container.classList.add('file-inspector');
scope.listen(container, 'pointerdown', onInteraction);
scope.listen(container, 'keydown', onInteraction);
scope.listen(container, 'click', onInteraction);
scope.listen(container, 'wheel', onInteraction, {passive:true});
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
function setState(state) {
if (scope.signal.aborted) return;
contentScope?.dispose(); contentScope = childScope(scope);
container.replaceChildren();
if (state.loading || state.error) {
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted');
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
if (state.error && state.refreshRequired) {
if (onRefresh) {
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) {
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry);
}
return;
}
const record = state.record;
if (!record) return;
const {file, library, metadata} = record;
const filename = record.identity.path.split('/').at(-1);
if (inline && record.download) {
const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview');
const video = file?.media_type === 'video';
const media = el(video ? 'video' : 'img'); media.src = record.download.url;
if (video) {
media.muted = true; media.playsInline = true; media.preload = 'metadata';
contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();});
} else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));}
cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview);
else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';}
container.append(cover);
}
const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner);
const header = el('div', undefined, 'file-title');
header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename));
const extension = file?.extension || filename.split('.').at(-1);
header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip'));
inner.append(header, el('p', filename, 'file-name muted'));
const chips = el('div', undefined, 'file-chips');
const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source));
const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null;
if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence'));
if (library?.status === 'linked' && library.category) {
const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category';
chips.append(el('span', `${label}: ${library.category}`, 'file-chip'));
}
if (chips.childNodes.length) inner.append(chips);
const prompts = el('div'); inner.append(prompts);
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (library?.status === 'linked') {
const vocabulary = el('div', undefined, 'file-vocabulary');
vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted'));
const tags = el('div', undefined, 'file-chips');
for (const tag of library.tags) tags.append(el('span', tag, 'file-chip'));
vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary);
} else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted'));
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts');
inner.append(facts);
if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) {
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted');
explanation.id = `file-fact-limit-${++factReasonId}`;
FACTS.forEach(([key], index) => {
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
});
inner.append(explanation);
}
const provenance = disclosure('File & provenance');
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],
['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type],
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
], 'file-facts'));
else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted'));
for (const [key, label] of FACTS) {
const fact = record.generation[key];
if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted'));
}
provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted'));
if (record.download) {
const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url;
original.download = safeFilename(record.download.filename); provenance.append(original);
}
inner.append(provenance);
const raw = disclosure('Workflow & raw metadata');
raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted'));
if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted'));
else {
if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted'));
for (const key of RAW_KEYS) {
const format = metadata.record_formats[key]; if (!format) continue;
raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted'));
if (Object.hasOwn(metadata.raw, key)) {
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text);
}
}
}
const downloads = inspectionDownloads(record);
for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) {
const item = downloads[key], action = createButton(label);
const group = el('div', undefined, 'file-download'); group.append(action);
if (!item.available) {
action.setAttribute('aria-disabled', 'true');
const explanation = el('p', item.reason, 'muted');
// IDs need only be unique within the current document, including its two inspectors.
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation);
}
contentScope.listen(action, 'click', () => {
if (!item.available || contentScope.signal.aborted) return;
const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime}));
let timer, released = false;
const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);};
contentScope.cleanup(release);
const anchor = el('a'); anchor.href = url; anchor.download = item.filename;
container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000);
});
raw.append(group);
}
inner.append(raw);
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
}
if (record.historyTarget) {
const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget;
history.prepend(createWorkspaceIcon('branch')); footer.append(history);
}
if (footer.childNodes.length) container.append(footer);
}
return {setState, dispose:() => scope.dispose()};
}
let downloadReasonId = 0, factReasonId = 0;
/** Compatible standalone controller; Images owns a single shared controller instead. */
export function mountPromptInspector(container, {api, image, sender, parentScope}) {
if (parentScope.signal.aborted) return {dispose() {}};
const scope = childScope(parentScope), controller = createInspectionController({api});
const view = mountFileInspector(container, {sender, parentScope:scope, onRetry:() => controller.retry()});
scope.cleanup(controller.subscribe(view.setState)); scope.cleanup(() => controller.dispose()); scope.cleanup(view.dispose);
controller.select(image);
return {dispose:() => scope.dispose()};
}