diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 0b66e4f..451d6b2 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -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] } }; diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 57ef0cd..9973d81 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -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(); + } +}); diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index d2a8e32..c0a4cc2 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -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"); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index af8a3b1..c1e4234 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -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()); }, }; }