diff --git a/.gitignore b/.gitignore index e4817e8..5c629bc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,9 @@ connector_state.json* # Agent/local project exclusions .pla*/ -re*/ -RE*/ +reference/ +REFERENCE/ +.reference/ ROA*.md roa*.md AG*.md diff --git a/docs/release/compatibility_matrix.md b/docs/release/compatibility_matrix.md index ddf8fde..9eb8091 100644 --- a/docs/release/compatibility_matrix.md +++ b/docs/release/compatibility_matrix.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 diff --git a/services/preflight.py b/services/preflight.py index f706d99..cf0156f 100644 --- a/services/preflight.py +++ b/services/preflight.py @@ -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 diff --git a/services/workflow_portability.py b/services/workflow_portability.py index 75ca780..469e4e2 100644 --- a/services/workflow_portability.py +++ b/services/workflow_portability.py @@ -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 = [] diff --git a/tests/e2e/specs/sidebar.spec.js b/tests/e2e/specs/sidebar.spec.js index d4a398f..328489f 100644 --- a/tests/e2e/specs/sidebar.spec.js +++ b/tests/e2e/specs/sidebar.spec.js @@ -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; diff --git a/tests/test_f47_workflow_portability.py b/tests/test_f47_workflow_portability.py index 93e587f..3574f20 100644 --- a/tests/test_f47_workflow_portability.py +++ b/tests/test_f47_workflow_portability.py @@ -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() diff --git a/tests/test_r90_compatibility_matrix_governance.py b/tests/test_r90_compatibility_matrix_governance.py index e3d5a37..3c4f542 100644 --- a/tests/test_r90_compatibility_matrix_governance.py +++ b/tests/test_r90_compatibility_matrix_governance.py @@ -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" diff --git a/web/tabs/explorer_tab.js b/web/tabs/explorer_tab.js index 1dd4e26..9a4e8a2 100644 --- a/web/tabs/explorer_tab.js +++ b/web/tabs/explorer_tab.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