fix(inspect): skip a workflow node whose type is a list or object

The overwritten-PromptManager walk now skips such a node, on the server
and in the browser port, instead of marking the whole file malformed.
A prompt that resolves still resolves; the other nodes still feed the
check.
This commit is contained in:
Vito Sansevero
2026-10-22 19:04:38 -07:00
parent e5dba0466b
commit bf30048bb4
7 changed files with 45 additions and 48 deletions
+6 -4
View File
@@ -399,10 +399,12 @@ reaches its own sampler is `malformed`; running out of budget is `work-limit` or
`depth-limit`, never single stage. Each sampler's walk runs once per resolution;
selector branch runs share it and other candidates are charged its visit cost.
A UI workflow node whose `type` is a list or an object (read only for the overwritten
PromptManager check) makes the evidence `malformed` ("Prompt evidence is malformed"),
and a list or object `class_type` is never a preview output; before #286 both raised
and the inspect route answered 500 (`test_unhashable_*` in
`tests/test_api_prompt_inspector.py`).
PromptManager check) is skipped like a non-object node, since it can be neither a
subgraph instance nor a PromptManager node, so a prompt that resolves still resolves
and the other nodes still feed the check; a list or object `class_type` is never a
preview output. Before #286 both raised and the inspect route answered 500
(`test_unhashable_*` in `tests/test_api_prompt_inspector.py`, the `workflow-type-*`
golden cases on both sides).
Text encoders: `single` encoders (the Qwen image encoders, `TextEncodeQwenImage21`
slot 0 `prompt` and slot 1 `negative_prompt`, `KikoTextEncode`) read one string
input unchanged. `parts-equal` encoders (CLIPTextEncodeSDXL, Flux, SD3) resolve when
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -131,11 +131,12 @@ test('committed server answers equal the local evidence', () => {
assert.ok(compared > 280, `compared ${compared}`);
});
test('a list or object workflow node type is malformed evidence, as on the server (#286)', () => {
test('a list or object workflow node type is skipped, as on the server (#286)', () => {
const graph = base({'6': {class_type: 'PromptManager', inputs: {text: 'a fox', clip: ['4', 1]}}});
for (const type of [['PromptManager'], {name: 'PromptManager'}]) {
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type}]})}, semantics);
assert.deepEqual(evidence.resolution, {state: 'unresolved', sampler_id: null, reason: 'Prompt evidence is malformed', reason_code: 'malformed', node: null});
assert.equal(evidence.prompts.negative.message, 'Prompt metadata in this file is malformed.');
const evidence = resolver.resolveLocalEvidence({prompt: JSON.stringify(graph), workflow: JSON.stringify({nodes: [{id: 6, type, widgets_values: ['x']}]})}, semantics);
assert.equal(evidence.resolution.state, 'resolved');
assert.equal(evidence.prompts.positive.status, 'resolved');
assert.equal(evidence.prompts.positive.text, 'a fox');
}
});
+15 -2
View File
@@ -834,8 +834,8 @@ def golden_cases():
_graph(["6", 0], ["7", 0], overwritten),
workflow="{bad",
)
# A list or object node ``type`` is malformed evidence (#286), also inside a
# subgraph and after a node the walk skips; one with an invalid ID is skipped.
# A list or object node ``type`` is skipped like a non-dict node (#286), also
# inside a subgraph; a skipped node never hides the rest of the walk.
for label, kind in (("list", ["PromptManager"]), ("dict", {"a": 1})):
add(
f"workflow-type-{label}",
@@ -856,6 +856,19 @@ def golden_cases():
}
),
)
add(
"workflow-type-skipped-keeps-overwritten",
_graph(["6", 0], ["7", 0], overwritten),
workflow=json.dumps(
{
"nodes": [
workflow["nodes"][0],
{"id": 8, "type": ["PromptManager"]},
workflow["nodes"][1],
]
}
),
)
add(
"workflow-type-without-id",
_graph(["6", 0], ["7", 0], overwritten),
+9 -17
View File
@@ -1643,11 +1643,13 @@ class TestPromptInspector(ImageAPITestCase):
str(path), False, report_ambiguity=True
)
async def test_unhashable_workflow_node_type_answers_malformed(self):
"""A UI workflow node whose ``type`` is a list or dict is malformed (#286).
async def test_unhashable_workflow_node_type_is_skipped(self):
"""A UI workflow node whose ``type`` is a list or dict is skipped (#286).
The R1 check looks each node's ``type`` up among subgraph IDs; an
unhashable ``type`` used to raise there and the route answered 500.
Such a node can be neither a subgraph instance nor a PromptManager
node, so the walk skips it and a prompt that resolves still resolves.
"""
source = graph()
source["1"]["class_type"] = "PromptManager"
@@ -1663,21 +1665,11 @@ class TestPromptInspector(ImageAPITestCase):
)
response, result = await self.inspect()
self.assertEqual(response.status, 200)
self.assertEqual(
result["resolution"],
{
"state": "unresolved",
"sampler_id": None,
"reason": "Prompt evidence is malformed",
"reason_code": "malformed",
"node": None,
},
)
for side in result["prompts"].values():
self.assertEqual(side["status"], "unresolved")
self.assertEqual(
side["message"], "Prompt metadata in this file is malformed."
)
self.assertEqual(result["resolution"]["state"], "resolved")
self.assertEqual(result["resolution"]["sampler_id"], "3")
self.assertEqual(result["prompts"]["positive"]["status"], "resolved")
self.assertEqual(result["prompts"]["positive"]["text"], "portrait")
self.assertEqual(result["prompts"]["negative"]["text"], "blurry")
self.assertNotIn("unhashable", json.dumps(result))
async def test_unhashable_graph_class_type_is_not_a_preview_output(self):
+5 -9
View File
@@ -1756,12 +1756,7 @@ def _resolve(
)
overwritten = frozenset()
if semantics is not None and "prompt" in metadata:
try:
overwritten = _overwritten_prompt_nodes(
metadata.get("workflow"), normalized
)
except _Unresolved as error:
return _result("unresolved", reason=str(error), reason_code=error.code)
overwritten = _overwritten_prompt_nodes(metadata.get("workflow"), normalized)
resolver = _GraphResolver(
normalized,
adapters or {},
@@ -1872,8 +1867,9 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
Nodes inside a subgraph run as ``"<instance>:<inner>"`` API IDs (nested
instances chain their IDs); a definition without an instance runs nothing.
The walk is bounded in depth and in nodes visited. A node ``type`` that is
a list or an object makes the workflow malformed (``_Unresolved``).
The walk is bounded in depth and in nodes visited. A node whose ``type`` is
a list or an object is skipped: it is neither a subgraph instance nor a
PromptManager node (#286).
"""
nodes = workflow.get("nodes") if isinstance(workflow, dict) else None
if not isinstance(nodes, list):
@@ -1895,7 +1891,7 @@ def _prompt_manager_widgets(workflow: Any) -> Dict[str, str]:
api_id = f"{prefix}{node_id}"
if isinstance(kind, (list, dict)):
# JSON's unhashable values are never a node type (#286).
raise _Unresolved(MALFORMED_EVIDENCE_REASON, "malformed")
continue
if kind in subgraphs and depth < _MAX_SUBGRAPH_DEPTH:
stack.append((subgraphs[kind], api_id + ":", depth + 1))
continue
+4 -11
View File
@@ -621,12 +621,6 @@ function noSampler(graph) {
return result('unresolved', { reason, reasonCode: 'no-sampler' });
}
/** A workflow `type` that is a list or an object is malformed evidence on the server too (#286). */
function hashable(value) {
if (isList(value) || isDict(value)) throw new Unresolved(MALFORMED_EVIDENCE_REASON, 'malformed');
return value;
}
/** `_prompt_manager_widgets`: API node ID to widget text for every PromptManager node that runs. */
function promptManagerWidgets(workflow) {
const nodes = isDict(workflow) ? pyGet(workflow, 'nodes') : null;
@@ -648,7 +642,9 @@ function promptManagerWidgets(workflow) {
const kind = pyGet(node, 'type'), nodeId = pyGet(node, 'id');
if (!(typeof nodeId === 'string' || isInt(nodeId))) continue;
const apiId = `${prefix}${String(nodeId)}`;
if (typeof hashable(kind) === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) {
// A list or object `type` is neither a subgraph instance nor a PromptManager node (#286).
if (isList(kind) || isDict(kind)) continue;
if (typeof kind === 'string' && subgraphs.has(kind) && depth < MAX_SUBGRAPH_DEPTH) {
stack.push([subgraphs.get(kind), `${apiId}:`, depth + 1]);
continue;
}
@@ -692,10 +688,7 @@ function resolve(metadata, semantics) {
if (semantics === null) return result('unresolved', { reason: SEMANTICS_UNAVAILABLE_REASON });
let overwritten = new Set();
if (metadata.has('prompt')) {
try { overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph); } catch (error) {
if (!(error instanceof Unresolved)) throw error;
return result('unresolved', { reason: error.message, reasonCode: error.code });
}
overwritten = overwrittenPromptNodes(pyGet(metadata, 'workflow'), graph);
}
const resolver = new GraphResolver(graph, metadata.has('prompt') ? metadata : null, semantics, overwritten);
const samplerIds = finalStages(resolver, [...graph].filter(([, node]) => isDict(node) && typeof pyGet(node, 'class_type') === 'string' &&