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 f03e32a..57fbdf3 100644 --- a/services/api-server/src/api_server/html5_ai_structure.py +++ b/services/api-server/src/api_server/html5_ai_structure.py @@ -128,6 +128,34 @@ def render_html5_ai_structure_result(result: dict | None) -> str: """ +def render_html5_ai_structure_job( + *, + project_id: str, + job_id: str, + status: str, + source: str, + message: str, + logs: list[object] | None = None, +) -> str: + log_items = list(logs or []) + return f""" +
+
+ выполняется + {escape(message)} +
+

Задача агента: {escape(job_id)}. Источник: {escape(source)}. Текущий статус: {escape(status)}.

+ +
+ """ + + def render_html5_ai_structure_error(message: str) -> str: return f"""
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 817cbdc..5bc6041 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 @@ -9,6 +9,7 @@ from fastapi import HTTPException from api_server.html5_ai_structure import ( render_html5_ai_structure_error, + render_html5_ai_structure_job, render_html5_ai_structure_page, render_html5_ai_structure_result, ) @@ -32,12 +33,14 @@ def html5_ai_structure_page( ) -def html5_ai_structure_run( +async def html5_ai_structure_run( *, project_id: str, form: dict[str, list[str]], prepare: Callable[..., dict[str, Any]], work_root: Path, + start_binary_job: Callable[..., Any] | None = None, + save_run_state: Callable[[str, dict[str, Any]], None] | None = None, load_credentials: Callable[[str], SmbCredentials | None] | None = None, save_credentials: Callable[[str, SmbCredentials], None] | None = None, ) -> str: @@ -59,6 +62,37 @@ def html5_ai_structure_run( if should_save and save_credentials and username and password: save_credentials(project_id, {"username": username, "password": password, "domain": domain}) + binary_source = _detect_binary_input(input_path) + if binary_source 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 не подключен.") + try: + job = await start_binary_job(project_id=project_id, effective_project_id=effective_project_id, input_path=Path(input_path)) + except HTTPException as error: + return render_html5_ai_structure_error(str(error.detail)) + save_run_state( + job.job_id, + { + "project_id": project_id, + "effective_project_id": effective_project_id, + "input_path": input_path, + "output_path": output_path, + "username": username, + "password": password, + "domain": domain, + "display_input_path": input_path, + "display_output_path": output_path, + }, + ) + return render_html5_ai_structure_job( + project_id=project_id, + job_id=job.job_id, + status=_enum_text(job.status), + source=_enum_text(job.source), + message="Разбор CF/CFE запущен через Windows Agent", + logs=getattr(job, "logs", []), + ) + work_dir = work_root / f"{effective_project_id}-{uuid4().hex}" try: work_dir.mkdir(parents=True, exist_ok=True) @@ -99,3 +133,116 @@ def html5_ai_structure_run( if work_dir.exists(): remove_tree(work_dir, expected_parent=work_root) return render_html5_ai_structure_result(result) + + +def html5_ai_structure_job( + *, + project_id: str, + job_id: str, + prepare: Callable[..., dict[str, Any]], + work_root: Path, + load_run_state: Callable[[str], dict[str, Any] | None], + save_run_state: Callable[[str, dict[str, Any]], None], + load_job: Callable[[str], object | None], + current_project_source_root: Callable[[str], Path | None], +) -> str: + state = load_run_state(job_id) + if state is None: + return render_html5_ai_structure_error("Состояние подготовки для этой задачи не найдено. Запустите обработку заново.") + if state.get("result") is not None: + return render_html5_ai_structure_result(dict(state["result"])) + + job = load_job(job_id) + if job is None or str(getattr(job, "project_id", "")) != project_id: + return render_html5_ai_structure_error(f"Задача агента не найдена: {job_id}") + + status = _enum_text(getattr(job, "status", "UNKNOWN")) + source = _enum_text(getattr(job, "source", "")) + logs = list(getattr(job, "logs", []) or []) + if status in {"QUEUED", "RUNNING"}: + return render_html5_ai_structure_job( + project_id=project_id, + job_id=job_id, + status=status, + source=source, + message="Windows Agent выгружает структуру и передает ее на сервер", + logs=logs, + ) + if status != "SUCCEEDED": + error = str(getattr(job, "error", "") or "Windows Agent завершил задачу с ошибкой.") + if logs: + error = f"{error} Последние сообщения: {' | '.join(str(item) for item in logs[-4:])}" + return render_html5_ai_structure_error(error) + + source_root = current_project_source_root(str(state.get("effective_project_id") or project_id)) + if source_root is None: + import_summary = getattr(job, "import_summary", None) or {} + source_path = str(import_summary.get("source_path") or "") + source_root = Path(source_path) if source_path else None + if source_root is None or not source_root.exists(): + return render_html5_ai_structure_error("После выгрузки агентом сервер не нашел папку с XML/BSL-структурой для подготовки пакета.") + + output_path = str(state.get("output_path") or "") + username = str(state.get("username") or "") + password = str(state.get("password") or "") + domain = str(state.get("domain") or "") + display_input_path = str(state.get("display_input_path") or source_root) + display_output_path = str(state.get("display_output_path") or output_path) + + work_dir = work_root / f"{state.get('effective_project_id') or project_id}-{uuid4().hex}" + try: + work_dir.mkdir(parents=True, exist_ok=True) + local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path) + result = prepare( + project_id=str(state.get("effective_project_id") or project_id), + input_path=source_root, + output_path=local_output, + display_input_path=display_input_path, + display_output_path=display_output_path, + ) + if is_unc_path(output_path): + copy_local_tree_to_smb( + source=local_output, + target=output_path, + username=username, + password=password, + domain=domain or None, + ) + except FileNotFoundError as error: + return render_html5_ai_structure_error(str(error)) + except PermissionError as error: + return render_html5_ai_structure_error(f"Нет доступа к папке: {error}") + except OSError as error: + return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}") + except RuntimeError as error: + return render_html5_ai_structure_error(str(error)) + finally: + if work_dir.exists(): + remove_tree(work_dir, expected_parent=work_root) + + state["result"] = result + save_run_state(job_id, state) + return render_html5_ai_structure_result(result) + + +def _detect_binary_input(raw_input_path: str) -> str | None: + lowered = raw_input_path.strip().casefold() + if lowered.endswith(".cf"): + return ".cf" + if lowered.endswith(".cfe"): + return ".cfe" + input_path = Path(raw_input_path) + suffixes = {".cf", ".cfe"} + if input_path.is_file() and input_path.suffix.casefold() in suffixes: + return input_path.suffix.casefold() + if not input_path.exists() or not input_path.is_dir(): + return None + binary_files = sorted(path for path in input_path.rglob("*") if path.is_file() and path.suffix.casefold() in suffixes) + parseable_files = any(path.suffix.casefold() in {".xml", ".mdo", ".bsl"} for path in input_path.rglob("*") if path.is_file()) + if parseable_files or not binary_files: + return None + return binary_files[0].suffix.casefold() + + +def _enum_text(value: object) -> str: + return str(getattr(value, "value", value or "")) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 1e6477a..b199064 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -65,6 +65,7 @@ from api_server.html5_access_controller import ( ) from api_server.ai_structure_service import prepare_ai_structure as _prepare_ai_structure from api_server.html5_ai_structure_controller import ( + html5_ai_structure_job as _html5_ai_structure_job, html5_ai_structure_page as _html5_ai_structure_page, html5_ai_structure_run as _html5_ai_structure_run, ) @@ -318,6 +319,51 @@ def _save_ai_structure_smb_credentials(project_id: str, credentials: dict[str, s }, ) + +def _load_ai_structure_agent_run(job_id: str) -> dict[str, Any] | None: + try: + payload = _storage.read_document("ai_structure_agent_runs", job_id) + except FileNotFoundError: + return None + password_value = str(payload.get("password_b64") or "") + try: + password = base64.b64decode(password_value.encode("ascii")).decode("utf-8") if password_value else "" + except (ValueError, UnicodeDecodeError): + password = "" + result = payload.get("result") + return { + "project_id": str(payload.get("project_id") or ""), + "effective_project_id": str(payload.get("effective_project_id") or ""), + "input_path": str(payload.get("input_path") or ""), + "output_path": str(payload.get("output_path") or ""), + "display_input_path": str(payload.get("display_input_path") or ""), + "display_output_path": str(payload.get("display_output_path") or ""), + "username": str(payload.get("username") or ""), + "domain": str(payload.get("domain") or ""), + "password": password, + "result": result if isinstance(result, dict) else None, + } + + +def _save_ai_structure_agent_run(job_id: str, payload: dict[str, Any]) -> None: + password = str(payload.get("password") or "") + _storage.write_document( + "ai_structure_agent_runs", + job_id, + { + "project_id": str(payload.get("project_id") or ""), + "effective_project_id": str(payload.get("effective_project_id") or ""), + "input_path": str(payload.get("input_path") or ""), + "output_path": str(payload.get("output_path") or ""), + "display_input_path": str(payload.get("display_input_path") or ""), + "display_output_path": str(payload.get("display_output_path") or ""), + "username": str(payload.get("username") or ""), + "domain": str(payload.get("domain") or ""), + "password_b64": base64.b64encode(password.encode("utf-8")).decode("ascii") if password else "", + "result": payload.get("result") if isinstance(payload.get("result"), dict) else None, + }, + ) + _ACCESS_TARGET_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, @@ -535,6 +581,78 @@ def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSour return str(agent.get("agent_id") or "").strip() +async def _start_ai_structure_agent_job(*, project_id: str, effective_project_id: str, input_path: Path) -> AgentImportJob: + settings = _project_settings_or_404(project_id) + binary_files = _ai_structure_binary_files(input_path) + if not binary_files: + raise HTTPException(status_code=400, detail="Во входном пути не найдены файлы .cf или .cfe.") + + source = ImportSourceKind.CF_FILE if any(path.suffix.casefold() == ".cf" for path in binary_files) else ImportSourceKind.CFE_FILE + agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE) + if not agent_id: + raise HTTPException(status_code=400, detail="В настройках проекта не выбран Windows Agent для CF/CFE.") + agent_status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id))) + if agent_status.status != "online": + raise HTTPException(status_code=409, detail=f"Windows Agent {agent_id} сейчас офлайн. Запустите агент и повторите.") + + agent = settings.agent if isinstance(settings.agent, dict) else {} + metadata: dict[str, Any] = { + "platform_version": settings.platform_version or None, + "compatibility_mode": settings.compatibility_mode or None, + } + local_path: str | None = None + + if source == ImportSourceKind.CF_FILE: + one_c_server = _agent_string_value(agent, "one_c_server") or _agent_string_value(agent, "published_1c_server") or _agent_string_value(agent, "published_server_url") + one_c_infobase = _agent_string_value(agent, "one_c_infobase") or _agent_string_value(agent, "published_infobase") + if one_c_server.startswith(("http://", "https://")): + one_c_server = urlsplit(one_c_server).hostname or one_c_server + if not one_c_server or not one_c_infobase: + raise HTTPException( + status_code=400, + detail="Для разбора .cf нужен сервер 1С и имя информационной базы в настройках проекта. Сейчас они не заполнены.", + ) + metadata.update( + { + "one_c_server": one_c_server, + "one_c_infobase": one_c_infobase, + "one_c_user": _agent_string_value(agent, "one_c_user") or None, + "one_c_password": _agent_string_value(agent, "one_c_password") or None, + "include_extensions": True, + } + ) + else: + cfe_files = [path for path in binary_files if path.suffix.casefold() == ".cfe"] + if len(cfe_files) != 1: + raise HTTPException( + status_code=400, + detail="Для прямого разбора расширения укажите один конкретный файл .cfe, а не папку с несколькими расширениями.", + ) + cfe_file = cfe_files[0] + local_path = str(cfe_file) + metadata["one_c_extension"] = cfe_file.stem + + return await create_agent_import_job( + effective_project_id, + source, + AgentImportJobRequest( + agent_id=agent_id, + source=source, + local_path=local_path, + mode=ImportMode.FULL_REPLACE, + metadata=metadata, + ), + ) + + +def _ai_structure_binary_files(input_path: Path) -> list[Path]: + if input_path.is_file() and input_path.suffix.casefold() in {".cf", ".cfe"}: + return [input_path] + if not input_path.exists() or not input_path.is_dir(): + return [] + return sorted(path for path in input_path.rglob("*") if path.is_file() and path.suffix.casefold() in {".cf", ".cfe"}) + + def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None: now = _current_timestamp() for job in list(_agent_import_jobs.values()): @@ -1631,17 +1749,35 @@ async def html5_project_ai_structure(project_id: str) -> Response: async def html5_project_ai_structure_run(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) return _html5_response( - _html5_ai_structure_run( + await _html5_ai_structure_run( project_id=project_id, form=form, prepare=_prepare_ai_structure, work_root=_storage.root / "ai_structure_work", + start_binary_job=_start_ai_structure_agent_job, + save_run_state=_save_ai_structure_agent_run, load_credentials=_load_ai_structure_smb_credentials, save_credentials=_save_ai_structure_smb_credentials, ) ) +@app.get("/html5/projects/{project_id}/ai-structure/jobs/{job_id}") +async def html5_project_ai_structure_job(project_id: str, job_id: str) -> Response: + return _html5_response( + _html5_ai_structure_job( + project_id=project_id, + job_id=job_id, + prepare=_prepare_ai_structure, + work_root=_storage.root / "ai_structure_work", + load_run_state=_load_ai_structure_agent_run, + save_run_state=_save_ai_structure_agent_run, + load_job=lambda current_job_id: _agent_import_jobs.get(current_job_id), + current_project_source_root=_current_project_source_root, + ) + ) + + @app.get("/html5/projects/{project_id}/access/profiles/{profile_name}/plan") async def html5_project_access_profile_plan(project_id: str, profile_name: str) -> Response: return _html5_response( diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 076ffd8..7311cfb 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1809,6 +1809,84 @@ def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path): assert "Статус: `export_required`" in (output / payload["codex_package_folder"] / "README.md").read_text(encoding="utf-8") +def test_html5_ai_structure_routes_binary_cf_through_windows_agent(tmp_path: Path): + metadata_root = tmp_path / "metadata" + metadata_root.mkdir() + (metadata_root / "metadata.xml").write_text( + """ + + + +""", + encoding="utf-8", + ) + cf_input = tmp_path / "demo.cf" + cf_input.write_bytes(b"binary-cf") + output = tmp_path / "ai-out" + client = TestClient(app) + project_id = f"ai-agent-{uuid4()}" + agent_id = f"win-agent-{uuid4()}" + + indexed = client.post("/projects/index", json={"path": str(metadata_root), "project_id": project_id}) + assert indexed.status_code == 200 + settings = client.post( + f"/projects/{project_id}/settings", + json={ + "name": "AI Agent Demo", + "structure_source": "CF_FILE", + "agent": { + "cf_agent_id": agent_id, + "one_c_server": "192.168.200.95", + "one_c_infobase": "upo_test", + "one_c_user": "svc", + "one_c_password": "secret", + }, + }, + ) + assert settings.status_code == 200 + heartbeat = client.post("/agent/heartbeat", json={"agent_id": agent_id, "host": "test-host"}) + assert heartbeat.status_code == 200 + + queued = client.post( + f"/html5/projects/{project_id}/ai-structure/run", + data={"project_id": project_id, "input_path": str(cf_input), "output_path": str(output)}, + ) + assert queued.status_code == 200 + assert "Windows Agent" in queued.text + assert "/ai-structure/jobs/" in queued.text + match = re.search(r"/html5/projects/[^/]+/ai-structure/jobs/([A-Za-z0-9-]+)", queued.text) + assert match is not None + job_id = match.group(1) + + claimed = client.get("/agent/jobs/next", params={"agent_id": agent_id}) + assert claimed.status_code == 200 + assert claimed.json()["job_id"] == job_id + assert claimed.json()["source"] == "CF_FILE" + + completed = client.post( + f"/agent/jobs/{job_id}/result", + json={ + "status": "SUCCEEDED", + "server_path": str(metadata_root), + "logs": ["Выгрузка конфигурации завершена."], + }, + ) + assert completed.status_code == 200 + + deadline = time.monotonic() + 10 + fragment = "" + while time.monotonic() < deadline: + polled = client.get(f"/html5/projects/{project_id}/ai-structure/jobs/{job_id}") + assert polled.status_code == 200 + fragment = polled.text + if "готово" in fragment: + break + time.sleep(0.05) + assert "готово" in fragment + assert "codex-1c-context" in fragment + assert (output / f"codex-1c-context-{project_id}" / "AGENTS.md").exists() + + def test_import_full_replace_replaces_current_normalized_project(tmp_path: Path): first = tmp_path / "first" second = tmp_path / "second"