diff --git a/services/api-server/src/api_server/html5_ai_structure.py b/services/api-server/src/api_server/html5_ai_structure.py index f11a3e0..84684c6 100644 --- a/services/api-server/src/api_server/html5_ai_structure.py +++ b/services/api-server/src/api_server/html5_ai_structure.py @@ -57,6 +57,8 @@ def render_html5_ai_structure_agent_panel(project_id: str, *, agent_info: dict[s details.append(("Хост", str(info["host"]))) if info.get("version"): details.append(("Версия", str(info["version"]))) + if info.get("network_roots"): + details.append(("Доступные сетевые корни", str(info["network_roots"]))) detail_html = "".join( f'
{escape(label)}{escape(value)}
' for label, value in details @@ -250,7 +252,7 @@ def _agent_status_title(status: str) -> str: def _agent_status_advice(status: str, agent_id: str) -> str: if status == "online": - return f"Для бинарных .cf/.cfe будет использован Windows Agent {agent_id}. Можно запускать подготовку." + return f"Для бинарных .cf/.cfe будет использован Windows Agent {agent_id}. Перед запуском проверьте, что входной UNC-путь лежит внутри одного из доступных сетевых корней агента." if status == "offline": return f"Windows Agent {agent_id} выбран, но сейчас не отвечает. Обновите или запустите агент, затем повторите." return "Для прямого разбора .cf/.cfe нужен выбранный Windows Agent. Укажите его в настройках проекта." diff --git a/services/api-server/src/api_server/html5_ai_structure_controller.py b/services/api-server/src/api_server/html5_ai_structure_controller.py index 606a0a3..81ff1e5 100644 --- a/services/api-server/src/api_server/html5_ai_structure_controller.py +++ b/services/api-server/src/api_server/html5_ai_structure_controller.py @@ -67,9 +67,11 @@ async def html5_ai_structure_run( work_dir = work_root / f"{effective_project_id}-{uuid4().hex}" try: work_dir.mkdir(parents=True, exist_ok=True) - local_input = work_dir / "input" if is_unc_path(input_path) else Path(input_path) + direct_binary_match = _normalize_binary_match(_detect_binary_input(input_path)) + direct_binary_file = input_path.strip().casefold().endswith((".cf", ".cfe")) + local_input = work_dir / "input" if is_unc_path(input_path) and not direct_binary_file else Path(input_path) local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path) - if is_unc_path(input_path): + if is_unc_path(input_path) and not direct_binary_file: copy_smb_tree_to_local( source=input_path, target=local_input, @@ -77,7 +79,7 @@ async def html5_ai_structure_run( password=password, domain=domain or None, ) - binary_match = _detect_binary_tree(local_input) or _normalize_binary_match(_detect_binary_input(input_path)) + binary_match = _detect_binary_tree(local_input) or direct_binary_match if binary_match is not None: if start_binary_job is None or save_run_state is None: return render_html5_ai_structure_error("Сервис подготовки CF/CFE через Windows Agent не подключен.") diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 41b7a62..509f9bf 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -191,6 +191,7 @@ from api_server.snapshot_module_service import ( module_sources_for_object as _snapshot_module_sources_for_object, snapshot_bsl_completion_items as _snapshot_bsl_completion_items, ) +from api_server.smb_paths import is_unc_path from api_server.time_utils import current_timestamp as _current_timestamp from impact_engine import object_impact, routine_impact from incremental_indexer import rebuild_changed_file @@ -582,6 +583,34 @@ def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSour return str(agent.get("agent_id") or "").strip() +def _normalize_unc_for_compare(value: str) -> str: + text = str(value or "").strip().replace("/", "\\") + while "\\\\" in text: + text = text.replace("\\\\", "\\") + if text.startswith("\\") and not text.startswith("\\\\"): + text = "\\" + text + if text.startswith("\\\\"): + return text.rstrip("\\").casefold() + return text.casefold() + + +def _unc_path_matches_root(path: str, root: str) -> bool: + normalized_path = _normalize_unc_for_compare(path) + normalized_root = _normalize_unc_for_compare(root) + if not normalized_path.startswith("\\\\") or not normalized_root.startswith("\\\\"): + return False + return normalized_path == normalized_root or normalized_path.startswith(normalized_root + "\\") + + +def _ai_structure_agent_root_mismatch_detail(agent_id: str, input_path: str, roots: list[str]) -> str: + visible_roots = ", ".join(roots) if roots else "не указаны" + return ( + f"Windows Agent {agent_id} сейчас не может открыть путь {input_path}. " + f"Для этого агента доступны только корни: {visible_roots}. " + "Укажите путь внутри доступного корня или переключите проект на другой Windows Agent." + ) + + async def _start_ai_structure_agent_job( *, project_id: str, @@ -625,6 +654,14 @@ async def _start_ai_structure_agent_job( detail += f" Последний heartbeat: {last_seen}." detail += " Запустите агент и повторите." raise HTTPException(status_code=409, detail=detail) + network_roots = [str(item).strip() for item in getattr(agent_status, "network_roots", []) if str(item).strip()] + if is_unc_path(input_path) and network_roots and not any( + _unc_path_matches_root(input_path, root) for root in network_roots + ): + raise HTTPException( + status_code=400, + detail=_ai_structure_agent_root_mismatch_detail(agent_id, input_path, network_roots), + ) agent = settings.agent if isinstance(settings.agent, dict) else {} metadata: dict[str, Any] = { @@ -718,6 +755,7 @@ def _ai_structure_agent_info(project_id: str) -> dict[str, str]: "last_seen_at": str(status.last_seen_at or ""), "host": str(status.host or ""), "version": str(status.version or ""), + "network_roots": ", ".join(str(item) for item in getattr(status, "network_roots", []) if str(item).strip()), } diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 79a72cf..746ddf6 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -2081,7 +2081,15 @@ def test_html5_ai_structure_page_shows_agent_status_panel(): json={"name": "AI Page", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}}, ) assert settings.status_code == 200 - heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host", "version": "0.2.31"}) + heartbeat = client.post( + "/agent/heartbeat", + json={ + "agent_id": agent_id, + "host": "test-host", + "version": "0.2.31", + "network_roots": [r"\\192.168.220.220\mst"], + }, + ) assert heartbeat.status_code == 200 page = client.get(f"/html5/projects/{project_id}/ai-structure") @@ -2091,6 +2099,7 @@ def test_html5_ai_structure_page_shows_agent_status_panel(): assert agent_id in page.text assert "test-host" in page.text assert "0.2.31" in page.text + assert r"\\192.168.220.220\mst" in page.text def test_html5_ai_structure_page_shows_missing_agent_hint(): @@ -2109,6 +2118,42 @@ def test_html5_ai_structure_page_shows_missing_agent_hint(): assert "Укажите его в настройках проекта" in page.text +def test_html5_ai_structure_reports_unc_root_mismatch_for_online_agent(tmp_path: Path): + cf_input = r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF\demo.cf" + client = TestClient(app) + project_id = f"ai-root-mismatch-{uuid4()}" + agent_id = f"win-agent-{uuid4()}" + + settings = client.post( + f"/projects/{project_id}/settings", + json={"name": "AI Root Mismatch", "structure_source": "CF_FILE", "agent": {"cf_agent_id": agent_id}}, + ) + assert settings.status_code == 200 + heartbeat = client.post( + "/agent/heartbeat", + json={ + "agent_id": agent_id, + "host": "test-host", + "network_roots": [r"\\192.168.220.220\mst"], + }, + ) + assert heartbeat.status_code == 200 + + queued = client.post( + f"/html5/projects/{project_id}/ai-structure/run", + data={ + "project_id": project_id, + "input_path": cf_input, + "output_path": str(tmp_path / "out"), + "smb_username": "m", + "smb_password": "secret", + }, + ) + assert queued.status_code == 200 + assert "сейчас не может открыть путь" in queued.text + assert r"\\192.168.220.220\mst" in queued.text + + def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path): first = tmp_path / "first" second = tmp_path / "second"