fix(inspector): guard fact ancestry and inspection state
This commit is contained in:
@@ -45,12 +45,16 @@ test("facts do not choose equal sampler branches or unrelated model", () => {
|
||||
});
|
||||
|
||||
test("facts respect named scalar outputs and custom scheduler paths", () => {
|
||||
const graph = factGraph({ seed: ["primitive", 0], steps: ["settings", 2], cfg: ["settings", 3], sampler_name: ["settings", 0], scheduler: ["settings", 1], model: ["lora", 0] });
|
||||
const graph = factGraph({ seed: ["primitive", 0], steps: ["steps", 0], cfg: ["cfg", 0], sampler_name: ["sampler", 0], scheduler: ["scheduler", 0], model: ["lora", 0] });
|
||||
graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } };
|
||||
graph.settings = { class_type: "SamplerCombo", inputs: { sampler_name: "euler", scheduler: "normal", steps: "50", cfg: "0" } };
|
||||
graph.steps = { class_type: "PrimitiveInt", inputs: { value: "50" } };
|
||||
graph.cfg = { class_type: "PrimitiveFloat", inputs: { value: "0" } };
|
||||
graph.sampler = { class_type: "PrimitiveString", inputs: { value: "euler" } };
|
||||
graph.scheduler = { class_type: "PrimitiveStringMultiline", inputs: { value: "normal" } };
|
||||
graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } };
|
||||
assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]);
|
||||
graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } };
|
||||
graph.s.inputs.steps = ["settings", 0];
|
||||
assert.equal(factsFor(graph).steps.status, "unresolved");
|
||||
const custom = {
|
||||
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
||||
@@ -68,6 +72,231 @@ test("facts respect named scalar outputs and custom scheduler paths", () => {
|
||||
assert.equal(factsFor(custom).sampler.value, "euler");
|
||||
});
|
||||
|
||||
test("facts require supported scalar producer types and output slots", () => {
|
||||
const cases = [
|
||||
["PrimitiveInt", "seed", "42", true], ["PrimitiveFloat", "cfg", "6.5000000000000000001", true],
|
||||
["PrimitiveString", "sampler_name", "euler", true], ["PrimitiveStringMultiline", "scheduler", "normal", true],
|
||||
["PrimitiveString", "seed", "42", false], ["PrimitiveStringMultiline", "steps", "42", false],
|
||||
["PrimitiveInt", "sampler_name", "euler", false], ["PrimitiveFloat", "scheduler", "normal", false],
|
||||
["PrimitiveBoolean", "seed", "42", false], ["PrimitiveNode", "seed", "42", false],
|
||||
["UnknownSettings", "seed", "42", false], ["SamplerCombo", "steps", "42", false],
|
||||
];
|
||||
for (const [class_type, input, value, supported] of cases) {
|
||||
for (const slot of [0, 1, 999, -1, 0.5]) {
|
||||
const graph = factGraph({ [input]: ["settings", slot] });
|
||||
graph.settings = { class_type, inputs: { value, [input]: value } };
|
||||
const fact = factsFor(graph)[input === "sampler_name" ? "sampler" : input];
|
||||
assert.equal(fact.status, supported && slot === 0 ? "known" : "unresolved", `${class_type} output${slot} as ${input}`);
|
||||
assert.equal(fact.value, supported && slot === 0 ? value : null);
|
||||
}
|
||||
}
|
||||
const graph = factGraph({ seed: ["primitive", 0] });
|
||||
graph.primitive = { class_type: "PrimitiveInt", inputs: { seed: "987" } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved", "a matching input is not the output's value");
|
||||
graph.primitive.inputs = { value: "42", seed: "987" };
|
||||
assert.equal(factsFor(graph).seed.value, "42");
|
||||
for (const [input, class_type, inputs] of [
|
||||
["seed", "RandomNoise", { noise_seed: "42", seed: "42" }],
|
||||
["sampler_name", "KSamplerSelect", { sampler_name: "euler" }],
|
||||
["steps", "BasicScheduler", { steps: "42" }],
|
||||
["cfg", "CFGGuider", { cfg: "7" }],
|
||||
]) {
|
||||
const graph = factGraph({ [input]: ["provider", 0] }); graph.provider = { class_type, inputs };
|
||||
assert.equal(factsFor(graph)[input === "sampler_name" ? "sampler" : input].status, "unresolved", `${class_type} is not a generic scalar output`);
|
||||
}
|
||||
});
|
||||
|
||||
function customFactGraph() {
|
||||
return {
|
||||
s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } },
|
||||
g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: ["cfg", 0] } },
|
||||
n: { class_type: "RandomNoise", inputs: { noise_seed: ["seed", 0] } },
|
||||
k: { class_type: "KSamplerSelect", inputs: { sampler_name: ["sampler", 0] } },
|
||||
sigmas: { class_type: "BasicScheduler", inputs: { steps: ["steps", 0], scheduler: ["scheduler", 0] } },
|
||||
seed: { class_type: "PrimitiveInt", inputs: { value: "9007199254740993" } },
|
||||
cfg: { class_type: "PrimitiveFloat", inputs: { value: "4.0000000000000001" } },
|
||||
steps: { class_type: "PrimitiveInt", inputs: { value: 22 } },
|
||||
sampler: { class_type: "PrimitiveString", inputs: { value: "euler" } },
|
||||
scheduler: { class_type: "PrimitiveString", inputs: { value: "karras" } },
|
||||
m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } },
|
||||
};
|
||||
}
|
||||
|
||||
test("facts require supported custom providers and guider outputs", () => {
|
||||
assert.deepEqual(factFields.map(field => factsFor(customFactGraph())[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]);
|
||||
for (const [input, nodeId, fields] of [["noise", "n", ["seed"]], ["sampler", "k", ["sampler"]], ["sigmas", "sigmas", ["steps", "scheduler"]], ["guider", "g", ["cfg", "model"]]]) {
|
||||
for (const slot of [1, 999, -1, 0.5]) {
|
||||
const graph = customFactGraph(); graph.s.inputs[input] = [nodeId, slot];
|
||||
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${input} output${slot} ${field}`);
|
||||
}
|
||||
for (const class_type of ["UnknownProvider", "PrimitiveInt", "PrimitiveString", "BasicGuider"]) {
|
||||
if (input === "guider" && class_type === "BasicGuider") continue;
|
||||
const graph = customFactGraph(); graph[nodeId].class_type = class_type;
|
||||
for (const field of fields) assert.equal(factsFor(graph)[field].status, "unresolved", `${class_type} cannot provide ${input}`);
|
||||
}
|
||||
}
|
||||
const basic = customFactGraph(); basic.g = { class_type: "BasicGuider", inputs: { model: ["m", 0] } };
|
||||
assert.equal(factsFor(basic).model.value, "flux.safetensors");
|
||||
assert.equal(factsFor(basic).cfg.status, "not-recorded");
|
||||
});
|
||||
|
||||
test("facts require model output ancestry with supported loaders and passthroughs", () => {
|
||||
for (const [class_type, key] of [["CheckpointLoader", "ckpt_name"], ["CheckpointLoaderSimple", "ckpt_name"], ["unCLIPCheckpointLoader", "ckpt_name"], ["UNETLoader", "unet_name"]]) {
|
||||
for (const slot of [0, 1, 2, 999, -1, 0.5]) {
|
||||
const graph = factGraph({ model: ["loader", slot] });
|
||||
graph.loader = { class_type, inputs: { [key]: "selected.safetensors" } };
|
||||
assert.equal(factsFor(graph).model.value, slot === 0 ? "selected.safetensors" : null, `${class_type} output${slot}`);
|
||||
assert.equal(factsFor(graph).model.status, slot === 0 ? "known" : "unresolved");
|
||||
}
|
||||
}
|
||||
for (const class_type of ["LoraLoader", "LoraLoaderModelOnly", "UnknownModelPassThrough"]) {
|
||||
for (const slot of [0, 1, 2, 999]) {
|
||||
const graph = factGraph({ model: ["lora", slot] }); graph.lora = { class_type, inputs: { model: ["model", 0] } };
|
||||
assert.equal(factsFor(graph).model.status, class_type !== "UnknownModelPassThrough" && slot === 0 ? "known" : "unresolved", `${class_type} output${slot}`);
|
||||
}
|
||||
}
|
||||
for (const [class_type, inputs] of [["UnknownLoader", { ckpt_name: "fake" }], ["PrimitiveString", { model_name: "fake", value: "fake" }], ["__proto__", { "[object Object]": "fake" }]]) {
|
||||
const graph = factGraph({ model: ["unknown", 0] }); graph.unknown = { class_type, inputs };
|
||||
assert.equal(factsFor(graph).model.status, "unresolved");
|
||||
}
|
||||
for (const [input, field] of [["sampler_name", "sampler"], ["scheduler", "scheduler"]]) assert.equal(factsFor(factGraph({ [input]: 42 }))[field].status, "unresolved");
|
||||
const numericModel = factGraph(); numericModel.model.inputs.ckpt_name = 42;
|
||||
assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence");
|
||||
});
|
||||
|
||||
test("wide workflow lookups retain selected core sampler facts", () => {
|
||||
const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = [];
|
||||
let linkId = 1;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
nodes.push({ id: `r${index}`, type: "Reroute", inputs: [{ name: "value", link: linkId }] });
|
||||
links.push([linkId++, index ? `r${index - 1}` : "text", 0, `r${index}`, 0, "STRING"]);
|
||||
}
|
||||
for (let index = 0; index < 40; index++) {
|
||||
nodes.push({ id: `encoder${index}`, type: "CLIPTextEncode", inputs: [{ name: "text", link: linkId }] });
|
||||
links.push([linkId++, "r19", 0, `encoder${index}`, 0, "STRING"]);
|
||||
}
|
||||
nodes.push({ id: "model", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } },
|
||||
{ id: "steps", type: "PrimitiveInt", widgets_values_named: { value: 28 } },
|
||||
{ id: "s", type: "KSampler", widgets_values: ["9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal"], inputs: [{ name: "model", link: linkId }, { name: "steps", link: linkId + 1 }] });
|
||||
links.push([linkId, "model", 0, "s", 0, "MODEL"], [linkId + 1, "steps", 0, "s", 1, "INT"]);
|
||||
const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph({ nodes, links }) });
|
||||
assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "selected.safetensors"]);
|
||||
});
|
||||
|
||||
test("workflow facts require supported passthrough output slots and bypass ancestry", () => {
|
||||
function workflow(type, slot, mode) {
|
||||
const pass = { id: "pass", type, ...(mode === undefined ? {} : { mode }), widgets_values: ["selected"], inputs: [{ name: type === "Reroute" ? "value" : "model", type: "MODEL", link: 1 }] };
|
||||
const nodes = [{ id: "m", type: "CheckpointLoaderSimple", widgets_values_named: { ckpt_name: "selected.safetensors" } }, pass,
|
||||
{ id: "s", type: "KSampler", widgets_values: [0, "fixed", 28, "0", "euler", "normal"], inputs: [{ name: "model", link: 2 }] }];
|
||||
if (type === "GetNode") {
|
||||
nodes.push({ id: "set", type: "SetNode", widgets_values: ["selected"], inputs: [{ name: "model", link: 1 }] });
|
||||
pass.inputs = [];
|
||||
}
|
||||
return { nodes, links: [[1, "m", 0, "pass", 0, "MODEL"], [2, "pass", slot, "s", 0, "MODEL"]] };
|
||||
}
|
||||
const model = value => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(value) }).model;
|
||||
for (const [type, mode] of [["Reroute", undefined], ["GetNode", undefined], ["SetNode", undefined], ["LoraLoader", 4], ["LoraLoaderModelOnly", 4]]) {
|
||||
for (const slot of [0, 1, 999, -1, 0.5]) {
|
||||
assert.equal(model(workflow(type, slot, mode)).status, slot === 0 ? "known" : "unresolved", `${type} output${slot}`);
|
||||
}
|
||||
}
|
||||
assert.equal(model(workflow("UnknownBypass", 0, 4)).status, "unresolved");
|
||||
const duplicate = workflow("LoraLoader", 0, 4);
|
||||
duplicate.nodes[1].inputs.push({ name: "model", type: "MODEL", link: 1 });
|
||||
assert.equal(model(duplicate).status, "unresolved", "bypass model input is uniquely named");
|
||||
const wrongOrigin = workflow("Reroute", 0); wrongOrigin.links[0][2] = 1;
|
||||
assert.equal(model(wrongOrigin).status, "unresolved", "passthrough cannot turn CLIP into MODEL");
|
||||
const cycle = workflow("Reroute", 0); cycle.links[0][1] = "pass";
|
||||
assert.equal(model(cycle).status, "unresolved");
|
||||
const unnamed = workflow("GetNode", 0);
|
||||
delete unnamed.nodes.find(node => node.id === "pass").widgets_values;
|
||||
delete unnamed.nodes.find(node => node.id === "set").widgets_values;
|
||||
assert.equal(model(unnamed).status, "unresolved", "missing Get/Set names are not a source association");
|
||||
const scalar = factGraph({ seed: ["bypass", 0] });
|
||||
scalar.bypass = { class_type: "LoraLoader", inputs: { model: ["primitive", 0] } };
|
||||
scalar.primitive = { class_type: "PrimitiveInt", inputs: { value: 42 } };
|
||||
const scalarWorkflow = factWorkflowFromGraph(scalar); scalarWorkflow.nodes.find(node => node.id === "bypass").mode = 4;
|
||||
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(scalarWorkflow) }).seed.status, "unresolved", "bypassed MODEL output cannot become a scalar");
|
||||
for (const [input, field] of [["noise", "seed"], ["sampler", "sampler"], ["sigmas", "steps"], ["guider", "model"]]) {
|
||||
const graph = customFactGraph(), target = graph.s.inputs[input];
|
||||
graph.s.inputs[input] = ["bypass", 0]; graph.bypass = { class_type: "LoraLoaderModelOnly", inputs: { model: target } };
|
||||
const workflow = factWorkflowFromGraph(graph); workflow.nodes.find(node => node.id === "bypass").mode = 4;
|
||||
assert.equal(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })[field].status, "unresolved", `MODEL output cannot become ${input}`);
|
||||
}
|
||||
});
|
||||
|
||||
function factWorkflowFromGraph(graph) {
|
||||
const nodes = [], links = [];
|
||||
for (const [id, node] of Object.entries(graph)) {
|
||||
const inputs = [], widgets_values_named = {};
|
||||
for (const [name, value] of Object.entries(node.inputs)) {
|
||||
if (Array.isArray(value)) {
|
||||
const link = links.length + 1;
|
||||
links.push([link, value[0], value[1], id, inputs.length, "*"]);
|
||||
inputs.push({ name, link });
|
||||
} else widgets_values_named[name] = value;
|
||||
}
|
||||
nodes.push({ id, type: node.class_type, inputs, widgets_values_named });
|
||||
}
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
test("workflow facts count combined original ancestry once across passthroughs and providers", () => {
|
||||
const inspect = graph => factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(factWorkflowFromGraph(graph)) });
|
||||
function chain(graph, reroutes, primitives, model = false) {
|
||||
let link = model ? ["model", 0] : null;
|
||||
for (let index = 0; index < primitives; index++) {
|
||||
graph[`p${index}`] = { class_type: model ? "LoraLoader" : "PrimitiveInt", inputs: model ? { model: link } : { value: link || "42" } };
|
||||
link = [`p${index}`, 0];
|
||||
}
|
||||
for (let index = 0; index < reroutes; index++) {
|
||||
graph[`r${index}`] = { class_type: "Reroute", inputs: { value: link } };
|
||||
link = [`r${index}`, 0];
|
||||
}
|
||||
return link;
|
||||
}
|
||||
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
||||
const scalar = factGraph(); scalar.s.inputs.seed = chain(scalar, reroutes, 32);
|
||||
assert.equal(inspect(scalar).seed.status, known ? "known" : "unresolved", `${reroutes}+32 scalar links`);
|
||||
const model = factGraph(); model.s.inputs.model = chain(model, reroutes, 31, true);
|
||||
assert.equal(inspect(model).model.status, known ? "known" : "unresolved", `${reroutes}+31 LoRA+loader links`);
|
||||
const noise = customFactGraph();
|
||||
noise.n.inputs.noise_seed = chain(noise, reroutes - 1, 31);
|
||||
noise.noiseAlias = { class_type: "Reroute", inputs: { value: ["n", 0] } };
|
||||
noise.s.inputs.noise = ["noiseAlias", 0];
|
||||
assert.equal(inspect(noise).seed.status, known ? "known" : "unresolved", `noise alias+provider+${reroutes - 1}+31 links`);
|
||||
const guider = customFactGraph();
|
||||
guider.model = { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } };
|
||||
guider.g.inputs.model = chain(guider, reroutes - 1, 30, true);
|
||||
guider.guiderAlias = { class_type: "Reroute", inputs: { value: ["g", 0] } };
|
||||
guider.s.inputs.guider = ["guiderAlias", 0];
|
||||
assert.equal(inspect(guider).model.status, known ? "known" : "unresolved", `guider alias+provider+${reroutes - 1}+30 LoRA+loader links`);
|
||||
}
|
||||
const cycle = factGraph();
|
||||
cycle.s.inputs.seed = ["r", 0]; cycle.r = { class_type: "Reroute", inputs: { value: ["p", 0] } }; cycle.p = { class_type: "PrimitiveInt", inputs: { value: ["r", 0] } };
|
||||
assert.equal(inspect(cycle).seed.status, "unresolved");
|
||||
cycle.s.inputs.model = ["r", 0]; cycle.p = { class_type: "LoraLoader", inputs: { model: ["r", 0] } };
|
||||
assert.equal(inspect(cycle).model.status, "unresolved");
|
||||
for (const [reroutes, known] of [[32, true], [33, false]]) {
|
||||
const graph = factGraph(), value = chain(graph, reroutes, 31);
|
||||
const workflow = factWorkflowFromGraph(graph);
|
||||
const sourceLink = workflow.links.length + 1;
|
||||
workflow.nodes.push({ id: "set", type: "SetNode", widgets_values: ["seed"], inputs: [{ name: "value", link: sourceLink }] }, { id: "get", type: "GetNode", widgets_values: ["seed"], inputs: [] });
|
||||
workflow.links.push([sourceLink, value[0], 0, "set", 0, "INT"], [sourceLink + 1, "get", 0, "s", 0, "INT"]);
|
||||
workflow.nodes.find(node => node.id === "s").inputs.push({ name: "seed", link: sourceLink + 1 });
|
||||
const fact = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }).seed;
|
||||
assert.equal(fact.status, known ? "known" : "unresolved", `Get+${reroutes}+31 links, no extra setter expansion`);
|
||||
}
|
||||
});
|
||||
|
||||
test("workflow sampler selection retains prototype-named node identities", () => {
|
||||
const workflow = { nodes: [
|
||||
{ id: "__proto__", type: "KSampler", widgets_values: [1, "fixed", 20, "7", "euler", "normal"] },
|
||||
{ id: "other", type: "KSampler", widgets_values: [2, "fixed", 20, "7", "euler", "normal"] },
|
||||
], links: [] };
|
||||
assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "ambiguous"));
|
||||
});
|
||||
|
||||
test("workflow-only facts are configuration without creating sendable roles", () => {
|
||||
const workflow = { nodes: [
|
||||
{ id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] },
|
||||
@@ -136,6 +365,10 @@ test("conservative facts bound cycles fan-out visits and link depth", () => {
|
||||
graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved");
|
||||
assert.equal(factsFor(graph).model.status, "unresolved");
|
||||
graph.loop = { class_type: "PrimitiveInt", inputs: { value: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).seed.status, "unresolved", "registered scalar cycle is bounded");
|
||||
graph.loop = { class_type: "LoraLoader", inputs: { model: ["loop", 0] } };
|
||||
assert.equal(factsFor(graph).model.status, "unresolved", "registered model cycle is bounded");
|
||||
const chain = depth => {
|
||||
const value = factGraph({ seed: ["p0", 0] });
|
||||
for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } };
|
||||
|
||||
@@ -78,6 +78,7 @@ test('record format validation never guesses original text', () => {
|
||||
assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid');
|
||||
data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit';
|
||||
data.metadata.prompt_graph = { status: 'limited', value: null };
|
||||
data.metadata.workflow_graph = { status: 'limited', value: null };
|
||||
const limited = toRecord(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
|
||||
assert.equal(limited.generation.seed.status, 'unresolved');
|
||||
@@ -88,6 +89,46 @@ test('record format validation never guesses original text', () => {
|
||||
assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing');
|
||||
});
|
||||
|
||||
test('record accepts actual atomic omission producer states', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const wholeRaw of [false, true]) {
|
||||
const data = envelope();
|
||||
data.metadata.record_formats.workflow = 'invalid';
|
||||
data.metadata.prompt_graph = { status: 'limited', value: null };
|
||||
data.metadata.workflow_graph = { status: 'limited', value: null };
|
||||
if (wholeRaw) Object.assign(data.metadata, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' });
|
||||
let record;
|
||||
assert.doesNotThrow(() => { record = toRecord(data, image(), extractGenerationFacts); }, 'actual producer omission remains renderable');
|
||||
assert.deepEqual(record.metadata.raw, wholeRaw ? {} : { prompt: '{"rounded":"raw copy"}' });
|
||||
assert.equal(record.metadata.status, wholeRaw ? 'limited' : 'available');
|
||||
assert.equal(record.metadata.omitted_reason, wholeRaw ? 'response-size-limit' : null);
|
||||
assert.deepEqual(record.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' });
|
||||
assert.equal(record.prompts.positive.text, 'exact\npositive');
|
||||
assert.equal(record.download.url, image().url);
|
||||
assert.ok(Object.values(record.generation).every(fact => fact.status === 'unresolved' && fact.reason === 'projection-limited'));
|
||||
}
|
||||
const absent = envelope();
|
||||
Object.assign(absent.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, record_formats: { parameters: 'original-text' }, parameters: null, prompt_graph: { status: 'absent', value: null } });
|
||||
assert.equal(toRecord(absent, image(), extractGenerationFacts).generation.seed.reason, 'projection-limited');
|
||||
});
|
||||
|
||||
test('record rejects contradictory omission provenance before extracting facts', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit' }),
|
||||
data => Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {} }),
|
||||
data => { data.metadata.raw = {}; },
|
||||
data => { data.metadata.record_formats.workflow = 'original-text'; data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [] } }; },
|
||||
data => { Object.assign(data.metadata, { status: 'limited', omitted_reason: 'response-size-limit', raw: {}, prompt_graph: { status: 'limited', value: null } }); data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'malformed', value: null }; },
|
||||
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.raw.workflow = 'null'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
|
||||
data => { data.metadata.record_formats.workflow = 'invalid'; data.metadata.workflow_graph = { status: 'limited', value: null }; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data); let extractions = 0;
|
||||
assert.throws(() => toRecord(data, image(), () => { extractions++; return extractGenerationFacts({}); }), TypeError);
|
||||
assert.equal(extractions, 0, 'contradictory transport cannot produce known facts');
|
||||
}
|
||||
});
|
||||
|
||||
test('record rejects root path media and invalid versioned fields with retryable TypeError', () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
for (const mutate of [
|
||||
@@ -226,3 +267,45 @@ test('disposed or superseded responses never publish', async () => {
|
||||
await controller.select(newer); await controller.retry(); controller.clear();
|
||||
assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
const invalidSelections = () => [null, undefined, 0, 'new.png', {}, { ...image(), path: '../new.png' }, { ...image(), root_scope: null }, { ...image(), root_scope: 'malformed' }];
|
||||
|
||||
test('invalid attempted selections clear completed actions and retry validation only', async () => {
|
||||
for (const invalid of invalidSelections()) {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
const previous = controller.select(image()); requests[0].resolve(envelope()); await previous;
|
||||
assert.equal(controller.state.prompts.positive.text, 'exact\npositive');
|
||||
let rejected, thrown;
|
||||
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
|
||||
assert.equal(controller.state.record, null, 'invalid navigation cannot retain completed actions'); assert.equal(controller.state.prompts, undefined);
|
||||
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
|
||||
await rejected;
|
||||
assert.deepEqual([controller.state.loading, controller.state.error, controller.state.refreshRequired, controller.state.message], [false, true, false, 'Could not read details from this file.']);
|
||||
await controller.retry(); await controller.retry(); assert.equal(requests.length, 1);
|
||||
const valid = controller.select(image()); assert.equal(requests.length, 2);
|
||||
requests[1].resolve(envelope()); await valid;
|
||||
await controller.select({ ...image() }); assert.equal(requests.length, 2);
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid attempted selections abort pending reads and suppress late results', async () => {
|
||||
for (const invalid of invalidSelections()) {
|
||||
const { api, requests } = pendingApi(), changes = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
controller.subscribe(state => changes.push(state));
|
||||
const previous = controller.select(image());
|
||||
let rejected, thrown;
|
||||
try { rejected = controller.select(invalid); } catch (error) { thrown = error; }
|
||||
assert.equal(requests[0].signal.aborted, true);
|
||||
assert.equal(thrown, undefined, 'selection validation uses the retryable state');
|
||||
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
await rejected; const count = changes.length;
|
||||
requests[0].resolve(envelope()); await previous;
|
||||
assert.equal(changes.length, count); assert.equal(controller.state.error, true);
|
||||
assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined);
|
||||
await controller.retry(); assert.equal(requests.length, 1);
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
+107
-32
@@ -59,7 +59,7 @@
|
||||
* their workflow widget text back. Otherwise the API graph is what actually ran
|
||||
* (expanded dynamic prompts, linked text) and always wins.
|
||||
*/
|
||||
function createContext(graph, workflow, conservative = false, budget = { remaining: MAX_VISITS }) {
|
||||
function createContext(graph, workflow, conservative = false) {
|
||||
const widgets = new Map();
|
||||
for (const n of workflowNodes(workflow)) {
|
||||
const text = widgetsOf(n)[0];
|
||||
@@ -82,7 +82,7 @@
|
||||
ids.forEach((id) => widgetText.set(id, widgets.get(id)));
|
||||
}
|
||||
}
|
||||
return { graph, widgetText, conservative, budget };
|
||||
return { graph, widgetText, conservative };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@
|
||||
*/
|
||||
function enter(ctx, link, seen) {
|
||||
if (!isLink(link) || seen.size >= MAX_VISITS) return [];
|
||||
if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64 || ctx.budget.remaining-- <= 0)) return [];
|
||||
if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64)) return [];
|
||||
const id = String(link[0]);
|
||||
const node = !ctx.conservative || Object.hasOwn(ctx.graph, id) ? ctx.graph[id] : undefined;
|
||||
const key = `${id}:${link[1]}`;
|
||||
@@ -110,13 +110,15 @@
|
||||
if (!isLink(value)) return value;
|
||||
const [, node] = enter(ctx, value, seen);
|
||||
if (!node) return undefined;
|
||||
if (ctx.conservative) {
|
||||
if (node[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && value[1] === 0 ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
|
||||
const kind = ["seed", "noise_seed", "steps", "cfg"].includes(name) ? "number" : "string";
|
||||
return value[1] === 0 && SCALAR_OUTPUTS[node.class_type] === kind && Object.hasOwn(node.inputs, "value") ?
|
||||
resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
|
||||
}
|
||||
if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) {
|
||||
return resolveScalar(ctx, node.inputs[name], name, seen);
|
||||
}
|
||||
if (ctx.conservative) {
|
||||
return value[1] === 0 && /^(Primitive(Int|Float|String|Boolean|Node)|PrimitiveStringMultiline)$/.test(node.class_type)
|
||||
&& Object.hasOwn(node.inputs, "value") ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
|
||||
}
|
||||
for (const v of Object.values(node.inputs)) {
|
||||
const resolved = resolveScalar(ctx, v, name, seen);
|
||||
if (resolved !== undefined && typeof resolved !== "object") return resolved;
|
||||
@@ -191,11 +193,11 @@
|
||||
const [, node] = enter(ctx, link, seen);
|
||||
if (!node) return undefined;
|
||||
if (ctx.conservative) {
|
||||
const names = MODEL_NAME_KEYS.filter(key => Object.hasOwn(node.inputs, key));
|
||||
if (names.length) {
|
||||
const values = names.map(key => resolveScalar(ctx, node.inputs[key], key, new Set(seen)));
|
||||
return values.every(value => typeof value === "string" && value.trim() && value === values[0]) ? values[0] : undefined;
|
||||
}
|
||||
if (link[1] !== 0) return undefined;
|
||||
if (node[WORKFLOW_PASSTHROUGH]) return traceModelName(ctx, node.inputs.value, seen);
|
||||
const key = Object.hasOwn(MODEL_OUTPUTS, node.class_type) ? MODEL_OUTPUTS[node.class_type] : undefined;
|
||||
if (key) return resolveScalar(ctx, node.inputs[key], key, seen);
|
||||
return MODEL_PASSTHROUGHS.has(node.class_type) ? traceModelName(ctx, node.inputs.model, seen) : undefined;
|
||||
}
|
||||
const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string");
|
||||
return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen);
|
||||
@@ -290,12 +292,56 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function factWorkflowGraph(workflow) {
|
||||
const nodes = workflowNodes(workflow), links = new Map(), setters = new Map();
|
||||
for (const link of workflow.links || []) {
|
||||
if (Array.isArray(link)) links.set(link[0], { from: link[1], slot: link[2] });
|
||||
else links.set(link.id, { from: link.origin_id, slot: link.origin_slot });
|
||||
}
|
||||
const source = id => {
|
||||
const link = links.get(id);
|
||||
if (!link || !["string", "number"].includes(typeof link.from) || !Number.isSafeInteger(link.slot) || link.slot < 0) return undefined;
|
||||
return [String(link.from), link.slot];
|
||||
};
|
||||
for (const node of nodes) {
|
||||
const name = widgetsOf(node)[0];
|
||||
if (node.type === "SetNode" && node.mode !== MODE_MUTED && typeof name === "string") setters.set(name, node.inputs?.length === 1 ? node.inputs[0].link : undefined);
|
||||
}
|
||||
const graph = Object.create(null);
|
||||
for (const node of nodes) {
|
||||
if (node.mode === MODE_MUTED) continue;
|
||||
let pass = PASS_THROUGH_TYPES.has(node.type), link, kind = "value";
|
||||
if (pass) {
|
||||
const name = widgetsOf(node)[0];
|
||||
if (node.type === "GetNode") link = typeof name === "string" ? setters.get(name) : undefined;
|
||||
else if (node.inputs?.length === 1) link = node.inputs[0].link;
|
||||
}
|
||||
else if (node.mode === MODE_BYPASSED) {
|
||||
if (!MODEL_PASSTHROUGHS.has(node.type)) continue;
|
||||
const models = (node.inputs || []).filter(input => input.name === "model");
|
||||
pass = true; kind = "model"; link = models.length === 1 ? models[0].link : undefined;
|
||||
}
|
||||
if (pass) {
|
||||
// Retain the actual alias hop so a fact's full original ancestry shares one depth/visit set.
|
||||
graph[String(node.id)] = { class_type: node.type, inputs: { value: source(link) }, [WORKFLOW_PASSTHROUGH]: kind };
|
||||
continue;
|
||||
}
|
||||
const inputs = namedWidgets(node);
|
||||
for (const input of node.inputs || []) {
|
||||
if (input.link !== null && input.link !== undefined) inputs[input.name] = source(input.link);
|
||||
}
|
||||
graph[String(node.id)] = { class_type: node.type, inputs };
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UI workflow ({nodes, links}) into the API graph shape so images
|
||||
* without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and
|
||||
* bypassed nodes are resolved to their real source; muted nodes are dropped.
|
||||
*/
|
||||
function workflowToGraph(workflow, conservative = false, budget = { remaining: MAX_VISITS }) {
|
||||
function workflowToGraph(workflow, conservative = false) {
|
||||
if (conservative) return factWorkflowGraph(workflow);
|
||||
const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null);
|
||||
const byId = new Map(nodes.map((n) => [String(n.id), n]));
|
||||
const links = new Map();
|
||||
@@ -311,8 +357,7 @@
|
||||
const source = (linkId, hops = 0) => {
|
||||
const link = links.get(linkId);
|
||||
const node = link && byId.get(String(link.from));
|
||||
if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS ||
|
||||
(conservative && (hops >= 64 || budget.remaining-- <= 0))) return undefined;
|
||||
if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS) return undefined;
|
||||
const next = (id) => source(id, hops + 1);
|
||||
if (node.type === "Reroute") return next(node.inputs && node.inputs[0] && node.inputs[0].link);
|
||||
if (node.type === "GetNode") return next(setters.get(widgetsOf(node)[0]));
|
||||
@@ -331,7 +376,7 @@
|
||||
const inputs = namedWidgets(n);
|
||||
for (const i of n.inputs || []) {
|
||||
const src = i && i.link !== null && i.link !== undefined ? source(i.link) : undefined;
|
||||
if (src || (conservative && i && i.link !== null && i.link !== undefined)) inputs[i.name] = src;
|
||||
if (src) inputs[i.name] = src;
|
||||
}
|
||||
graph[String(n.id)] = { class_type: n.type, inputs };
|
||||
}
|
||||
@@ -359,6 +404,24 @@
|
||||
|
||||
const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"];
|
||||
const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]);
|
||||
const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
|
||||
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
|
||||
const SCALAR_OUTPUTS = Object.freeze({
|
||||
PrimitiveInt: "number", PrimitiveFloat: "number",
|
||||
PrimitiveString: "string", PrimitiveStringMultiline: "string",
|
||||
});
|
||||
const MODEL_OUTPUTS = Object.freeze({
|
||||
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
|
||||
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
|
||||
});
|
||||
const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
|
||||
const CONFIGURATION_OUTPUTS = Object.freeze({
|
||||
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
|
||||
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
|
||||
BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] },
|
||||
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
|
||||
BasicGuider: { kind: "guider", fields: ["model"] },
|
||||
});
|
||||
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
function fact(status, source = null, samplerId = null, value = null, reason = null) {
|
||||
@@ -372,7 +435,7 @@
|
||||
function exactFactValue(value, field) {
|
||||
if (typeof value === "number") {
|
||||
// The projection carries decimals as strings; a float here cannot prove exactness.
|
||||
if (!Number.isFinite(value) || !Number.isSafeInteger(value)) return null;
|
||||
if (!["seed", "steps", "cfg"].includes(field) || !Number.isFinite(value) || !Number.isSafeInteger(value)) return null;
|
||||
value = String(value);
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
@@ -389,15 +452,22 @@
|
||||
return fact(value === null ? "unresolved" : "known", source, samplerId, value);
|
||||
}
|
||||
|
||||
function providerFact(ctx, link, keys, field, source, samplerId) {
|
||||
function configurationOutput(ctx, link, kind, seen) {
|
||||
const [id, node] = enter(ctx, link, seen);
|
||||
if (node?.[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && link[1] === 0 ? configurationOutput(ctx, node.inputs.value, kind, seen) : [];
|
||||
const adapter = node && CONFIGURATION_OUTPUTS[node.class_type];
|
||||
return node && link[1] === 0 && adapter?.kind === kind ? [id, node, adapter] : [];
|
||||
}
|
||||
|
||||
function providerFact(ctx, link, kind, keys, field, source, samplerId) {
|
||||
const seen = new Set();
|
||||
const [, node] = enter(ctx, link, seen);
|
||||
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
|
||||
if (!node) return fact("unresolved", source, samplerId);
|
||||
const key = keys.find(name => Object.hasOwn(node.inputs, name));
|
||||
const key = keys.find(name => adapter.fields.includes(name) && Object.hasOwn(node.inputs, name));
|
||||
return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen);
|
||||
}
|
||||
|
||||
function graphFacts(graph, source, resolution, budget) {
|
||||
function graphFacts(graph, source, resolution) {
|
||||
if (!object(graph)) return factSet("unresolved", source);
|
||||
const entries = Object.entries(graph);
|
||||
if (entries.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
|
||||
@@ -406,24 +476,30 @@
|
||||
const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id);
|
||||
if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source);
|
||||
const [samplerId, sampler] = selected || candidates[0];
|
||||
const ctx = createContext(graph, null, true, budget);
|
||||
const ctx = createContext(graph, null, true);
|
||||
const inputs = sampler.inputs;
|
||||
const advanced = sampler.class_type === "SamplerCustomAdvanced";
|
||||
const custom = advanced || sampler.class_type === "SamplerCustom";
|
||||
let settings = inputs;
|
||||
let settingsAdapter;
|
||||
const settingsPath = new Set();
|
||||
if (advanced) {
|
||||
const [, guider] = enter(ctx, inputs.guider, settingsPath);
|
||||
settings = guider && ["CFGGuider", "BasicGuider"].includes(guider.class_type) ? guider.inputs : null;
|
||||
const [, guider, adapter] = configurationOutput(ctx, inputs.guider, "guider", settingsPath);
|
||||
settings = guider ? guider.inputs : null;
|
||||
settingsAdapter = adapter;
|
||||
}
|
||||
const fromSettings = (key, field) => settings ? scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath)) : fact("unresolved", source, samplerId);
|
||||
const fromSettings = (key, field) => {
|
||||
if (!settings) return fact("unresolved", source, samplerId);
|
||||
if (settingsAdapter && !settingsAdapter.fields.includes(key)) return fact(Object.hasOwn(settings, key) ? "unresolved" : "not-recorded", source, samplerId);
|
||||
return scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath));
|
||||
};
|
||||
const seedKey = Object.hasOwn(inputs, "seed") ? "seed" : "noise_seed";
|
||||
const facts = {
|
||||
seed: advanced ? providerFact(ctx, inputs.noise, ["noise_seed", "seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId),
|
||||
steps: custom ? providerFact(ctx, inputs.sigmas, ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId),
|
||||
seed: advanced ? providerFact(ctx, inputs.noise, "noise", ["noise_seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId),
|
||||
steps: custom ? providerFact(ctx, inputs.sigmas, "sigmas", ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId),
|
||||
cfg: fromSettings("cfg", "cfg"),
|
||||
sampler: custom ? providerFact(ctx, inputs.sampler, ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId),
|
||||
scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, ["scheduler"], "scheduler", source, samplerId),
|
||||
sampler: custom ? providerFact(ctx, inputs.sampler, "sampler", ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId),
|
||||
scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, "sigmas", ["scheduler"], "scheduler", source, samplerId),
|
||||
model: fact(settings && !Object.hasOwn(settings, "model") ? "not-recorded" : "unresolved", source, samplerId),
|
||||
};
|
||||
if (settings && Object.hasOwn(settings, "model")) {
|
||||
@@ -483,13 +559,12 @@
|
||||
if (!record || record.status === "absent") continue;
|
||||
if (record.status === "limited") return factSet("unresolved", source, "projection-limited");
|
||||
if (record.status !== "parsed") return factSet("unresolved", source);
|
||||
const budget = { remaining: MAX_VISITS };
|
||||
if (source === "workflow") {
|
||||
if (record.value?.nodes?.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
|
||||
if (!validFactWorkflow(record.value)) return factSet("unresolved", source);
|
||||
return graphFacts(workflowToGraph(record.value, true, budget), source, null, budget);
|
||||
return graphFacts(workflowToGraph(record.value, true), source, null);
|
||||
}
|
||||
return graphFacts(record.value, source, resolution, budget);
|
||||
return graphFacts(record.value, source, resolution);
|
||||
}
|
||||
if (metadataStatus === "limited") return factSet("unresolved", null, "projection-limited");
|
||||
if (metadataStatus === "unreadable") return factSet("unresolved", null, "metadata-unreadable");
|
||||
|
||||
@@ -107,10 +107,15 @@ function metadataFor(value) {
|
||||
}
|
||||
if (value.parameters !== (raw.parameters ?? null)) throw new TypeError('Invalid inspection parameters projection');
|
||||
const prompt = graphFor(value.prompt_graph), workflow = graphFor(value.workflow_graph);
|
||||
const wholeRawOmitted = value.status === 'limited';
|
||||
if (wholeRawOmitted && (Object.keys(raw).length || value.parameters !== null)) throw new TypeError('Invalid atomic raw omission');
|
||||
if (!wholeRawOmitted && Object.entries(formats).some(([key, format]) => format !== 'invalid' && !Object.hasOwn(raw, key))) throw new TypeError('Missing represented raw record');
|
||||
const invalidGraphOmitted = [['prompt', prompt], ['workflow', workflow]].some(([key, graph]) => formats[key] === 'invalid' && graph.status === 'limited');
|
||||
for (const [key, graph] of [['prompt', prompt], ['workflow', workflow]]) {
|
||||
const hasRecord = Object.hasOwn(formats, key);
|
||||
if ((formats[key] === 'invalid' && graph.status !== 'malformed') ||
|
||||
if ((formats[key] === 'invalid' && !['malformed', 'limited'].includes(graph.status)) ||
|
||||
hasRecord !== (graph.status !== 'absent')) throw new TypeError('Invalid graph representation provenance');
|
||||
if (hasRecord && (wholeRawOmitted || invalidGraphOmitted) && graph.status !== 'limited') throw new TypeError('Invalid atomic graph omission');
|
||||
}
|
||||
if ((value.status === 'limited') !== (value.omitted_reason === 'response-size-limit')) throw new TypeError('Invalid metadata omission status');
|
||||
if (['absent', 'unreadable'].includes(value.status) && (Object.keys(raw).length || Object.keys(formats).length || prompt.status !== 'absent' || workflow.status !== 'absent')) throw new TypeError('Invalid unavailable metadata');
|
||||
@@ -188,6 +193,7 @@ const emptyState = () => ({ loading: false, error: false, record: null, prompts:
|
||||
|
||||
export function createInspectionController({ api, extractFacts }) {
|
||||
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
|
||||
let attempted, hasAttempt = false;
|
||||
const listeners = new Set();
|
||||
function publish(update) {
|
||||
if (disposed) return;
|
||||
@@ -204,18 +210,25 @@ export function createInspectionController({ api, extractFacts }) {
|
||||
get state() { return state; },
|
||||
select(image) {
|
||||
if (disposed) return;
|
||||
const scope = validateIdentity(image), key = imageKey(image);
|
||||
if (current?.key === key && current.scope === scope) return pending;
|
||||
current = { key, scope, image: { ...image } };
|
||||
return read(current.image);
|
||||
let selected = null;
|
||||
try {
|
||||
const scope = validateIdentity(image), key = imageKey(image);
|
||||
if (current?.key === key && current.scope === scope) return pending;
|
||||
selected = { key, scope };
|
||||
} catch {
|
||||
// The loader publishes validation errors after invalidating the previous source.
|
||||
}
|
||||
loader.dispose(); loader = makeLoader(); current = selected;
|
||||
attempted = object(image) ? { ...image } : image; hasAttempt = true;
|
||||
return read(attempted);
|
||||
},
|
||||
retry() {
|
||||
if (disposed || !current || state.refreshRequired) return;
|
||||
return read(current.image);
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted);
|
||||
},
|
||||
clear() {
|
||||
if (disposed) return;
|
||||
loader.dispose(); loader = makeLoader(); current = null; pending = null;
|
||||
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;
|
||||
publish({});
|
||||
},
|
||||
subscribe(listener) {
|
||||
@@ -225,7 +238,7 @@ export function createInspectionController({ api, extractFacts }) {
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; state = Object.freeze(emptyState());
|
||||
disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; attempted = undefined; hasAttempt = false; state = Object.freeze(emptyState());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user