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:
+6
-4
@@ -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
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user