mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat(preflight): align inactive branch diagnostics
Refresh host compatibility anchors and governance expectations for current ComfyUI, frontend, and desktop references. Add inactive branch suppression for workflow portability and preflight diagnostics, including Explorer rendering and regression coverage. Validation: powershell -File scripts/run_full_tests_windows.ps1 passed.
This commit is contained in:
+3
-2
@@ -16,8 +16,9 @@ connector_state.json*
|
||||
|
||||
# Agent/local project exclusions
|
||||
.pla*/
|
||||
re*/
|
||||
RE*/
|
||||
reference/
|
||||
REFERENCE/
|
||||
.reference/
|
||||
ROA*.md
|
||||
roa*.md
|
||||
AG*.md
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
```openclaw-compat-matrix-meta
|
||||
{
|
||||
"anchors": {
|
||||
"comfyui": "0.19.3",
|
||||
"comfyui_frontend": "1.44.4",
|
||||
"desktop": "0.8.32 (core 0.19.3 / frontend 1.42.11)"
|
||||
"comfyui": "df22bcd5 (v0.19.3-41-gdf22bcd5 / frontend package 1.42.15)",
|
||||
"comfyui_frontend": "1.44.10",
|
||||
"desktop": "0.8.35 (core 0.19.5 / frontend 1.42.14)"
|
||||
},
|
||||
"evidence": {
|
||||
"evidence_id": "compat-matrix-refresh-20260418",
|
||||
"updated_at": "2026-04-18T10:23:56.704178+00:00",
|
||||
"evidence_id": "compat-matrix-refresh-20260426",
|
||||
"updated_at": "2026-04-26T00:00:00+00:00",
|
||||
"updated_by": "manual"
|
||||
},
|
||||
"last_validated_date": "2026-04-18",
|
||||
"matrix_version": "v0.2.2",
|
||||
"last_validated_date": "2026-04-26",
|
||||
"matrix_version": "v0.2.3",
|
||||
"policy": {
|
||||
"max_age_days": 45,
|
||||
"warn_age_days": 30
|
||||
@@ -28,17 +28,17 @@ This document tracks the current reference anchors and validated environments fo
|
||||
|
||||
| Component | Validated Range | Best Effort / Experimental | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **ComfyUI** | `0.19.3` reference anchor | Older snapshots | Current upstream reference repo version used for compatibility review |
|
||||
| **ComfyUI Frontend** | `1.44.4` reference anchor | Minor drift around the anchor | Sidebar extension contract (`registerSidebarTab`) still matches this repo |
|
||||
| **ComfyUI Desktop** | `0.8.32 (core 0.19.3 / frontend 1.42.11)` reference anchor | Desktop bundle may lag standalone frontend | Treat desktop parity as a distinct host surface, not an alias of standalone frontend HEAD |
|
||||
| **ComfyUI** | `df22bcd5` reference anchor (`v0.19.3-41-gdf22bcd5`, frontend package `1.42.15`) | Older snapshots | Current upstream reference repo snapshot used for compatibility review |
|
||||
| **ComfyUI Frontend** | `1.44.10` reference anchor | Minor drift around the anchor | Sidebar extension contract (`registerSidebarTab`) still matches this repo |
|
||||
| **ComfyUI Desktop** | `0.8.35 (core 0.19.5 / frontend 1.42.14)` reference anchor | Desktop bundle may lag standalone frontend | Treat desktop parity as a distinct host surface, not an alias of standalone frontend HEAD |
|
||||
| **Python** | 3.10, 3.11, 3.12 | 3.9 | 3.13 not yet validated |
|
||||
| **Torch** | 2.1.2+ | 1.13+ | CUDA 11.8/12.1 verified |
|
||||
|
||||
## Host-Surface Notes
|
||||
|
||||
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow.
|
||||
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, but nested-subgraph and promoted-widget behavior should be treated as a regression-sensitive seam.
|
||||
- **Desktop host surface**: desktop currently embeds frontend `1.42.11`, which still lags the standalone frontend `1.44.4` reference. Validate desktop-specific behavior against the desktop anchor instead of assuming standalone-frontend parity.
|
||||
- **ComfyUI host runtime**: current bootstrap assumptions remain aligned with upstream `PromptServer` startup and route registration flow, including `/api`-prefixed canonical API routing.
|
||||
- **Frontend host surface**: current sidebar integration contract remains compatible with the standalone frontend reference anchor, while inactive subgraph diagnostics and promoted-widget behavior remain regression-sensitive seams.
|
||||
- **Desktop host surface**: desktop currently embeds frontend `1.42.14`, which still lags the standalone frontend `1.44.10` reference. Validate desktop-specific behavior against the desktop anchor instead of assuming standalone-frontend parity.
|
||||
|
||||
## Operating Systems
|
||||
|
||||
|
||||
+67
-8
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Set, Tuple
|
||||
from .workflow_portability import (
|
||||
analyze_workflow_portability,
|
||||
get_missing_node_fallback,
|
||||
iter_workflow_diagnostic_nodes,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ComfyUI-OpenClaw.services.preflight")
|
||||
@@ -268,9 +269,17 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
report = {
|
||||
"ok": True,
|
||||
"summary": {"missing_nodes": 0, "missing_models": 0, "invalid_inputs": 0},
|
||||
"summary": {
|
||||
"missing_nodes": 0,
|
||||
"missing_models": 0,
|
||||
"invalid_inputs": 0,
|
||||
"suppressed_missing_nodes": 0,
|
||||
"suppressed_missing_models": 0,
|
||||
},
|
||||
"missing_nodes": [],
|
||||
"missing_models": [],
|
||||
"suppressed_missing_nodes": [],
|
||||
"suppressed_missing_models": [],
|
||||
"invalid_inputs": [],
|
||||
"notes": [],
|
||||
"portability": {
|
||||
@@ -278,6 +287,7 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"export_mode": "advisory_metadata",
|
||||
"summary": {
|
||||
"openclaw_nodes": 0,
|
||||
"suppressed_openclaw_nodes": 0,
|
||||
"portable_mode_required": False,
|
||||
"portable_mode_supported": False,
|
||||
"requires_manual_rewire": False,
|
||||
@@ -285,6 +295,7 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"detected_class_types": [],
|
||||
"recommended_actions": [],
|
||||
"openclaw_nodes": [],
|
||||
"suppressed_openclaw_nodes": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -303,22 +314,57 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
inventory = _get_model_inventory()
|
||||
missing_models_counts: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for node_id, node_data in workflow.items():
|
||||
for diagnostic_node in iter_workflow_diagnostic_nodes(workflow):
|
||||
node_data = diagnostic_node.get("node_data")
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
|
||||
node_id = str(diagnostic_node.get("node_id") or "")
|
||||
active = bool(diagnostic_node.get("active", True))
|
||||
inactive_reason = diagnostic_node.get("inactive_reason")
|
||||
is_subgraph_container = bool(diagnostic_node.get("is_subgraph_container"))
|
||||
# Check Node Class
|
||||
class_type = node_data.get("class_type")
|
||||
class_type = diagnostic_node.get("class_type")
|
||||
if not class_type:
|
||||
continue
|
||||
|
||||
if available_nodes and class_type not in available_nodes:
|
||||
missing_node_counts[class_type] = missing_node_counts.get(class_type, 0) + 1
|
||||
if (
|
||||
available_nodes
|
||||
and class_type not in available_nodes
|
||||
and not is_subgraph_container
|
||||
):
|
||||
if not active:
|
||||
item = {
|
||||
"node_id": node_id,
|
||||
"class_type": class_type,
|
||||
"inactive_reason": inactive_reason or "inactive",
|
||||
}
|
||||
fallback = get_missing_node_fallback(class_type)
|
||||
if fallback is not None:
|
||||
item["fallback"] = fallback
|
||||
report["suppressed_missing_nodes"].append(item)
|
||||
else:
|
||||
missing_node_counts[class_type] = (
|
||||
missing_node_counts.get(class_type, 0) + 1
|
||||
)
|
||||
|
||||
# Check Inputs for Models
|
||||
inputs = node_data.get("inputs")
|
||||
inputs = diagnostic_node.get("inputs")
|
||||
if isinstance(inputs, dict):
|
||||
_check_inputs_for_models(inputs, inventory, missing_models_counts)
|
||||
if active:
|
||||
_check_inputs_for_models(inputs, inventory, missing_models_counts)
|
||||
else:
|
||||
suppressed_counts: Dict[str, Dict[str, Any]] = {}
|
||||
_check_inputs_for_models(inputs, inventory, suppressed_counts)
|
||||
for info in suppressed_counts.values():
|
||||
report["suppressed_missing_models"].append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"type": info["type"],
|
||||
"name": info["name"],
|
||||
"count": info["count"],
|
||||
"inactive_reason": inactive_reason or "inactive",
|
||||
}
|
||||
)
|
||||
|
||||
# Format Results
|
||||
for cls in sorted(missing_node_counts):
|
||||
@@ -336,6 +382,12 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Summarize
|
||||
report["summary"]["missing_nodes"] = len(report["missing_nodes"])
|
||||
report["summary"]["missing_models"] = len(report["missing_models"])
|
||||
report["summary"]["suppressed_missing_nodes"] = len(
|
||||
report["suppressed_missing_nodes"]
|
||||
)
|
||||
report["summary"]["suppressed_missing_models"] = len(
|
||||
report["suppressed_missing_models"]
|
||||
)
|
||||
|
||||
if (
|
||||
report["summary"]["missing_nodes"] > 0
|
||||
@@ -351,6 +403,13 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
report["notes"].append(
|
||||
"Portable mode guidance is available for missing OpenClaw nodes."
|
||||
)
|
||||
if (
|
||||
report["summary"]["suppressed_missing_nodes"] > 0
|
||||
or report["summary"]["suppressed_missing_models"] > 0
|
||||
):
|
||||
report["notes"].append(
|
||||
"Inactive subgraph branches were suppressed from actionable diagnostics."
|
||||
)
|
||||
|
||||
# F49: Inject Guidance Banners
|
||||
# We serialize them so they are ready for JSON response
|
||||
|
||||
@@ -20,6 +20,8 @@ else: # pragma: no cover (top-level test import mode)
|
||||
get_node_portability_mappings,
|
||||
)
|
||||
|
||||
INACTIVE_LITEGRAPH_MODES = {2, 4}
|
||||
|
||||
|
||||
def get_workflow_portability_contract() -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -46,30 +48,34 @@ def get_missing_node_fallback(class_type: str) -> Dict[str, Any] | None:
|
||||
def analyze_workflow_portability(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
contract = get_workflow_portability_contract()
|
||||
entries = []
|
||||
suppressed_entries = []
|
||||
detected_class_types = set()
|
||||
recommended_actions = []
|
||||
|
||||
for node_id, node_data in _iter_sorted_workflow_nodes(workflow):
|
||||
class_type = node_data.get("class_type")
|
||||
for node in iter_workflow_diagnostic_nodes(workflow):
|
||||
class_type = node.get("class_type")
|
||||
if not isinstance(class_type, str):
|
||||
continue
|
||||
metadata = contract["nodes"].get(class_type)
|
||||
if metadata is None:
|
||||
continue
|
||||
item = {
|
||||
"node_id": str(node["node_id"]),
|
||||
"class_type": class_type,
|
||||
"display_name": metadata["display_name"],
|
||||
"portable_mode": metadata["portable_mode"],
|
||||
"fallback_kind": metadata["fallback_kind"],
|
||||
"portable_summary": metadata["portable_summary"],
|
||||
"standard_field_targets": list(metadata["standard_field_targets"]),
|
||||
"replacement_hints": list(metadata["replacement_hints"]),
|
||||
}
|
||||
if not node.get("active", True):
|
||||
item["inactive_reason"] = node.get("inactive_reason") or "inactive"
|
||||
suppressed_entries.append(item)
|
||||
continue
|
||||
detected_class_types.add(class_type)
|
||||
recommended_actions.extend(metadata["replacement_hints"])
|
||||
entries.append(
|
||||
{
|
||||
"node_id": str(node_id),
|
||||
"class_type": class_type,
|
||||
"display_name": metadata["display_name"],
|
||||
"portable_mode": metadata["portable_mode"],
|
||||
"fallback_kind": metadata["fallback_kind"],
|
||||
"portable_summary": metadata["portable_summary"],
|
||||
"standard_field_targets": list(metadata["standard_field_targets"]),
|
||||
"replacement_hints": list(metadata["replacement_hints"]),
|
||||
}
|
||||
)
|
||||
entries.append(item)
|
||||
|
||||
total_nodes = len(entries)
|
||||
portable_mode_required = total_nodes > 0
|
||||
@@ -82,6 +88,7 @@ def analyze_workflow_portability(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"export_mode": contract["export_mode"],
|
||||
"summary": {
|
||||
"openclaw_nodes": total_nodes,
|
||||
"suppressed_openclaw_nodes": len(suppressed_entries),
|
||||
"portable_mode_required": portable_mode_required,
|
||||
"portable_mode_supported": portable_mode_supported,
|
||||
"requires_manual_rewire": portable_mode_required,
|
||||
@@ -89,9 +96,20 @@ def analyze_workflow_portability(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"detected_class_types": sorted(detected_class_types),
|
||||
"recommended_actions": _dedupe_preserve_order(recommended_actions),
|
||||
"openclaw_nodes": entries,
|
||||
"suppressed_openclaw_nodes": suppressed_entries,
|
||||
}
|
||||
|
||||
|
||||
def iter_workflow_diagnostic_nodes(
|
||||
workflow: Dict[str, Any]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
if isinstance(workflow.get("nodes"), list):
|
||||
return list(_iter_frontend_workflow_nodes(workflow))
|
||||
return list(_iter_api_workflow_nodes(workflow))
|
||||
|
||||
|
||||
def _iter_sorted_workflow_nodes(
|
||||
workflow: Dict[str, Any],
|
||||
) -> Iterable[Tuple[str, Dict[str, Any]]]:
|
||||
@@ -106,6 +124,131 @@ def _iter_sorted_workflow_nodes(
|
||||
yield str(node_id), node_data
|
||||
|
||||
|
||||
def _iter_api_workflow_nodes(workflow: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
|
||||
for node_id, node_data in _iter_sorted_workflow_nodes(workflow):
|
||||
inactive = _node_is_inactive(node_data)
|
||||
yield {
|
||||
"node_id": node_id,
|
||||
"node_data": node_data,
|
||||
"class_type": _node_class_type(node_data),
|
||||
"inputs": node_data.get("inputs") if isinstance(node_data, dict) else None,
|
||||
"active": not inactive,
|
||||
"inactive_reason": "self_inactive" if inactive else None,
|
||||
"is_subgraph_container": False,
|
||||
"source": "api_prompt",
|
||||
}
|
||||
|
||||
|
||||
def _iter_frontend_workflow_nodes(workflow: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
|
||||
root_nodes = (
|
||||
workflow.get("nodes") if isinstance(workflow.get("nodes"), list) else []
|
||||
)
|
||||
subgraph_defs = _collect_subgraph_defs(
|
||||
workflow.get("definitions", {}).get("subgraphs", [])
|
||||
if isinstance(workflow.get("definitions"), dict)
|
||||
else []
|
||||
)
|
||||
subgraph_def_map = {str(item["id"]): item for item in subgraph_defs}
|
||||
|
||||
def walk(
|
||||
nodes: list[Any],
|
||||
*,
|
||||
parent_prefix: str = "",
|
||||
parent_active: bool = True,
|
||||
visiting: set[Tuple[str, str]] | None = None,
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
visiting = visiting or set()
|
||||
for raw_node in nodes:
|
||||
if not isinstance(raw_node, dict):
|
||||
continue
|
||||
raw_id = raw_node.get("id")
|
||||
if raw_id is None:
|
||||
continue
|
||||
node_id = f"{parent_prefix}:{raw_id}" if parent_prefix else str(raw_id)
|
||||
class_type = _node_class_type(raw_node)
|
||||
self_inactive = _node_is_inactive(raw_node)
|
||||
active = parent_active and not self_inactive
|
||||
if active:
|
||||
inactive_reason = None
|
||||
elif not parent_active:
|
||||
inactive_reason = "ancestor_inactive"
|
||||
else:
|
||||
inactive_reason = "self_inactive"
|
||||
is_subgraph_container = (
|
||||
isinstance(class_type, str) and class_type in subgraph_def_map
|
||||
)
|
||||
yield {
|
||||
"node_id": node_id,
|
||||
"node_data": raw_node,
|
||||
"class_type": class_type,
|
||||
"inputs": raw_node.get("inputs"),
|
||||
"active": active,
|
||||
"inactive_reason": inactive_reason,
|
||||
"is_subgraph_container": is_subgraph_container,
|
||||
"source": "frontend_workflow",
|
||||
}
|
||||
if not is_subgraph_container:
|
||||
continue
|
||||
visit_key = (class_type, node_id)
|
||||
if visit_key in visiting:
|
||||
continue
|
||||
nested_def = subgraph_def_map.get(class_type)
|
||||
nested_nodes = nested_def.get("nodes") if nested_def else None
|
||||
if not isinstance(nested_nodes, list):
|
||||
continue
|
||||
next_visiting = set(visiting)
|
||||
next_visiting.add(visit_key)
|
||||
yield from walk(
|
||||
nested_nodes,
|
||||
parent_prefix=node_id,
|
||||
parent_active=active,
|
||||
visiting=next_visiting,
|
||||
)
|
||||
|
||||
return list(walk(root_nodes))
|
||||
|
||||
|
||||
def _collect_subgraph_defs(raw_defs: Any) -> list[Dict[str, Any]]:
|
||||
result: list[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def collect(defs: Any) -> None:
|
||||
if not isinstance(defs, list):
|
||||
return
|
||||
for raw_def in defs:
|
||||
if not isinstance(raw_def, dict) or not isinstance(raw_def.get("id"), str):
|
||||
continue
|
||||
def_id = raw_def["id"]
|
||||
if def_id in seen:
|
||||
continue
|
||||
seen.add(def_id)
|
||||
result.append(raw_def)
|
||||
nested = raw_def.get("definitions")
|
||||
if isinstance(nested, dict):
|
||||
collect(nested.get("subgraphs"))
|
||||
|
||||
collect(raw_defs)
|
||||
return result
|
||||
|
||||
|
||||
def _node_class_type(node_data: Dict[str, Any]) -> str | None:
|
||||
class_type = node_data.get("class_type")
|
||||
if isinstance(class_type, str):
|
||||
return class_type
|
||||
node_type = node_data.get("type")
|
||||
if isinstance(node_type, str):
|
||||
return node_type
|
||||
return None
|
||||
|
||||
|
||||
def _node_is_inactive(node_data: Dict[str, Any]) -> bool:
|
||||
mode = node_data.get("mode")
|
||||
try:
|
||||
return int(mode) in INACTIVE_LITEGRAPH_MODES
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _dedupe_preserve_order(items: Iterable[str]) -> list[str]:
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
@@ -38,6 +38,55 @@ test.describe('OpenClaw Sidebar', () => {
|
||||
await expect(page.locator('#pnginfo-empty-state')).toContainText('Load an image to inspect');
|
||||
});
|
||||
|
||||
test('Explorer preflight surfaces inactive-branch suppressed diagnostics', async ({ page }) => {
|
||||
await page.route('**/preflight', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== 'POST' || !url.pathname.endsWith('/preflight')) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
summary: {
|
||||
missing_nodes: 0,
|
||||
missing_models: 0,
|
||||
invalid_inputs: 0,
|
||||
suppressed_missing_nodes: 2,
|
||||
suppressed_missing_models: 1,
|
||||
},
|
||||
missing_nodes: [],
|
||||
missing_models: [],
|
||||
suppressed_missing_nodes: [
|
||||
{ node_id: '5:7', class_type: 'MoltbotPromptPlanner' },
|
||||
{ node_id: '5:8', class_type: 'MissingCustomNode' },
|
||||
],
|
||||
suppressed_missing_models: [
|
||||
{ node_id: '5:8', type: 'checkpoints', name: 'missing-model.safetensors' },
|
||||
],
|
||||
notes: [
|
||||
'Inactive subgraph branches were suppressed from actionable diagnostics.',
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await clickTab(page, 'Explorer');
|
||||
await page.locator('.openclaw-preflight-results').waitFor({ state: 'attached' });
|
||||
await page.locator('textarea').fill(JSON.stringify({ nodes: [] }));
|
||||
await page.getByRole('button', { name: 'Run Preflight' }).click();
|
||||
|
||||
const results = page.locator('.openclaw-preflight-results');
|
||||
await expect(results).toContainText('Workflow Compatible');
|
||||
await expect(results).toContainText('Inactive Branch Findings Suppressed (3)');
|
||||
await expect(results).toContainText('MissingCustomNode');
|
||||
await expect(results).toContainText('missing-model.safetensors');
|
||||
});
|
||||
|
||||
test('harness recovers from one transient openclaw entry fetch failure', async ({ page }) => {
|
||||
let failedOnce = false;
|
||||
|
||||
|
||||
@@ -13,6 +13,33 @@ from services.workflow_portability import (
|
||||
|
||||
|
||||
class TestF47WorkflowPortability(unittest.TestCase):
|
||||
def _frontend_subgraph_workflow(self, *, container_mode=0):
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "subgraph-def-a",
|
||||
"mode": container_mode,
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-def-a",
|
||||
"name": "OpenClaw portability subgraph",
|
||||
"nodes": [
|
||||
{"id": 7, "type": "MoltbotPromptPlanner", "inputs": {}},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "MissingCustomNode",
|
||||
"inputs": {"ckpt_name": "missing-model.safetensors"},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
def test_contract_matches_current_node_schema(self):
|
||||
contract = get_workflow_portability_contract()
|
||||
nodes = contract["nodes"]
|
||||
@@ -56,6 +83,132 @@ class TestF47WorkflowPortability(unittest.TestCase):
|
||||
["MoltbotBatchVariants", "MoltbotPromptPlanner"],
|
||||
)
|
||||
|
||||
def test_frontend_workflow_active_subgraph_reports_openclaw_nodes(self):
|
||||
report = analyze_workflow_portability(
|
||||
self._frontend_subgraph_workflow(container_mode=0)
|
||||
)
|
||||
|
||||
self.assertEqual(report["summary"]["openclaw_nodes"], 1)
|
||||
self.assertEqual(report["summary"]["suppressed_openclaw_nodes"], 0)
|
||||
self.assertEqual(report["openclaw_nodes"][0]["node_id"], "5:7")
|
||||
self.assertEqual(
|
||||
report["detected_class_types"],
|
||||
["MoltbotPromptPlanner"],
|
||||
)
|
||||
|
||||
def test_frontend_workflow_muted_subgraph_suppresses_openclaw_nodes(self):
|
||||
report = analyze_workflow_portability(
|
||||
self._frontend_subgraph_workflow(container_mode=2)
|
||||
)
|
||||
|
||||
self.assertEqual(report["summary"]["openclaw_nodes"], 0)
|
||||
self.assertFalse(report["summary"]["portable_mode_required"])
|
||||
self.assertEqual(report["summary"]["suppressed_openclaw_nodes"], 1)
|
||||
self.assertEqual(report["suppressed_openclaw_nodes"][0]["node_id"], "5:7")
|
||||
self.assertEqual(
|
||||
report["suppressed_openclaw_nodes"][0]["inactive_reason"],
|
||||
"ancestor_inactive",
|
||||
)
|
||||
|
||||
def test_api_prompt_muted_root_node_suppresses_openclaw_nodes(self):
|
||||
workflow = {
|
||||
"1": {"class_type": "MoltbotPromptPlanner", "mode": 2, "inputs": {}},
|
||||
"2": {"class_type": "KSampler", "inputs": {}},
|
||||
}
|
||||
|
||||
report = analyze_workflow_portability(workflow)
|
||||
|
||||
self.assertEqual(report["summary"]["openclaw_nodes"], 0)
|
||||
self.assertEqual(report["summary"]["suppressed_openclaw_nodes"], 1)
|
||||
self.assertEqual(report["suppressed_openclaw_nodes"][0]["node_id"], "1")
|
||||
self.assertEqual(
|
||||
report["suppressed_openclaw_nodes"][0]["inactive_reason"],
|
||||
"self_inactive",
|
||||
)
|
||||
|
||||
def test_frontend_workflow_bypassed_subgraph_suppresses_preflight_findings(self):
|
||||
services.preflight._CACHE.clear()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
services.preflight, "nodes", MagicMock(), create=True
|
||||
) as mock_nodes,
|
||||
patch.object(
|
||||
services.preflight, "folder_paths", MagicMock(), create=True
|
||||
) as mock_folder_paths,
|
||||
):
|
||||
mock_nodes.NODE_CLASS_MAPPINGS = {"KSampler": object}
|
||||
mock_folder_paths.folder_names_and_paths = {}
|
||||
mock_folder_paths.get_filename_list.return_value = []
|
||||
|
||||
report = services.preflight.run_preflight_check(
|
||||
self._frontend_subgraph_workflow(container_mode=4)
|
||||
)
|
||||
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual(report["summary"]["missing_nodes"], 0)
|
||||
self.assertEqual(report["summary"]["missing_models"], 0)
|
||||
self.assertEqual(report["summary"]["suppressed_missing_nodes"], 2)
|
||||
self.assertEqual(report["summary"]["suppressed_missing_models"], 1)
|
||||
self.assertEqual(
|
||||
[item["node_id"] for item in report["suppressed_missing_nodes"]],
|
||||
["5:7", "5:8"],
|
||||
)
|
||||
self.assertIn(
|
||||
"Inactive subgraph branches were suppressed from actionable diagnostics.",
|
||||
report["notes"],
|
||||
)
|
||||
|
||||
def test_api_prompt_bypassed_root_node_suppresses_preflight_findings(self):
|
||||
services.preflight._CACHE.clear()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
services.preflight, "nodes", MagicMock(), create=True
|
||||
) as mock_nodes,
|
||||
patch.object(
|
||||
services.preflight, "folder_paths", MagicMock(), create=True
|
||||
) as mock_folder_paths,
|
||||
):
|
||||
mock_nodes.NODE_CLASS_MAPPINGS = {"KSampler": object}
|
||||
mock_folder_paths.folder_names_and_paths = {}
|
||||
mock_folder_paths.get_filename_list.return_value = []
|
||||
|
||||
report = services.preflight.run_preflight_check(
|
||||
{
|
||||
"1": {
|
||||
"class_type": "MoltbotPromptPlanner",
|
||||
"mode": 4,
|
||||
"inputs": {"ckpt_name": "missing-model.safetensors"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual(report["summary"]["missing_nodes"], 0)
|
||||
self.assertEqual(report["summary"]["missing_models"], 0)
|
||||
self.assertEqual(report["summary"]["suppressed_missing_nodes"], 1)
|
||||
self.assertEqual(report["summary"]["suppressed_missing_models"], 1)
|
||||
self.assertEqual(report["suppressed_missing_nodes"][0]["node_id"], "1")
|
||||
self.assertEqual(
|
||||
report["suppressed_missing_nodes"][0]["inactive_reason"],
|
||||
"self_inactive",
|
||||
)
|
||||
|
||||
def test_api_prompt_without_subgraph_metadata_remains_supported(self):
|
||||
workflow = {
|
||||
"11": {"class_type": "MoltbotBatchVariants"},
|
||||
"2": {"class_type": "MoltbotPromptPlanner"},
|
||||
}
|
||||
|
||||
report = analyze_workflow_portability(workflow)
|
||||
|
||||
self.assertEqual(report["summary"]["openclaw_nodes"], 2)
|
||||
self.assertEqual(report["summary"]["suppressed_openclaw_nodes"], 0)
|
||||
self.assertEqual(
|
||||
[entry["node_id"] for entry in report["openclaw_nodes"]], ["2", "11"]
|
||||
)
|
||||
|
||||
def test_preflight_attaches_openclaw_missing_node_fallback(self):
|
||||
services.preflight._CACHE.clear()
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ from services.compatibility_matrix_governance import (
|
||||
from services.operator_doctor import DoctorReport, check_compatibility_matrix_governance
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXPECTED_CURRENT_ANCHORS = {
|
||||
"comfyui": "df22bcd5 (v0.19.3-41-gdf22bcd5 / frontend package 1.42.15)",
|
||||
"comfyui_frontend": "1.44.10",
|
||||
"desktop": "0.8.35 (core 0.19.5 / frontend 1.42.14)",
|
||||
}
|
||||
|
||||
|
||||
class TestR90CompatMatrixGovernance(unittest.TestCase):
|
||||
@@ -33,6 +38,12 @@ class TestR90CompatMatrixGovernance(unittest.TestCase):
|
||||
self.assertTrue(validation["ok"], msg=validation)
|
||||
self.assertIn(validation["status"], ("fresh", "warning", "stale"))
|
||||
|
||||
def test_repo_matrix_tracks_current_reference_anchors(self):
|
||||
doc = read_matrix_document(
|
||||
REPO_ROOT / "docs" / "release" / "compatibility_matrix.md"
|
||||
)
|
||||
self.assertEqual(doc["metadata"]["anchors"], EXPECTED_CURRENT_ANCHORS)
|
||||
|
||||
def test_detect_anchor_drift(self):
|
||||
published = {
|
||||
"comfyui": "a",
|
||||
@@ -52,15 +63,15 @@ class TestR90CompatMatrixGovernance(unittest.TestCase):
|
||||
def test_build_host_surface_contract_tracks_desktop_embedded_frontend_lag(self):
|
||||
contract = build_host_surface_contract(
|
||||
{
|
||||
"comfyui": "0.19.3",
|
||||
"comfyui_frontend": "1.44.4",
|
||||
"desktop": "0.8.32 (core 0.19.3 / frontend 1.42.11)",
|
||||
"comfyui": EXPECTED_CURRENT_ANCHORS["comfyui"],
|
||||
"comfyui_frontend": EXPECTED_CURRENT_ANCHORS["comfyui_frontend"],
|
||||
"desktop": EXPECTED_CURRENT_ANCHORS["desktop"],
|
||||
}
|
||||
)
|
||||
self.assertTrue(contract["ok"], msg=contract)
|
||||
self.assertEqual(contract["code"], "R164_HOST_SURFACES_READY")
|
||||
self.assertEqual(
|
||||
contract["surfaces"]["desktop"]["embedded_frontend_version"], "1.42.11"
|
||||
contract["surfaces"]["desktop"]["embedded_frontend_version"], "1.42.14"
|
||||
)
|
||||
self.assertEqual(
|
||||
contract["surfaces"]["desktop"]["frontend_parity"]["status"], "lagging"
|
||||
@@ -183,7 +194,7 @@ class TestR90CompatMatrixGovernance(unittest.TestCase):
|
||||
self.assertTrue(contract["ok"], msg=contract)
|
||||
desktop_surface = contract["surfaces"]["desktop"]
|
||||
self.assertEqual(desktop_surface["frontend_parity"]["status"], "lagging")
|
||||
self.assertEqual(desktop_surface["embedded_frontend_version"], "1.42.11")
|
||||
self.assertEqual(desktop_surface["embedded_frontend_version"], "1.42.14")
|
||||
self.assertTrue(
|
||||
(
|
||||
REPO_ROOT / "tests" / "e2e" / "specs" / "desktop_host_parity.spec.js"
|
||||
|
||||
@@ -108,7 +108,7 @@ export const ExplorerTab = {
|
||||
const diagHeader = makeEl("h3", "", "Preflight Diagnostics");
|
||||
diagHeader.style.marginTop = "0";
|
||||
|
||||
const diagDesc = makeEl("p", "", "Paste a workflow JSON (API format) to check for missing nodes/models compatible with this environment.");
|
||||
const diagDesc = makeEl("p", "", "Paste workflow JSON or API prompt JSON to check for missing nodes/models compatible with this environment.");
|
||||
diagDesc.style.fontSize = "0.9em";
|
||||
diagDesc.style.opacity = "0.8";
|
||||
|
||||
@@ -387,6 +387,43 @@ export const ExplorerTab = {
|
||||
section.appendChild(ul);
|
||||
resultsArea.appendChild(section);
|
||||
}
|
||||
|
||||
const suppressedNodeCount = Number(report.summary?.suppressed_missing_nodes || 0);
|
||||
const suppressedModelCount = Number(report.summary?.suppressed_missing_models || 0);
|
||||
const suppressedTotal = suppressedNodeCount + suppressedModelCount;
|
||||
if (suppressedTotal > 0) {
|
||||
const section = makeEl("div", "openclaw-preflight-suppressed");
|
||||
const heading = makeEl("h4", "", `Inactive Branch Findings Suppressed (${suppressedTotal})`);
|
||||
section.appendChild(heading);
|
||||
const note = makeEl(
|
||||
"div",
|
||||
"",
|
||||
"Muted or bypassed workflow branches are not counted as actionable missing dependencies."
|
||||
);
|
||||
note.style.opacity = "0.75";
|
||||
note.style.fontSize = "0.9em";
|
||||
section.appendChild(note);
|
||||
|
||||
const ul = makeEl("ul");
|
||||
(report.suppressed_missing_nodes || []).forEach(m => {
|
||||
const li = makeEl(
|
||||
"li",
|
||||
"",
|
||||
`${m.node_id || "unknown"}: ${m.class_type || "unknown node"}`
|
||||
);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
(report.suppressed_missing_models || []).forEach(m => {
|
||||
const li = makeEl(
|
||||
"li",
|
||||
"",
|
||||
`${m.node_id || "unknown"}: ${m.type || "model"} ${m.name || "unknown model"}`
|
||||
);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
section.appendChild(ul);
|
||||
resultsArea.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial Load
|
||||
|
||||
Reference in New Issue
Block a user