from __future__ import annotations import asyncio import base64 import hashlib import json import os import re import shutil import threading import time import urllib.error import urllib.request import zipfile import xml.etree.ElementTree as ET from collections import Counter from difflib import SequenceMatcher from enum import Enum from pathlib import Path from typing import Any, Callable from urllib.parse import parse_qs, quote, urljoin, urlsplit, urlunsplit from uuid import uuid4 from collaboration import ( ActivityEvent, ChangeSession, Comment, InMemoryCollaborationStore, Ownership, Task, User, ) from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import PlainTextResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from neo4j import AsyncGraphDatabase from pydantic import BaseModel, Field from api_server.html5 import ( render_html5_flowchart, render_html5_index, render_html5_object_context, render_html5_object_report, render_html5_project_rows, render_html5_project_report, render_html5_review, ) from api_server.html5_authoring import ( render_html5_authoring_apply_result, render_html5_authoring_changes, render_html5_authoring_change_detail, render_html5_authoring_diff_result, render_html5_authoring_preview_result, render_html5_authoring_rollback_result, render_html5_metadata_apply_result, render_html5_metadata_preview_result, ) from api_server.html5_editor import ( render_html5_editor, render_html5_source, render_html5_status, render_html5_symbol_detail, render_html5_symbols, ) from api_server.html5_operations import ( render_html5_operation_detail, render_html5_operation_rows, render_html5_operation_summary, render_html5_operations, ) from api_server.html5_setup import ( render_html5_import_check, render_html5_import_job, render_html5_project_setup, render_html5_settings_panel, render_html5_setup_summary, ) from impact_engine import object_impact, routine_impact from incremental_indexer import rebuild_changed_file from integration_topology import IntegrationKind, build_integration_topology from job_topology import snapshot_scheduled_jobs from knowledge_base import InMemoryKnowledgeBase, KnowledgePack, KnowledgeRecord, KnowledgeScope from operations_core import ( AiUsageRecord, AiUsageSummary, InMemoryOperationsStore, LicenseState, MarketplacePackage, MetricSample, OperationJob, OperationJobStatus, build_project_report, ) from one_c_normalizer import ( COMMON_BRANCH_CHILDREN, METADATA_CHILD_OBJECT_SPECS, METADATA_TYPE_BY_BRANCH, METADATA_TYPE_BY_CODE, METADATA_TYPE_CONTEXT_ACTIONS, METADATA_TYPE_DESCRIPTIONS, METADATA_TYPE_DOCUMENTATION_URLS, METADATA_TYPE_PROPERTIES, METADATA_TYPE_SPECS, MetadataObject, NormalizedProject, normalize_one_c_project, ) from pattern_mining import SemanticPattern, mine_patterns from projection_engine import InMemoryProjection, Neo4jProjection from query_intelligence import table_usage from review_engine import review_snapshot from runtime_overlays import RuntimeOverlay, RuntimeSignal, summarize_runtime from security_core import ( AiUsagePolicy, InMemoryPrivacyStore, Permission, PrivacyClassification, PrivacyMarker, PrivacyMode, UserAccess, default_rbac_policy, ) from semantic_search import search_snapshot from semantic_versioning import ( InMemoryObjectVersionStore, SemanticObjectDiff, SemanticObjectVersion, diff_versions, ) from semantic_kernel import index_project from sir import DiagnosticSeverity, EdgeKind, NodeKind, SirDelta, SirSnapshot, stable_hash from storage_core import FileStorage, StoredSnapshotInfo from transaction_topology import routines_touching_target, transaction_write_sets from ui_semantics import form_semantics app = FastAPI(title="SFERA API", version="0.1.0") _HTML5_ASSETS_DIR = Path(__file__).resolve().parent / "static" / "html5" app.mount("/html5/assets", StaticFiles(directory=_HTML5_ASSETS_DIR), name="html5-assets") app.add_middleware( CORSMiddleware, allow_origin_regex=os.environ.get( "SFERA_CORS_ORIGIN_REGEX", r"^https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+|docker-test\.cin\.su)(:\d+)?$", ), allow_credentials=False, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Accept", "Content-Type"], ) _snapshots: dict[str, SirSnapshot] = {} _graphs: dict[str, InMemoryProjection] = {} _neo4j_projected_projects: set[str] = set() _overlays: dict[str, RuntimeOverlay] = {} _knowledge = InMemoryKnowledgeBase() _collaboration = InMemoryCollaborationStore() _rbac = default_rbac_policy() _privacy = InMemoryPrivacyStore() _operations = InMemoryOperationsStore() _versions = InMemoryObjectVersionStore() _ai_policy = AiUsagePolicy(token_limit_per_day=100_000) _storage = FileStorage(os.environ.get("SFERA_STORAGE_PATH", ".sfera/storage")) _storage.initialize() _normalized_projects: dict[str, NormalizedProject] = {} _neo4j_uri = os.environ.get("NEO4J_URI", "bolt://docker-test.cin.su:17687") _neo4j_user = os.environ.get("NEO4J_USER", "neo4j") _neo4j_password = os.environ.get("NEO4J_PASSWORD", "password") _EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None) _ACCESS_TARGET_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.COMMON_MODULE, NodeKind.CONSTANT, NodeKind.DOCUMENT_JOURNAL, NodeKind.ENUM, NodeKind.REPORT, NodeKind.DATA_PROCESSOR, NodeKind.CHART_OF_CHARACTERISTIC_TYPES, NodeKind.CHART_OF_ACCOUNTS, NodeKind.CHART_OF_CALCULATION_TYPES, NodeKind.EXCHANGE_PLAN, NodeKind.EXTERNAL_DATA_SOURCE, NodeKind.SCHEDULED_JOB, NodeKind.BUSINESS_PROCESS, NodeKind.TASK, } if _EVENT_SUBSCRIPTION_KIND is not None: _ACCESS_TARGET_KINDS.add(_EVENT_SUBSCRIPTION_KIND) _OWNERSHIP_TARGET_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.COMMON_MODULE, NodeKind.EXCHANGE_PLAN, NodeKind.SCHEDULED_JOB, NodeKind.BUSINESS_PROCESS, NodeKind.TASK, } if _EVENT_SUBSCRIPTION_KIND is not None: _OWNERSHIP_TARGET_KINDS.add(_EVENT_SUBSCRIPTION_KIND) _PRIVACY_TARGET_KINDS = _OWNERSHIP_TARGET_KINDS | { NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION, NodeKind.FORM, NodeKind.FORM_ELEMENT, } _SENSITIVE_NAME_HINTS = { "адрес", "банк", "дата рождения", "договор", "документ", "email", "e-mail", "инн", "кпп", "лицо", "паспорт", "почта", "снилс", "телефон", "физлицо", } _MODULE_OWNER_NODE_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.REPORT, NodeKind.DATA_PROCESSOR, NodeKind.COMMON_MODULE, NodeKind.EXCHANGE_PLAN, NodeKind.BUSINESS_PROCESS, NodeKind.TASK, NodeKind.SUBSYSTEM, NodeKind.HTTP_SERVICE, NodeKind.XDTO_PACKAGE, } if _EVENT_SUBSCRIPTION_KIND is not None: _MODULE_OWNER_NODE_KINDS.add(_EVENT_SUBSCRIPTION_KIND) _METADATA_ICON_BY_NODE_KIND = { NodeKind.EXCHANGE_PLAN: "exchange-plan", NodeKind.SCHEDULED_JOB: "scheduled-job", NodeKind.ATTRIBUTE: "attribute", NodeKind.COMMAND: "command", NodeKind.FORM: "form", NodeKind.FORM_ELEMENT: "form", NodeKind.ROLE: "role", NodeKind.TABULAR_SECTION: "tabular", } if _EVENT_SUBSCRIPTION_KIND is not None: _METADATA_ICON_BY_NODE_KIND[_EVENT_SUBSCRIPTION_KIND] = "event" class ProjectSetupStatus(str, Enum): NOT_CONFIGURED = "NOT_CONFIGURED" IMPORT_REQUIRED = "IMPORT_REQUIRED" IMPORTED = "IMPORTED" STRUCTURE_INDEXED = "STRUCTURE_INDEXED" INDEXED = "INDEXED" class ImportSourceStatus(str, Enum): AVAILABLE = "доступен" REQUIRES_1C_PLATFORM = "требует 1С платформу" REQUIRES_AGENT = "требует агент" REQUIRES_CREDENTIALS = "требует учетные данные" METADATA_ONLY = "только metadata" FULL_IMPORT = "полный import" class ImportSourceKind(str, Enum): CF_FILE = "CF_FILE" CFE_FILE = "CFE_FILE" XML_DUMP = "XML_DUMP" LIVE_INFOBASE = "LIVE_INFOBASE" EPF_AGENT = "EPF_AGENT" CFE_AGENT = "CFE_AGENT" EDT_PROJECT = "EDT_PROJECT" ARCHIVE_DUMP = "ARCHIVE_DUMP" FILE_TREE = "FILE_TREE" CONTEXT_ONLY = "CONTEXT_ONLY" REFERENCE_CONFIGURATION = "REFERENCE_CONFIGURATION" class ImportMode(str, Enum): FULL_REPLACE = "FULL_REPLACE" SYNC_PREVIEW = "SYNC_PREVIEW" class AgentImportJobStatus(str, Enum): QUEUED = "QUEUED" RUNNING = "RUNNING" SUCCEEDED = "SUCCEEDED" FAILED = "FAILED" CANCELLED = "CANCELLED" class AgentImportJobRequest(BaseModel): agent_id: str source: ImportSourceKind local_path: str | None = None bin_path: str | None = None infobase: str | None = None credentials_ref: str | None = None mode: ImportMode = ImportMode.FULL_REPLACE metadata: dict = Field(default_factory=dict) class AgentImportJob(BaseModel): job_id: str project_id: str agent_id: str source: ImportSourceKind mode: ImportMode = ImportMode.FULL_REPLACE status: AgentImportJobStatus = AgentImportJobStatus.QUEUED local_path: str | None = None bin_path: str | None = None infobase: str | None = None credentials_ref: str | None = None metadata: dict = Field(default_factory=dict) created_at: str updated_at: str claimed_at: str | None = None completed_at: str | None = None server_path: str | None = None logs: list[str] = Field(default_factory=list) error: str | None = None import_summary: dict | None = None class AgentImportJobResult(BaseModel): status: AgentImportJobStatus = AgentImportJobStatus.SUCCEEDED server_path: str | None = None logs: list[str] = Field(default_factory=list) error: str | None = None metadata: dict = Field(default_factory=dict) class AgentImportJobLogRequest(BaseModel): logs: list[str] = Field(default_factory=list) status: AgentImportJobStatus | None = None class AgentBrowseRequestStatus(str, Enum): QUEUED = "QUEUED" RUNNING = "RUNNING" SUCCEEDED = "SUCCEEDED" FAILED = "FAILED" class AgentBrowseRequestCreate(BaseModel): agent_id: str path: str | None = None class AgentFolderEntry(BaseModel): name: str path: str is_directory: bool = True class AgentBrowseRequest(BaseModel): request_id: str agent_id: str path: str | None = None status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.QUEUED created_at: str updated_at: str claimed_at: str | None = None completed_at: str | None = None entries: list[AgentFolderEntry] = Field(default_factory=list) parent_path: str | None = None error: str | None = None class AgentBrowseResult(BaseModel): status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.SUCCEEDED entries: list[AgentFolderEntry] = Field(default_factory=list) parent_path: str | None = None error: str | None = None class AgentHeartbeatRequest(BaseModel): agent_id: str host: str | None = None user: str | None = None version: str | None = None started_at: str | None = None network_roots: list[str] = Field(default_factory=list) platform_bins: list[str] = Field(default_factory=list) class AgentStatus(BaseModel): agent_id: str status: str = "offline" last_seen_at: str | None = None host: str | None = None user: str | None = None version: str | None = None started_at: str | None = None network_roots: list[str] = Field(default_factory=list) platform_bins: list[str] = Field(default_factory=list) class AgentProjectConfig(BaseModel): project_id: str project_name: str one_c_bin: str | None = None edt_path: str | None = None default_local_path: str | None = None network_roots: list[str] = Field(default_factory=list) class AgentConfigResponse(BaseModel): agent_id: str poll_seconds: int = 5 projects: list[AgentProjectConfig] = Field(default_factory=list) class ServerBrowseResponse(BaseModel): path: str parent_path: str | None = None entries: list[AgentFolderEntry] = Field(default_factory=list) error: str | None = None class ServerSmbBrowseRequest(BaseModel): path: str username: str | None = None password: str | None = None domain: str | None = None _IMPORT_SOURCE_REGISTRY: dict[ImportSourceKind, dict] = { ImportSourceKind.CF_FILE: { "title": "Выгрузка конфигурации из базы 1С", "statuses": [ ImportSourceStatus.REQUIRES_1C_PLATFORM, ImportSourceStatus.FULL_IMPORT, ], }, ImportSourceKind.CFE_FILE: { "title": "Загрузка отдельного .cfe расширения", "statuses": [ ImportSourceStatus.REQUIRES_1C_PLATFORM, ImportSourceStatus.FULL_IMPORT, ], }, ImportSourceKind.XML_DUMP: { "title": "Загрузка XML dump", "statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY], }, ImportSourceKind.LIVE_INFOBASE: { "title": "Подключение к live infobase через Designer CLI", "statuses": [ ImportSourceStatus.REQUIRES_1C_PLATFORM, ImportSourceStatus.REQUIRES_CREDENTIALS, ImportSourceStatus.FULL_IMPORT, ], }, ImportSourceKind.EPF_AGENT: { "title": "Snapshot от EPF агента", "statuses": [ImportSourceStatus.REQUIRES_AGENT, ImportSourceStatus.FULL_IMPORT], }, ImportSourceKind.CFE_AGENT: { "title": "Snapshot от CFE агента", "statuses": [ImportSourceStatus.REQUIRES_AGENT, ImportSourceStatus.FULL_IMPORT], }, ImportSourceKind.EDT_PROJECT: { "title": "Загрузка EDT project", "statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY], }, ImportSourceKind.ARCHIVE_DUMP: { "title": "Загрузка архива выгрузки конфигурации", "statuses": [ImportSourceStatus.REQUIRES_1C_PLATFORM, ImportSourceStatus.FULL_IMPORT], }, ImportSourceKind.FILE_TREE: { "title": "Загрузка набора BSL/XML файлов", "statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY], }, ImportSourceKind.CONTEXT_ONLY: { "title": "Context-only конфигурация для анализа экосистемы", "statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY], }, ImportSourceKind.REFERENCE_CONFIGURATION: { "title": "Reference configuration для сравнения и проверки поведения", "statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY], }, } _project_setup: dict[str, dict] = {} _agent_import_jobs: dict[str, "AgentImportJob"] = {} _agent_browse_requests: dict[str, "AgentBrowseRequest"] = {} _agent_statuses: dict[str, "AgentStatus"] = {} ImportProgressCallback = Callable[[str, dict[str, Any]], None] def _agent_status_with_liveness(status: "AgentStatus") -> "AgentStatus": from datetime import datetime, timezone if not status.last_seen_at: status.status = "offline" return status try: last_seen = datetime.fromisoformat(status.last_seen_at) if last_seen.tzinfo is None: last_seen = last_seen.replace(tzinfo=timezone.utc) age_seconds = (datetime.now(timezone.utc) - last_seen).total_seconds() status.status = "online" if age_seconds <= 90 else "offline" except ValueError: status.status = "offline" return status def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSourceKind") -> str: agent = settings.agent if isinstance(settings.agent, dict) else {} if source == ImportSourceKind.CF_FILE: return str(agent.get("cf_agent_id") or agent.get("agent_id") or "").strip() if source in { ImportSourceKind.XML_DUMP, ImportSourceKind.EDT_PROJECT, ImportSourceKind.ARCHIVE_DUMP, ImportSourceKind.FILE_TREE, }: return str(agent.get("edt_agent_id") or agent.get("agent_id") or "").strip() return str(agent.get("agent_id") or "").strip() 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()): if ( job.project_id != project_id or job.agent_id == selected_agent_id or job.metadata.get("agent_action") != "INSTALL_SFERA_EXTENSION" or job.status not in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING} ): continue job.status = AgentImportJobStatus.CANCELLED job.updated_at = now job.completed_at = now job.error = ( "Старая задача установки расширения SFERA отменена: " f"в настройках проекта выбран Windows Agent {selected_agent_id}." ) job.logs.append( f"Cancelled old SFERA extension install on {job.agent_id}; current selected agent is {selected_agent_id}." ) _persist_agent_import_job(job) def _load_persisted_state() -> None: for payload in _storage.list_documents("project_settings"): project_id = payload.get("project_id") if isinstance(project_id, str): _project_setup[project_id] = payload for payload in _storage.list_documents("knowledge"): _knowledge.upsert(KnowledgeRecord.model_validate(payload)) for payload in _storage.list_documents("knowledge_packs"): _knowledge.import_pack(KnowledgePack.model_validate(payload)) for payload in _storage.list_documents("collaboration_users"): _collaboration.upsert_user(User.model_validate(payload)) for payload in _storage.list_documents("collaboration_tasks"): _collaboration.upsert_task(Task.model_validate(payload)) for payload in _storage.list_documents("collaboration_sessions"): _collaboration.sessions[payload["session_id"]] = ChangeSession.model_validate(payload) for payload in _storage.list_documents("collaboration_comments"): comment = Comment.model_validate(payload) _collaboration.comments[comment.comment_id] = comment for payload in _storage.list_documents("collaboration_ownership"): ownership = Ownership.model_validate(payload) _collaboration.ownership[_collaboration._ownership_key(ownership)] = ownership for payload in _storage.list_documents("operations_jobs"): job = OperationJob.model_validate(payload) if job.kind == "SERVER_IMPORT" and job.status in {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING}: job.status = OperationJobStatus.FAILED job.error = "Операция была прервана перезапуском сервера." from datetime import datetime, timezone job.payload = { **job.payload, "stage": "interrupted", "message": "Операция была прервана перезапуском сервера.", "finished_at": datetime.now(timezone.utc).isoformat(), } _storage.write_document("operations_jobs", job.job_id, job.model_dump(mode="json")) _operations.jobs[job.job_id] = job for payload in _storage.list_documents("agent_import_jobs"): job = AgentImportJob.model_validate(payload) _agent_import_jobs[job.job_id] = job for job in list(_agent_import_jobs.values()): if job.status not in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING}: continue server_import_job_id = str(job.metadata.get("server_import_job_id") or "").strip() if not server_import_job_id: continue server_job = _operations.jobs.get(server_import_job_id) if not server_job or server_job.status == OperationJobStatus.RUNNING: continue job.status = AgentImportJobStatus.SUCCEEDED if server_job.status == OperationJobStatus.SUCCEEDED else AgentImportJobStatus.FAILED job.error = server_job.error from datetime import datetime, timezone job.completed_at = datetime.now(timezone.utc).isoformat() job.updated_at = job.completed_at if server_job.result.get("import_summary"): job.import_summary = ImportSummary.model_validate(server_job.result["import_summary"]) job.logs.append(f"Server import {server_import_job_id} finished with status {server_job.status.value}.") _storage.write_document("agent_import_jobs", job.job_id, job.model_dump(mode="json")) for payload in _storage.list_documents("agent_browse_requests"): browse_request = AgentBrowseRequest.model_validate(payload) _agent_browse_requests[browse_request.request_id] = browse_request for payload in _storage.list_documents("agent_statuses"): status = AgentStatus.model_validate(payload) _agent_statuses[status.agent_id] = _agent_status_with_liveness(status) for payload in _storage.list_documents("operations_metrics"): _operations.metrics.append(MetricSample.model_validate(payload)) for payload in _storage.list_documents("ai_usage"): usage = AiUsageRecord.model_validate(payload) _operations.ai_usage[usage.usage_id] = usage for payload in _storage.list_documents("marketplace_packages"): package = MarketplacePackage.model_validate(payload) _operations.marketplace[package.package_id] = package for payload in _storage.list_documents("security_users"): access = UserAccess.model_validate(payload) _rbac.users[access.user_id] = access for payload in _storage.list_documents("privacy_markers"): _privacy.upsert_marker(PrivacyMarker.model_validate(payload)) version_load_limit = int(os.environ.get("SFERA_STARTUP_OBJECT_VERSION_LIMIT", "0")) for payload in _storage.list_documents("object_versions", limit=version_load_limit): _versions.upsert_version(SemanticObjectVersion.model_validate(payload)) _load_persisted_state() @app.get("/") async def root() -> dict: return { "name": "SFERA API", "status": "ok", "docs": "/docs", "health": "/health", "endpoints": [ "POST /projects/index", "POST /projects/demo/index", "POST /projects/{project_id}/load", "POST /projects/{project_id}/incremental/file", "GET /storage/snapshots", "GET /metadata/catalog", "GET /projects/{project_id}/snapshot", "GET /projects/{project_id}/snapshot/export", "GET /projects/{project_id}/versions", "GET /versions/{lineage_id}", "GET /versions/{lineage_id}/diff", "GET /projects/{project_id}/impact/{routine_name}", "GET /projects/{project_id}/objects/schema/{object_name}", "GET /projects/{project_id}/objects/attributes/{object_name}", "GET /projects/{project_id}/objects/tabular-sections/{object_name}", "GET /projects/{project_id}/objects/tabular-sections/{object_name}/columns", "GET /projects/{project_id}/search", "GET /projects/{project_id}/tables/usage", "GET /projects/{project_id}/patterns", "GET /projects/{project_id}/transactions/writes", "GET /projects/{project_id}/ui/forms", "GET /projects/{project_id}/objects/ui/{object_name}", "GET /projects/{project_id}/jobs/scheduled", "GET /projects/{project_id}/integrations", "GET /projects/{project_id}/access/objects/{object_name}/roles", "GET /projects/{project_id}/access/roles/{role_name}/objects", "GET /graph/neo4j/status", "POST /projects/{project_id}/graph/neo4j/project", "GET /projects/{project_id}/graph/neo4j/callees/{routine_name}", "GET /projects/{project_id}/graph/neo4j/callers/{routine_name}", "GET /projects/{project_id}/graph/neo4j/query-tables/{routine_name}", "GET /projects/{project_id}/graph/neo4j/writes/{routine_name}", "GET /projects/{project_id}/graph/neo4j/integrations", "GET /projects/{project_id}/graph/neo4j/integrations/{integration_name}/modules", "GET /projects/{project_id}/graph/neo4j/objects/schema/{object_name}", "GET /projects/{project_id}/graph/neo4j/objects/attributes/{object_name}", "GET /projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}", "GET /projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}/columns", "GET /projects/{project_id}/graph/neo4j/objects/impact/{object_name}", "GET /projects/{project_id}/graph/neo4j/objects/ui/{object_name}", "GET /projects/{project_id}/graph/neo4j/access/objects/{object_name}/roles", "GET /projects/{project_id}/graph/neo4j/access/roles/{role_name}/objects", "POST /knowledge", "POST /knowledge/packs", "GET /knowledge/packs", "GET /knowledge/search", "GET /projects/{project_id}/knowledge/coverage", "GET /projects/{project_id}/knowledge/schema-coverage", "POST /collaboration/users", "POST /collaboration/tasks", "POST /collaboration/sessions", "POST /collaboration/sessions/{session_id}/finish", "POST /projects/{project_id}/comments", "GET /projects/{project_id}/comments", "GET /projects/{project_id}/comments/{target_id}", "POST /projects/{project_id}/ownership", "GET /projects/{project_id}/ownership", "GET /projects/{project_id}/objects/ownership/{object_name}", "GET /projects/{project_id}/activity", "GET /security/users/{user_id}/permissions/{permission}", "GET /security/users/{user_id}/permissions", "POST /security/users/{user_id}/roles/{role_id}", "POST /projects/{project_id}/privacy/markers", "GET /projects/{project_id}/privacy/markers", "GET /projects/{project_id}/objects/privacy/{object_name}", "GET /projects/{project_id}/symbols", "GET /projects/{project_id}/symbols/definition", "GET /projects/{project_id}/symbols/references", "POST /operations/jobs", "GET /operations/jobs", "POST /operations/jobs/{job_id}/run", "POST /operations/metrics", "GET /operations/metrics", "POST /ai/usage", "GET /ai/usage", "GET /ai/usage/summary", "GET /ai/policy", "POST /projects/{project_id}/ai/answer-policy", "GET /projects/{project_id}/report", "POST /marketplace/packages", "GET /marketplace/packages", "GET /license", "GET /admin/summary", "POST /projects/{project_id}/runtime/signals", "GET /projects/{project_id}/runtime/summary", ], "quick_start": { "index_demo": "POST /projects/demo/index", "open_docs": "/docs", "admin_summary": "/admin/summary", }, } class IndexProjectRequest(BaseModel): path: str project_id: str | None = None structure_only: bool = False class ProjectSettingsRequest(BaseModel): name: str = "SFERA Project" configuration_source: str | None = None structure_source: ImportSourceKind | None = None platform_version: str | None = None compatibility_mode: str | None = None extensions: list[str] = Field(default_factory=list) environments: dict = Field(default_factory=dict) agent: dict = Field(default_factory=dict) server_import: dict = Field(default_factory=dict) privacy_mode: str = "METADATA_ONLY" knowledge_sources: list[str] = Field(default_factory=list) task_session_policy: dict = Field(default_factory=dict) class ProjectCreateRequest(BaseModel): project_id: str name: str | None = None template_project_id: str | None = None copy_settings: bool = False class ProjectDeleteRequest(BaseModel): confirmation: str delete_settings: bool = True delete_imports: bool = True delete_collaboration: bool = True delete_versions: bool = True class ProjectDeleteResponse(BaseModel): project_id: str deleted: list[str] = Field(default_factory=list) class ProjectSummaryResponse(BaseModel): project_id: str name: str status: ProjectSetupStatus has_snapshot: bool = False class ImportSourceInfo(BaseModel): kind: ImportSourceKind title: str statuses: list[ImportSourceStatus] = Field(default_factory=list) runtime_mode: str = "mock" available: bool = True class ImportPreflightCheck(BaseModel): code: str title: str status: str message: str class ImportCheckResponse(BaseModel): project_id: str source: ImportSourceKind status: str ready: bool = False checks: list[ImportPreflightCheck] = Field(default_factory=list) class PublishedInfobaseCheckRequest(BaseModel): server_url: str | None = None infobase: str | None = None base_url: str | None = None username: str | None = None password: str | None = None odata_path: str = "/odata/standard.odata" extension_http_path: str | None = None extension_service_root: str | None = None extension_health_path: str | None = None timeout_seconds: float = 10.0 class PublishedInfobaseEntity(BaseModel): name: str entity_type: str | None = None class PublishedInfobaseCheckResponse(BaseModel): project_id: str status: str ready: bool = False base_url: str | None = None odata_url: str | None = None metadata_url: str | None = None extension_url: str | None = None entity_sets_count: int = 0 entity_types_count: int = 0 functions_count: int = 0 actions_count: int = 0 entity_sets: list[PublishedInfobaseEntity] = Field(default_factory=list) checks: list[ImportPreflightCheck] = Field(default_factory=list) class SferaExtensionCallRequest(BaseModel): operation: str payload: dict = Field(default_factory=dict) dry_run: bool = True allow_mutation: bool = False timeout_seconds: float = 30.0 class SferaExtensionCallResponse(BaseModel): project_id: str operation: str status: str ready: bool = False dry_run: bool = True extension_url: str | None = None result: dict = Field(default_factory=dict) checks: list[ImportPreflightCheck] = Field(default_factory=list) class ImportRequest(BaseModel): source: ImportSourceKind path: str | None = None credentials_ref: str | None = None metadata: dict = Field(default_factory=dict) run_indexing: bool = True structure_only: bool = False mode: ImportMode = ImportMode.FULL_REPLACE class ImportSyncDiffItem(BaseModel): qualified_name: str name: str object_kind: str group_name: str | None = None change_kind: str before_hash: str | None = None after_hash: str | None = None class ImportSyncPreview(BaseModel): mode: ImportMode = ImportMode.SYNC_PREVIEW applied: bool = False status: str = "preview_only" message: str added_count: int = 0 removed_count: int = 0 changed_count: int = 0 unchanged_count: int = 0 items: list[ImportSyncDiffItem] = Field(default_factory=list) class NormalizedGroupSummary(BaseModel): name: str object_kind: str object_count: int class NormalizedProjectSummary(BaseModel): project_id: str | None = None source_path: str | None = None group_count: int = 0 object_count: int = 0 attribute_count: int = 0 tabular_section_count: int = 0 form_count: int = 0 command_count: int = 0 role_count: int = 0 rights_count: int = 0 module_count: int = 0 layout_count: int = 0 movement_count: int = 0 extension_count: int = 0 extensions: list[str] = Field(default_factory=list) groups: list[NormalizedGroupSummary] = Field(default_factory=list) class NormalizedObjectDetail(BaseModel): project_id: str | None = None group_name: str object: MetadataObject class ModuleRoutineResponse(BaseModel): name: str kind: str line_start: int | None = None line_end: int | None = None export: bool = False calls_count: int = 0 queries_count: int = 0 writes_count: int = 0 calls: list[str] = Field(default_factory=list) queries: list[str] = Field(default_factory=list) writes: list[str] = Field(default_factory=list) impact_level: str = "LOW" impact_reasons: list[str] = Field(default_factory=list) class ModuleSourceResponse(BaseModel): name: str qualified_name: str module_role: str = "MODULE" source_path: str source_text: str routines_count: int = 0 routines: list[ModuleRoutineResponse] = Field(default_factory=list) class BslCompletionItemResponse(BaseModel): label: str kind: str = "VALUE" detail: str | None = None insert_text: str | None = None class ImportSummary(BaseModel): source: ImportSourceKind mode: ImportMode = ImportMode.FULL_REPLACE applied: bool = True status: str last_import: str source_path: str | None = None runtime_mode: str = "mock" runtime_diagnostics: list[str] = Field(default_factory=list) errors: list[str] = Field(default_factory=list) diagnostics_count: int = 0 diagnostics: list[str] = Field(default_factory=list) object_count: int = 0 module_count: int = 0 form_count: int = 0 role_count: int = 0 extensions: list[str] = Field(default_factory=list) platform_version: str | None = None compatibility_mode: str | None = None snapshot: SnapshotSummary | None = None normalized_summary: NormalizedProjectSummary | None = None sync_preview: ImportSyncPreview | None = None class ImportQualityCheck(BaseModel): code: str title: str severity: str = "INFO" passed: bool = True message: str value: int | str | None = None class ImportQualityResponse(BaseModel): project_id: str status: str score: int = 0 ready_for_ide: bool = False summary: NormalizedProjectSummary | None = None checks: list[ImportQualityCheck] = Field(default_factory=list) class ProjectSetupResponse(BaseModel): project_id: str status: ProjectSetupStatus message: str settings: ProjectSettingsRequest current_source: ImportSourceKind | None = None last_import: ImportSummary | None = None import_history: list[ImportSummary] = Field(default_factory=list) import_sources: list[ImportSourceInfo] = Field(default_factory=list) class IncrementalFileRequest(BaseModel): path: str class SnapshotSummary(BaseModel): snapshot_id: str project_id: str snapshot_hash: str | None node_count: int edge_count: int diagnostics_count: int unresolved_references_count: int class IndexProjectResponse(BaseModel): snapshot: SnapshotSummary class IncrementalFileResponse(BaseModel): snapshot: SnapshotSummary added_nodes: int updated_nodes: int removed_nodes: int added_edges: int removed_edges: int neo4j_projected: bool = False neo4j_error: str | None = None class MetadataTypeSpecResponse(BaseModel): code: str russian_name: str tree_branch: str icon: str description: str = "" documentation_url: str = "" child_groups: list[str] = Field(default_factory=list) module_kinds: list[str] = Field(default_factory=list) properties: list[str] = Field(default_factory=list) context_actions: list[str] = Field(default_factory=list) class MetadataChildObjectSpecResponse(BaseModel): code: str russian_name: str parent_groups: list[str] = Field(default_factory=list) description: str = "" documentation_url: str = "" class MetadataCatalogResponse(BaseModel): platform_family: str source: str common_branch_children: list[str] = Field(default_factory=list) types: list[MetadataTypeSpecResponse] = Field(default_factory=list) child_object_types: list[MetadataChildObjectSpecResponse] = Field(default_factory=list) class MetadataTreeNodeResponse(BaseModel): id: str label: str kind: str icon: str qualified_name: str | None = None count: int = 0 loaded_count: int = 0 has_more: bool = False children: list["MetadataTreeNodeResponse"] = Field(default_factory=list) class ProjectMetadataTreeResponse(BaseModel): project_id: str root: MetadataTreeNodeResponse class MetadataTreeChildrenResponse(BaseModel): project_id: str parent_id: str offset: int = 0 limit: int = 50 total: int = 0 has_more: bool = False children: list[MetadataTreeNodeResponse] = Field(default_factory=list) class MetadataTreeSearchResponse(BaseModel): project_id: str q: str total: int = 0 results: list[MetadataTreeNodeResponse] = Field(default_factory=list) class MetadataTreePathStepResponse(BaseModel): parent_id: str child_id: str offset: int = 0 class MetadataTreePathResponse(BaseModel): project_id: str node_id: str path: list[str] = Field(default_factory=list) steps: list[MetadataTreePathStepResponse] = Field(default_factory=list) class FlowchartNodeResponse(BaseModel): id: str label: str kind: str qualified_name: str | None = None count: int = 1 level: int = 0 expandable: bool = False class FlowchartEdgeResponse(BaseModel): id: str source: str target: str kind: str label: str count: int = 1 class ProjectFlowchartResponse(BaseModel): project_id: str mode: str focus: str | None = None total_nodes: int total_edges: int nodes: list[FlowchartNodeResponse] = Field(default_factory=list) edges: list[FlowchartEdgeResponse] = Field(default_factory=list) class NamedNode(BaseModel): lineage_id: str kind: str name: str qualified_name: str class ImpactResponse(BaseModel): routine_name: str callers: list[NamedNode] = Field(default_factory=list) callees: list[NamedNode] = Field(default_factory=list) query_tables: list[NamedNode] = Field(default_factory=list) writes: list[NamedNode] = Field(default_factory=list) class RoleAccessResponse(BaseModel): role: NamedNode permissions: dict = Field(default_factory=dict) class ObjectAccessResponse(BaseModel): object: NamedNode grants: list[RoleAccessResponse] = Field(default_factory=list) class RoleObjectAccessResponse(BaseModel): role: NamedNode objects: list[NamedNode] = Field(default_factory=list) grants: list[dict] = Field(default_factory=list) class ObjectImpactResponse(BaseModel): object_name: str object: NamedNode modules: list[NamedNode] = Field(default_factory=list) routines: list[NamedNode] = Field(default_factory=list) forms: list[NamedNode] = Field(default_factory=list) commands: list[NamedNode] = Field(default_factory=list) attributes: list[NamedNode] = Field(default_factory=list) tabular_sections: list[NamedNode] = Field(default_factory=list) tabular_section_columns: dict[str, list[NamedNode]] = Field(default_factory=dict) roles: list[NamedNode] = Field(default_factory=list) role_access: list[RoleAccessResponse] = Field(default_factory=list) jobs: list[NamedNode] = Field(default_factory=list) callees: list[NamedNode] = Field(default_factory=list) query_tables: list[NamedNode] = Field(default_factory=list) writes: list[NamedNode] = Field(default_factory=list) class SearchResponse(BaseModel): results: list[NamedNode] class SourceLocationResponse(BaseModel): source_path: str | None = None line_start: int | None = None line_end: int | None = None column_start: int | None = None column_end: int | None = None class SymbolResponse(BaseModel): node: NamedNode source: SourceLocationResponse class SymbolReferenceResponse(BaseModel): edge_id: str kind: str direction: str source: NamedNode | None = None target: NamedNode | None = None location: SourceLocationResponse | None = None attributes: dict = Field(default_factory=dict) class SymbolReferencesResponse(BaseModel): symbol: SymbolResponse references: list[SymbolReferenceResponse] = Field(default_factory=list) class TabularSectionColumnsResponse(BaseModel): tabular_section: NamedNode columns: list[NamedNode] = Field(default_factory=list) class ObjectSchemaResponse(BaseModel): object: NamedNode attributes: list[NamedNode] = Field(default_factory=list) tabular_sections: list[TabularSectionColumnsResponse] = Field(default_factory=list) class TableUsageResponse(BaseModel): table: NamedNode queries: list[NamedNode] readers: list[NamedNode] writers: list[NamedNode] class TransactionWriteSetResponse(BaseModel): routine: NamedNode writes: list[NamedNode] class FormSemanticsResponse(BaseModel): form: NamedNode commands: list[NamedNode] elements: list[NamedNode] command_handlers: dict[str, NamedNode] = Field(default_factory=dict) class ObjectUiResponse(BaseModel): object: NamedNode forms: list[FormSemanticsResponse] = Field(default_factory=list) class ScheduledJobResponse(BaseModel): job_id: str name: str routine_name: str schedule: str | None = None routine: NamedNode | None = None attributes: dict = Field(default_factory=dict) class IntegrationEndpointResponse(BaseModel): endpoint_id: str name: str kind: str direction: str owner: str | None = None attributes: dict = Field(default_factory=dict) class RuntimeSignalRequest(BaseModel): signal: RuntimeSignal class RuntimeSummaryResponse(BaseModel): node: NamedNode signal_count: int error_count: int max_duration_ms: float | None = None class AiPolicyResponse(BaseModel): policy: AiUsagePolicy used_tokens: int remaining_tokens: int | None = None class AiAnswerPolicyRequest(BaseModel): user_id: str question: str model: str | None = None operation: str = "answer" related_lineages: list[str] = Field(default_factory=list) estimated_tokens: int = 0 require_knowledge: bool = True class AiAnswerPolicyDecision(BaseModel): allowed: bool reasons: list[str] = Field(default_factory=list) warnings: list[str] = Field(default_factory=list) knowledge_records: list[KnowledgeRecord] = Field(default_factory=list) privacy_markers: list[PrivacyMarker] = Field(default_factory=list) used_tokens: int remaining_tokens: int | None = None policy: AiUsagePolicy class AuthoringContextRequest(BaseModel): object_name: str | None = None routine_name: str | None = None cursor_line: int | None = None source_text: str | None = None prefix: str = "" class AuthoringContextResponse(BaseModel): project_id: str object: NamedNode | None = None routine: NamedNode | None = None local_variables: list[str] = Field(default_factory=list) parameters: list[str] = Field(default_factory=list) object_attributes: list[NamedNode] = Field(default_factory=list) tabular_sections: list[NamedNode] = Field(default_factory=list) form_elements: list[NamedNode] = Field(default_factory=list) commands: list[NamedNode] = Field(default_factory=list) query_tables: list[NamedNode] = Field(default_factory=list) writes: list[NamedNode] = Field(default_factory=list) available_methods: list[str] = Field(default_factory=list) privacy_markers: list[PrivacyMarker] = Field(default_factory=list) review_findings: list[dict] = Field(default_factory=list) class AuthoringDiffLine(BaseModel): kind: str text: str class AuthoringGuardCheck(BaseModel): name: str status: str message: str class AuthoringCompletionPreviewRequest(AuthoringContextRequest): intent: str = "guarded-return" estimated_tokens: int = 512 user_id: str | None = None class AuthoringCompletionPreviewResponse(BaseModel): allowed: bool insert_text: str semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list) checks: list[AuthoringGuardCheck] = Field(default_factory=list) context: AuthoringContextResponse class AuthoringSemanticDiffPreviewRequest(BaseModel): target_lineage_id: str | None = None object_name: str | None = None routine_name: str | None = None source_path: str | None = None original_text: str proposed_text: str task_id: str | None = None session_id: str | None = None user_id: str | None = None estimated_tokens: int = 0 class AuthoringVersionPreview(BaseModel): lineage_id: str semantic_id: str current_version_id: str | None = None next_version_id: str object_hash: str task_id: str | None = None session_id: str | None = None apply_available: bool = False class AuthoringSemanticDiffPreviewResponse(BaseModel): project_id: str target: NamedNode | None = None changed: bool added_lines: int removed_lines: int semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list) affected_nodes: list[NamedNode] = Field(default_factory=list) checks: list[AuthoringGuardCheck] = Field(default_factory=list) version_preview: AuthoringVersionPreview | None = None class AuthoringApplyChangeSetRequest(AuthoringSemanticDiffPreviewRequest): expected_next_version_id: str approved_by: str approval_note: str | None = None apply_to_production: bool = False class AuthoringApplyChangeSetResponse(BaseModel): project_id: str status: str change_id: str version: SemanticObjectVersion preview: AuthoringSemanticDiffPreviewResponse persisted_path: str production_applied: bool = False class AuthoringChangeSummary(BaseModel): change_id: str project_id: str status: str target: NamedNode | None = None version_id: str approved_by: str approval_note: str | None = None task_id: str | None = None session_id: str | None = None added_lines: int removed_lines: int production_applied: bool = False class AuthoringRollbackPreviewResponse(BaseModel): project_id: str change_id: str original_version_id: str rollback_version_id: str target: NamedNode | None = None semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list) checks: list[AuthoringGuardCheck] = Field(default_factory=list) apply_available: bool = False class AuthoringApplyRollbackRequest(BaseModel): expected_rollback_version_id: str approved_by: str approval_note: str | None = None task_id: str | None = None session_id: str | None = None apply_to_production: bool = False class AuthoringApplyRollbackResponse(BaseModel): project_id: str status: str change_id: str rollback_change_id: str version: SemanticObjectVersion preview: AuthoringRollbackPreviewResponse persisted_path: str production_applied: bool = False class AuthoringMetadataAttributeDraft(BaseModel): name: str type: str = "Строка" synonym: str | None = None required: bool = False class AuthoringMetadataTabularSectionDraft(BaseModel): name: str synonym: str | None = None attributes: list[AuthoringMetadataAttributeDraft] = Field(default_factory=list) class AuthoringMetadataCommandDraft(BaseModel): name: str handler: str | None = None class AuthoringMetadataObjectPreviewRequest(BaseModel): object_kind: str name: str synonym: str | None = None attributes: list[AuthoringMetadataAttributeDraft] = Field(default_factory=list) tabular_sections: list[AuthoringMetadataTabularSectionDraft] = Field(default_factory=list) forms: list[str] = Field(default_factory=list) commands: list[AuthoringMetadataCommandDraft] = Field(default_factory=list) task_id: str | None = None session_id: str | None = None user_id: str | None = None class AuthoringMetadataObjectPreviewResponse(BaseModel): project_id: str target: NamedNode changed: bool added_lines: int removed_lines: int = 0 semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list) checks: list[AuthoringGuardCheck] = Field(default_factory=list) version_preview: AuthoringVersionPreview class AuthoringApplyMetadataObjectRequest(AuthoringMetadataObjectPreviewRequest): expected_next_version_id: str approved_by: str approval_note: str | None = None apply_to_production: bool = False class AuthoringApplyMetadataObjectResponse(BaseModel): project_id: str status: str change_id: str version: SemanticObjectVersion preview: AuthoringMetadataObjectPreviewResponse persisted_path: str production_applied: bool = False class KnowledgeSearchResponse(BaseModel): results: list[KnowledgeRecord] class KnowledgeCoverageResponse(BaseModel): node: NamedNode record_count: int class KnowledgeSchemaCoverageResponse(BaseModel): items: list[KnowledgeCoverageResponse] uncovered: list[NamedNode] covered_count: int uncovered_count: int class CollaborationSessionRequest(BaseModel): session: ChangeSession class CommentRequest(BaseModel): comment_id: str target_id: str user_id: str body: str class OwnershipRequest(BaseModel): target_id: str owner_user_id: str role: str = "OWNER" assigned_by: str | None = None attributes: dict = Field(default_factory=dict) class ObjectOwnershipResponse(BaseModel): object: NamedNode owners: list[Ownership] = Field(default_factory=list) class PrivacyMarkerRequest(BaseModel): target_id: str classification: PrivacyClassification reason: str | None = None attributes: dict = Field(default_factory=dict) class ObjectPrivacyResponse(BaseModel): object: NamedNode markers: list[PrivacyMarker] = Field(default_factory=list) class JobUpdateRequest(BaseModel): status: OperationJobStatus result: dict = Field(default_factory=dict) error: str | None = None class Neo4jProjectionResponse(BaseModel): project_id: str nodes: int edges: int status: str class ObjectVersionSummary(BaseModel): version_id: str lineage_id: str semantic_id: str object_hash: str parent_version_id: str | None = None task_id: str | None = None session_id: str | None = None @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok"} @app.get("/api/health") async def api_health() -> dict[str, str]: return {"status": "ok"} @app.get("/html5") async def html5_index() -> Response: return Response( render_html5_index(_project_summaries()), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects") async def html5_create_project(request: Request) -> Response: form = await _html5_form_data(request) project_id = _form_value(form, "project_id") if not project_id: raise HTTPException(status_code=400, detail="project_id is required.") await create_project(ProjectCreateRequest(project_id=project_id, name=_form_value(form, "name"))) return Response( render_html5_project_rows(_project_summaries()), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/delete") async def html5_delete_project(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) confirmation = _form_value(form, "confirmation") or "" await delete_project(project_id, ProjectDeleteRequest(confirmation=confirmation)) return Response( render_html5_project_rows(_project_summaries()), media_type="text/html; charset=utf-8", ) @app.get("/html5/operations") async def html5_operations(project_id: str = "", status: str = "", kind: str = "") -> Response: return Response( render_html5_operations( _html5_operation_jobs(project_id=project_id, status=status, kind=kind), project_id=project_id, status=status, kind=kind, ), media_type="text/html; charset=utf-8", ) @app.get("/html5/operations/jobs") async def html5_operation_jobs(project_id: str = "", status: str = "", kind: str = "") -> Response: return Response( render_html5_operation_rows(_html5_operation_jobs(project_id=project_id, status=status, kind=kind)), media_type="text/html; charset=utf-8", ) @app.get("/html5/operations/jobs/{job_id}/detail") async def html5_operation_job_detail(job_id: str) -> Response: job = _operations.jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown operation job: {job_id}") return Response( render_html5_operation_detail(job), media_type="text/html; charset=utf-8", ) @app.get("/html5/operations/summary") async def html5_operation_summary(project_id: str = "", status: str = "", kind: str = "") -> Response: return Response( render_html5_operation_summary(_html5_operation_jobs(project_id=project_id, status=status, kind=kind)), media_type="text/html; charset=utf-8", ) @app.get("/html5/operations/events") async def html5_operations_events( once: bool = False, project_id: str = "", status: str = "", kind: str = "", ) -> StreamingResponse: def stream_operations(): last_fragments: dict[str, str] = {} while True: yield _html5_sse_comment("operations heartbeat") jobs = _html5_operation_jobs(project_id=project_id, status=status, kind=kind) yield from _html5_sse_if_changed(last_fragments, "operations-summary", render_html5_operation_summary(jobs)) yield from _html5_sse_if_changed(last_fragments, "operations-jobs", render_html5_operation_rows(jobs)) if once: break time.sleep(3) return StreamingResponse( stream_operations(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.get("/html5/projects/{project_id}/editor") async def html5_project_editor(project_id: str, q: str = "") -> Response: try: snapshot = _project_snapshot_or_404(project_id) html = render_html5_editor( project_id=project_id, projects=_project_summaries(), snapshot=snapshot, q=q, ) except HTTPException as error: html = render_html5_editor( project_id=project_id, projects=_project_summaries(), snapshot=None, error=str(error.detail), q=q, ) return Response(html, media_type="text/html; charset=utf-8") @app.get("/html5/projects/{project_id}/events") async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse: async def stream_status(): last_fragments: dict[str, str] = {} while True: yield _html5_sse_comment(f"project {project_id} heartbeat") try: snapshot = _project_snapshot_or_404(project_id) fragment = render_html5_status(project_id, snapshot) report = await project_report(project_id) findings = await get_review(project_id) flowchart = await project_flowchart(project_id, focus=None, depth=1, limit=80) except HTTPException as error: fragment = f'project: {project_id}error: {error.detail}' report = None findings = None flowchart = None for event_text in _html5_sse_if_changed(last_fragments, "status", fragment): yield event_text for event_text in _html5_sse_if_changed( last_fragments, "authoring-changes", render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)), ): yield event_text if report is not None: for event_text in _html5_sse_if_changed(last_fragments, "project-report", render_html5_project_report(project_id, report)): yield event_text if findings is not None: for event_text in _html5_sse_if_changed(last_fragments, "project-review", render_html5_review(project_id, findings)): yield event_text if flowchart is not None: for event_text in _html5_sse_if_changed(last_fragments, "project-flowchart", render_html5_flowchart(project_id, flowchart)): yield event_text if once: break await asyncio.sleep(5) return StreamingResponse( stream_status(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.get("/html5/projects/{project_id}/symbols") async def html5_project_symbols(project_id: str, q: str = "") -> Response: snapshot = _project_snapshot_or_404(project_id) return Response( render_html5_symbols(snapshot, q, project_id), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/symbols/{lineage_id}/detail") async def html5_project_symbol_detail(project_id: str, lineage_id: str) -> Response: references = await project_symbol_references(project_id, lineage_id, direction="both") return Response( render_html5_symbol_detail(project_id, references), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/source/by-path") async def html5_project_source_by_path(project_id: str, path: str) -> Response: snapshot = _project_snapshot_or_404(project_id) node = next( ( item for item in snapshot.nodes if item.source_ref is not None and item.source_ref.source_path == path ), None, ) if node is None: raise HTTPException(status_code=404, detail=f"Source not found: {path}") return Response( render_html5_source(node), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/source/{lineage_id}") async def html5_project_source(project_id: str, lineage_id: str) -> Response: snapshot = _project_snapshot_or_404(project_id) node = _find_snapshot_node(snapshot, lineage_id) if node is None: raise HTTPException(status_code=404, detail=f"Lineage not found: {lineage_id}") return Response( render_html5_source(node), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/report") async def html5_project_report(project_id: str) -> Response: report = await project_report(project_id) return Response( render_html5_project_report(project_id, report), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/review") async def html5_project_review(project_id: str) -> Response: findings = await get_review(project_id) return Response( render_html5_review(project_id, findings), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/flowchart") async def html5_project_flowchart( project_id: str, focus: str | None = None, depth: int = 1, limit: int = 80, ) -> Response: flowchart = await project_flowchart(project_id, focus=focus, depth=depth, limit=limit) return Response( render_html5_flowchart(project_id, flowchart, focus=focus, depth=depth), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/objects/context/{object_name}") async def html5_project_object_context(project_id: str, object_name: str, mode: str = "overview") -> Response: schema = await get_object_schema(project_id, object_name) impact = await get_object_impact(project_id, object_name) access = await get_object_access(project_id, object_name) ui = await get_object_ui(project_id, object_name) privacy = await object_privacy(project_id, object_name) integrations = _integrations_for_object_context(project_id, impact) flowchart = await project_flowchart(project_id, focus=object_name, depth=1, limit=40) runtime = _runtime_for_object_context(project_id, impact) knowledge = _knowledge_for_object_context(schema, impact, ui) source_node = _source_node_for_object_context(project_id, impact) symbol_references = await project_symbol_references(project_id, schema.object.lineage_id, direction="both") focused_findings = _review_for_object_context(project_id, schema, impact, ui) object_context = render_html5_object_context( project_id, schema, impact, access, ui, runtime, knowledge, privacy, integrations, flowchart, mode, ) flowchart_context = render_html5_flowchart(project_id, flowchart, focus=object_name, depth=1, oob=True) source_context = render_html5_source(source_node, oob=True) if source_node is not None else "" symbol_context = render_html5_symbol_detail(project_id, symbol_references, oob=True) report_context = render_html5_object_report( project_id, impact, access=access, privacy=privacy, runtime=runtime, integrations=integrations, oob=True, ) review_context = render_html5_review(project_id, focused_findings, title="Review объекта", oob=True) return Response( object_context + flowchart_context + source_context + symbol_context + report_context + review_context, media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/authoring/changes") async def html5_project_authoring_changes(project_id: str) -> Response: return Response( render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/authoring/changes/{change_id}") async def html5_project_authoring_change_detail(project_id: str, change_id: str) -> Response: return Response( render_html5_authoring_change_detail(project_id, _authoring_rollback_preview(project_id, change_id)), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/authoring/changes/{change_id}/apply-rollback") async def html5_project_authoring_apply_rollback(project_id: str, change_id: str, request: Request) -> Response: form = await _html5_form_data(request) payload = AuthoringApplyRollbackRequest( expected_rollback_version_id=_form_value(form, "expected_rollback_version_id") or "", approved_by=_form_value(form, "approved_by") or "", approval_note=_form_value(form, "approval_note"), task_id=_form_value(form, "task_id"), session_id=_form_value(form, "session_id"), ) try: result = await authoring_apply_rollback(project_id, change_id, payload) html = render_html5_authoring_rollback_result(project_id, result) except HTTPException as error: html = render_html5_authoring_rollback_result(project_id, error=str(error.detail)) return Response(html, media_type="text/html; charset=utf-8") @app.post("/html5/projects/{project_id}/authoring/completion-preview") async def html5_project_authoring_completion_preview(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) cursor_line_value = _form_value(form, "cursor_line") try: cursor_line = int(cursor_line_value) if cursor_line_value else None except ValueError: cursor_line = None payload = AuthoringCompletionPreviewRequest( object_name=_form_value(form, "object_name"), routine_name=_form_value(form, "routine_name"), cursor_line=cursor_line, source_text=_form_value(form, "source_text"), intent=_form_value(form, "intent") or "guarded-return", user_id=_form_value(form, "user_id"), ) try: preview = await authoring_completion_preview(project_id, payload) html = render_html5_authoring_preview_result(project_id, preview) except HTTPException as error: html = render_html5_authoring_preview_result(project_id, error=str(error.detail)) return Response(html, media_type="text/html; charset=utf-8") @app.post("/html5/projects/{project_id}/authoring/semantic-diff-preview") async def html5_project_authoring_semantic_diff_preview(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) payload = AuthoringSemanticDiffPreviewRequest( routine_name=_form_value(form, "routine_name"), source_path=_form_value(form, "source_path"), original_text=_form_value(form, "original_text") or "", proposed_text=_form_value(form, "proposed_text") or "", task_id=_form_value(form, "task_id"), session_id=_form_value(form, "session_id"), user_id=_form_value(form, "user_id"), ) try: preview = _authoring_semantic_diff_preview(project_id, payload) html = render_html5_authoring_diff_result( project_id, preview, request_payload={ "routine_name": payload.routine_name, "source_path": payload.source_path, "original_text": payload.original_text, "proposed_text": payload.proposed_text, "task_id": payload.task_id, "session_id": payload.session_id, "user_id": payload.user_id, }, ) except HTTPException as error: html = render_html5_authoring_diff_result(project_id, error=str(error.detail)) return Response(html, media_type="text/html; charset=utf-8") @app.post("/html5/projects/{project_id}/authoring/apply-change-set") async def html5_project_authoring_apply_change_set(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) payload = AuthoringApplyChangeSetRequest( routine_name=_form_value(form, "routine_name"), source_path=_form_value(form, "source_path"), original_text=_form_value(form, "original_text") or "", proposed_text=_form_value(form, "proposed_text") or "", task_id=_form_value(form, "task_id"), session_id=_form_value(form, "session_id"), user_id=_form_value(form, "user_id"), expected_next_version_id=_form_value(form, "expected_next_version_id") or "", approved_by=_form_value(form, "approved_by") or "", approval_note=_form_value(form, "approval_note"), ) try: result = await authoring_apply_change_set(project_id, payload) html = render_html5_authoring_apply_result(project_id, result) except HTTPException as error: html = render_html5_authoring_apply_result(project_id, error=str(error.detail)) return Response(html, media_type="text/html; charset=utf-8") @app.post("/html5/projects/{project_id}/authoring/metadata-object-preview") async def html5_project_authoring_metadata_object_preview(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) raw_payload = _html5_metadata_payload(form) payload = AuthoringMetadataObjectPreviewRequest(**_html5_metadata_request_payload(raw_payload)) try: preview = _authoring_metadata_object_preview(project_id, payload) html = render_html5_metadata_preview_result(project_id, preview, request_payload=raw_payload) except (HTTPException, ValueError) as error: detail = getattr(error, "detail", str(error)) html = render_html5_metadata_preview_result(project_id, error=str(detail)) return Response(html, media_type="text/html; charset=utf-8") @app.post("/html5/projects/{project_id}/authoring/apply-metadata-object") async def html5_project_authoring_apply_metadata_object(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) raw_payload = _html5_metadata_payload(form) payload = AuthoringApplyMetadataObjectRequest( **_html5_metadata_request_payload(raw_payload), expected_next_version_id=_form_value(form, "expected_next_version_id") or "", approved_by=_form_value(form, "approved_by") or "", approval_note=_form_value(form, "approval_note"), ) try: result = await authoring_apply_metadata_object(project_id, payload) html = render_html5_metadata_apply_result(project_id, result) except (HTTPException, ValueError) as error: detail = getattr(error, "detail", str(error)) html = render_html5_metadata_apply_result(project_id, error=str(detail)) return Response(html, media_type="text/html; charset=utf-8") @app.get("/html5/projects/{project_id}/setup") async def html5_project_setup(project_id: str) -> Response: setup = _project_setup_response(project_id) return Response( render_html5_project_setup(project_id=project_id, projects=_project_summaries(), setup=setup), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/setup/summary") async def html5_project_setup_summary(project_id: str) -> Response: setup = _project_setup_response(project_id) return Response( render_html5_setup_summary(project_id, setup), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/setup/events") async def html5_project_setup_events(project_id: str, once: bool = False) -> StreamingResponse: async def stream_setup(): last_fragments: dict[str, str] = {} while True: yield _html5_sse_comment(f"setup {project_id} heartbeat") try: setup = _project_setup_response(project_id) except HTTPException as error: setup_error = f'

{error.detail}

' for event_text in _html5_sse_if_changed(last_fragments, "setup-summary", setup_error): yield event_text if once: break await asyncio.sleep(2) continue for event_text in _html5_sse_if_changed(last_fragments, "setup-summary", render_html5_setup_summary(project_id, setup)): yield event_text for event_text in _html5_sse_if_changed( last_fragments, "setup-import-job", render_html5_import_job(project_id, _html5_latest_import_job(project_id)), ): yield event_text if once: break await asyncio.sleep(2) return StreamingResponse( stream_setup(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.post("/html5/projects/{project_id}/setup/source") async def html5_project_setup_source(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) source = ImportSourceKind(_form_value(form, "source") or ImportSourceKind.XML_DUMP.value) current = _project_setup_response(project_id) settings = current.settings.model_copy(update={"structure_source": source}) setup = await save_project_settings(project_id, settings) return Response( render_html5_setup_summary(project_id, setup), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/setup/settings") async def html5_project_setup_settings(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) current = _project_setup_response(project_id) settings = current.settings.model_copy( update={ "name": _form_value(form, "name") or current.settings.name, "platform_version": _form_value(form, "platform_version"), "compatibility_mode": _form_value(form, "compatibility_mode"), } ) setup = await save_project_settings(project_id, settings) return Response( render_html5_settings_panel(project_id, setup, saved=True), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/setup/check") async def html5_project_setup_check(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value) check = _import_check_response(project_id, source, ImportRequest(source=source)) return Response( render_html5_import_check(project_id, check), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/setup/import") async def html5_project_setup_import(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value) structure_only = _form_value(form, "structure_only") in {"1", "true", "on", "yes"} _execute_import_project(project_id, ImportRequest(source=source, structure_only=structure_only)) setup = _project_setup_response(project_id) return Response( render_html5_setup_summary(project_id, setup), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/setup/import-job") async def html5_project_setup_import_job(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value) job = await start_project_import_job(project_id, source, ImportRequest(source=source)) return Response( render_html5_import_job(project_id, job), media_type="text/html; charset=utf-8", ) @app.get("/html5/projects/{project_id}/setup/jobs/{job_id}") async def html5_project_setup_job(project_id: str, job_id: str) -> Response: job = _operations.jobs.get(job_id) if job is None or job.payload.get("project_id") != project_id: raise HTTPException(status_code=404, detail=f"Unknown import job: {job_id}") return Response( render_html5_import_job(project_id, job), media_type="text/html; charset=utf-8", ) @app.post("/html5/projects/{project_id}/setup/reindex") async def html5_project_setup_reindex(project_id: str) -> Response: await reindex_project(project_id) setup = _project_setup_response(project_id) return Response( render_html5_setup_summary(project_id, setup), media_type="text/html; charset=utf-8", ) @app.get("/version") async def version() -> dict[str, str]: return {"name": "sfera", "version": "0.1.0"} @app.get("/import-sources", response_model=list[ImportSourceInfo]) async def import_sources() -> list[ImportSourceInfo]: return _import_source_infos() @app.get("/projects", response_model=list[ProjectSummaryResponse]) async def list_projects() -> list[ProjectSummaryResponse]: return _project_summaries() @app.get("/projects/{project_id}/setup", response_model=ProjectSetupResponse) async def project_setup(project_id: str) -> ProjectSetupResponse: return _project_setup_response(project_id) @app.post("/projects", response_model=ProjectSetupResponse) async def create_project(request: ProjectCreateRequest) -> ProjectSetupResponse: project_id = _normalize_project_id(request.project_id) if not project_id: raise HTTPException(status_code=400, detail="project_id is required.") if project_id in _project_setup or _storage.has_snapshot(project_id): raise HTTPException(status_code=409, detail=f"Project already exists: {project_id}") settings = ProjectSettingsRequest(name=request.name or project_id) if request.copy_settings and request.template_project_id: template = _project_setup.get(request.template_project_id, {}).get("settings", {}) settings = ProjectSettingsRequest.model_validate({**template, "name": request.name or project_id}) _project_setup[project_id] = { "project_id": project_id, "settings": settings.model_dump(mode="json"), "status": ProjectSetupStatus.NOT_CONFIGURED.value, "import_history": [], } _storage.write_document("project_settings", project_id, _project_setup[project_id]) return _project_setup_response(project_id) @app.post("/projects/{project_id}/settings", response_model=ProjectSetupResponse) async def save_project_settings(project_id: str, request: ProjectSettingsRequest) -> ProjectSetupResponse: state = _project_setup.setdefault(project_id, {}) state["project_id"] = project_id state["settings"] = request.model_dump(mode="json") if request.structure_source is not None: state["current_source"] = request.structure_source.value state["status"] = ProjectSetupStatus.IMPORT_REQUIRED.value else: state.setdefault("status", ProjectSetupStatus.NOT_CONFIGURED.value) _storage.write_document("project_settings", project_id, state) return _project_setup_response(project_id) @app.post("/projects/{project_id}/published-infobase/check", response_model=PublishedInfobaseCheckResponse) def check_published_infobase( project_id: str, request: PublishedInfobaseCheckRequest, ) -> PublishedInfobaseCheckResponse: if project_id not in _project_setup and not _storage.has_snapshot(project_id): raise HTTPException(status_code=404, detail=f"Project not found: {project_id}") settings = ProjectSettingsRequest.model_validate(_project_setup.get(project_id, {}).get("settings", {})) return _check_published_infobase(project_id, settings, request) @app.post("/projects/{project_id}/live/extension/call", response_model=SferaExtensionCallResponse) def call_sfera_extension(project_id: str, request: SferaExtensionCallRequest) -> SferaExtensionCallResponse: settings = _project_settings_or_404(project_id) return _call_sfera_extension(project_id, settings, request) @app.post("/projects/{project_id}/live/extension/health", response_model=SferaExtensionCallResponse) def sfera_extension_health(project_id: str) -> SferaExtensionCallResponse: settings = _project_settings_or_404(project_id) return _call_sfera_extension(project_id, settings, SferaExtensionCallRequest(operation="health", dry_run=True)) @app.post("/projects/{project_id}/live/extension/metadata-snapshot", response_model=SferaExtensionCallResponse) def sfera_extension_metadata_snapshot(project_id: str) -> SferaExtensionCallResponse: settings = _project_settings_or_404(project_id) return _call_sfera_extension(project_id, settings, SferaExtensionCallRequest(operation="metadata.snapshot", dry_run=True)) @app.post("/projects/{project_id}/live/extension/query", response_model=SferaExtensionCallResponse) def sfera_extension_query(project_id: str, request: SferaExtensionCallRequest) -> SferaExtensionCallResponse: settings = _project_settings_or_404(project_id) return _call_sfera_extension(project_id, settings, request.model_copy(update={"operation": "query.execute"})) @app.get("/projects/{project_id}/live/extension/package.zip") def download_sfera_extension_package(project_id: str) -> Response: _project_settings_or_404(project_id) package = _build_sfera_extension_package() return Response( content=package, media_type="application/zip", headers={"Content-Disposition": 'attachment; filename="sfera-extension-source.zip"'}, ) @app.post("/projects/{project_id}/live/extension/install-agent-job", response_model=AgentImportJob) async def create_sfera_extension_install_job(project_id: str, request: Request) -> AgentImportJob: settings = _project_settings_or_404(project_id) agent = dict(settings.agent or {}) agent_id = (_agent_string_value(agent, "cf_agent_id") or _agent_string_value(agent, "agent_id")).strip() agent_status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id))) if agent_id else None if not agent_id: raise HTTPException(status_code=400, detail="Select Windows Agent for CF/CFE or common agent settings before installing SFERA extension.") if agent_status is None or agent_status.status != "online": raise HTTPException(status_code=409, detail=f"Windows Agent {agent_id} is offline. Start agent and wait for heartbeat.") _cancel_stale_extension_install_jobs(project_id, agent_id) 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="Fill 1C server and infobase in project settings before installing SFERA extension.") active = [ job for job in _agent_import_jobs.values() if job.project_id == project_id and job.metadata.get("agent_action") == "INSTALL_SFERA_EXTENSION" and job.status in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING} ] if active: running = max(active, key=lambda item: item.updated_at) raise HTTPException(status_code=409, detail=f"SFERA extension install is already running as {running.job_id}.") now = _current_timestamp() cfe_path = _agent_string_value(agent, "sfera_extension_cfe_path") package_url = f"{_public_api_origin(request)}/projects/{quote(project_id)}/live/extension/package.zip" metadata = { "agent_action": "INSTALL_SFERA_EXTENSION", "extension_name": _agent_string_value(agent, "sfera_extension_name") or "SFERA", "force_reinstall": True, "extension_cfe_path": cfe_path or None, "extension_package_url": package_url, "one_c_bin": _agent_string_value(agent, "one_c_bin") or None, "one_c_server": one_c_server, "one_c_infobase": one_c_infobase, "one_c_user": _agent_string_value(agent, "one_c_user") or _agent_string_value(agent, "published_user") or None, "one_c_password": _agent_string_value(agent, "one_c_password") or _agent_string_value(agent, "published_password") or None, "published_base_url": _agent_string_value(agent, "published_base_url") or None, "published_vrd_path": _agent_string_value(agent, "published_vrd_path") or None, "published_extension_service_root": _agent_string_value(agent, "published_extension_service_root") or "sfera", "published_extension_health_path": _agent_string_value(agent, "published_extension_health_path") or "health", "sfera_extension_token": _agent_string_value(agent, "sfera_extension_token") or None, } job = AgentImportJob( job_id=f"agent-import-{uuid4()}", project_id=project_id, agent_id=agent_id, source=ImportSourceKind.LIVE_INFOBASE, mode=ImportMode.FULL_REPLACE, bin_path=_agent_string_value(agent, "one_c_bin") or None, infobase=None, metadata=metadata, created_at=now, updated_at=now, logs=[ f"Queued SFERA extension install/update for Windows Agent {agent_id}.", "Agent will run 1C Designer on its machine and will not upload an import archive for this service job.", ], ) _agent_import_jobs[job.job_id] = job return _persist_agent_import_job(job) @app.delete("/projects/{project_id}", response_model=ProjectDeleteResponse) async def delete_project(project_id: str, request: ProjectDeleteRequest) -> ProjectDeleteResponse: if request.confirmation != project_id: raise HTTPException(status_code=400, detail="Project deletion confirmation must match project_id.") deleted = _delete_project_data(project_id, request) return ProjectDeleteResponse(project_id=project_id, deleted=deleted) @app.post("/projects/{project_id}/imports", response_model=ImportSummary) async def import_project(project_id: str, request: ImportRequest) -> ImportSummary: return _execute_import_project(project_id, request) def _execute_import_project( project_id: str, request: ImportRequest, progress: "ImportProgressCallback | None" = None, ) -> ImportSummary: if request.mode == ImportMode.SYNC_PREVIEW: return _import_project_sync_preview(project_id, request) snapshot: SirSnapshot | None = None normalized: NormalizedProject | None = None errors: list[str] = [] _emit_import_progress(progress, "Проверка источника", stage="preflight") source_path = _materialize_import_path(project_id, request, errors, progress=progress) safe_metadata = _sanitize_import_metadata(request.metadata) runtime_diagnostics: list[str] = [] runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock") status = "mock_imported" if _import_path_is_blocked(request, errors): summary = _import_summary_from_snapshot( project_id=project_id, source=request.source, status="import_blocked", snapshot=None, errors=errors, metadata=safe_metadata, runtime_mode=runtime_mode, runtime_diagnostics=[], normalized=None, applied=False, ) state = _project_setup.setdefault(project_id, {}) state["current_source"] = request.source.value state["last_import"] = summary.model_dump(mode="json") state["status"] = ProjectSetupStatus.IMPORT_REQUIRED.value _append_import_history(project_id, summary) _storage.write_document("project_settings", project_id, state) return summary _prepare_project_full_replace(project_id) if ( request.run_indexing and request.source in {ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE} and source_path is not None and source_path.exists() and source_path.is_dir() and _looks_like_metadata_dump(source_path) ): _emit_import_progress(progress, "Индексация структуры из выгрузки CF/CFE", stage="indexing") snapshot = _index_and_store(source_path, project_id, structure_only=True) _emit_import_progress(progress, "Нормализация объектов 1С", stage="normalizing") normalized = _normalize_and_store_import(project_id, source_path) status = "structure_indexed" elif request.run_indexing and _source_requires_runtime(request.source): _emit_import_progress(progress, "Запрос runtime adapter", stage="runtime") runtime_result = _call_runtime_adapter(project_id, request) runtime_mode = str(runtime_result.get("mode", runtime_mode)) runtime_diagnostics.extend(str(item) for item in runtime_result.get("diagnostics", [])) runtime_diagnostics.extend(str(item) for item in runtime_result.get("dump_plan", [])) status = str(runtime_result.get("status", "mock_indexed")) normalized = _normalized_from_runtime_result(project_id, runtime_result) mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata) _emit_import_progress(progress, "Индексация структуры", stage="indexing") snapshot = _index_and_store(mock_root, project_id, structure_only=False) normalized = normalized or _normalize_and_store_import(project_id, mock_root) if status in {"mock_imported", "designer_dump_planned", "queued_for_remote_worker"}: status = "mock_indexed" if status == "mock_imported" else status elif source_path is not None and source_path.exists() and request.run_indexing: _emit_import_progress(progress, "Индексация структуры", stage="indexing") snapshot = _index_and_store(source_path, project_id, structure_only=request.structure_only) _emit_import_progress(progress, "Нормализация объектов 1С", stage="normalizing") normalized = _normalize_and_store_import(project_id, source_path) status = "structure_indexed" if request.structure_only else "indexed" elif request.run_indexing: if source_path is not None and not source_path.exists(): errors.append(f"Path not found, mock import used: {request.path}") mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata) _emit_import_progress(progress, "Индексация mock-структуры", stage="indexing") snapshot = _index_and_store(mock_root, project_id, structure_only=False) normalized = normalized or _normalize_and_store_import(project_id, mock_root) if status == "mock_imported": status = "mock_indexed" summary = _import_summary_from_snapshot( project_id=project_id, source=request.source, status=status, snapshot=snapshot, errors=errors, metadata=safe_metadata, runtime_mode=runtime_mode, runtime_diagnostics=runtime_diagnostics, normalized=normalized, ) state = _project_setup.setdefault(project_id, {}) state["current_source"] = request.source.value state["last_import"] = summary.model_dump(mode="json") _append_import_history(project_id, summary) state["status"] = _setup_status_after_import(summary, request, snapshot).value _storage.write_document("project_settings", project_id, state) _emit_import_progress(progress, "Импорт завершен", stage="done") return summary @app.post("/projects/{project_id}/imports/{source}", response_model=ImportSummary) async def import_project_from_source(project_id: str, source: ImportSourceKind, request: ImportRequest | None = None) -> ImportSummary: payload = request or ImportRequest(source=source) payload.source = source return await import_project(project_id, payload) @app.post("/projects/{project_id}/imports/{source}/jobs", response_model=OperationJob) async def start_project_import_job( project_id: str, source: ImportSourceKind, request: ImportRequest | None = None, ) -> OperationJob: payload = request or ImportRequest(source=source) payload.source = source active_job = _active_server_import_job(project_id) if active_job is not None: return active_job job = OperationJob( job_id=f"server-import-{uuid4()}", kind="SERVER_IMPORT", status=OperationJobStatus.QUEUED, payload={ "project_id": project_id, "source": source.value, "mode": payload.mode.value, "path": payload.path, "stage": "queued", "message": "Импорт поставлен в очередь.", "logs": ["Импорт поставлен в очередь."], "started_at": None, "finished_at": None, "bytes_copied": 0, "files_copied": 0, }, ) _persist_job(_operations.enqueue(job)) threading.Thread( target=_run_server_import_job, args=(job.job_id, project_id, payload), daemon=True, ).start() return _operations.jobs[job.job_id] @app.get("/operations/jobs/{job_id}", response_model=OperationJob) async def get_job(job_id: str) -> OperationJob: job = _operations.jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}") return job @app.post("/projects/{project_id}/imports/{source}/check", response_model=ImportCheckResponse) async def check_project_import( project_id: str, source: ImportSourceKind, request: ImportRequest | None = None, ) -> ImportCheckResponse: state = _project_setup.setdefault(project_id, {}) state["current_source"] = source.value state.setdefault("status", ProjectSetupStatus.IMPORT_REQUIRED.value) return _import_check_response(project_id, source, request or ImportRequest(source=source)) @app.post("/projects/{project_id}/reindex", response_model=ImportSummary) async def reindex_project(project_id: str) -> ImportSummary: state = _project_setup.get(project_id, {}) current_source = ImportSourceKind(state.get("current_source") or ImportSourceKind.XML_DUMP.value) last_import = state.get("last_import") or {} _prepare_project_full_replace(project_id) source_path = Path(last_import.get("source_path", "")) if last_import.get("source_path") else None if source_path is not None and source_path.exists(): snapshot = _index_and_store(source_path, project_id, structure_only=False) normalized = _normalize_and_store_import(project_id, source_path) else: mock_root = _create_mock_import_fixture(project_id, current_source, {}) snapshot = _index_and_store(mock_root, project_id, structure_only=False) normalized = _normalize_and_store_import(project_id, mock_root) summary = _import_summary_from_snapshot( project_id=project_id, source=current_source, status="reindexed", snapshot=snapshot, errors=[], metadata={}, runtime_mode=os.environ.get("RUNTIME_ADAPTER_MODE", "mock"), runtime_diagnostics=[], normalized=normalized, ) state = _project_setup.setdefault(project_id, {}) state["current_source"] = current_source.value state["last_import"] = summary.model_dump(mode="json") _append_import_history(project_id, summary) state["status"] = ProjectSetupStatus.INDEXED.value return summary @app.get("/projects/{project_id}/imports/history", response_model=list[ImportSummary]) async def import_history(project_id: str) -> list[ImportSummary]: state = _project_setup.get(project_id, {}) history = state.get("import_history") if history is None: try: history = _storage.read_document("project_import_history", project_id).get("items", []) except FileNotFoundError: history = [] return [ImportSummary.model_validate(item) for item in history] @app.post("/projects/{project_id}/imports/{source}/agent-jobs", response_model=AgentImportJob) async def create_agent_import_job( project_id: str, source: ImportSourceKind, request: AgentImportJobRequest, ) -> AgentImportJob: if request.source != source: raise HTTPException(status_code=400, detail="Request source must match path source.") agent_id = request.agent_id.strip() if not agent_id: raise HTTPException(status_code=400, detail="agent_id is required.") state = _project_setup.get(project_id) if state: settings = ProjectSettingsRequest.model_validate(state.get("settings", {})) configured_agent_id = _agent_id_for_source(settings, source) if configured_agent_id and configured_agent_id != agent_id: raise HTTPException( status_code=409, detail=f"{source.value} is configured to run on Windows Agent {configured_agent_id}, but request uses {agent_id}. Save settings and select the configured agent.", ) active_same_source = [ job for job in _agent_import_jobs.values() if job.project_id == project_id and job.source == source and job.status in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING} ] if active_same_source: active = max(active_same_source, key=lambda item: item.updated_at) raise HTTPException( status_code=409, detail=f"{source.value} import is already running for project {project_id} on Windows Agent {active.agent_id}. Wait for job {active.job_id} to finish before starting a new one.", ) 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} is offline. Select an online agent before import.") if source in { ImportSourceKind.XML_DUMP, ImportSourceKind.EDT_PROJECT, ImportSourceKind.ARCHIVE_DUMP, ImportSourceKind.FILE_TREE, } and not (request.local_path or "").strip(): raise HTTPException(status_code=400, detail="local_path is required for selected source.") if source in {ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE}: has_agent_export_connection = bool(str(request.metadata.get("one_c_server") or "").strip()) and bool( str(request.metadata.get("one_c_infobase") or "").strip() ) if not (request.local_path or "").strip() and not has_agent_export_connection: raise HTTPException( status_code=400, detail="local_path or 1C server/infobase settings are required for CF/CFE export.", ) if source == ImportSourceKind.LIVE_INFOBASE and not (request.infobase or "").strip(): raise HTTPException(status_code=400, detail="infobase is required for LIVE_INFOBASE.") from uuid import uuid4 now = _current_timestamp() job = AgentImportJob( job_id=f"agent-import-{uuid4()}", project_id=project_id, agent_id=agent_id, source=source, mode=request.mode, local_path=request.local_path, bin_path=request.bin_path, infobase=request.infobase, credentials_ref=request.credentials_ref, metadata=request.metadata, created_at=now, updated_at=now, logs=[ f"Queued {source.value} import for agent {agent_id}.", "Windows agent resolves local_path on its own machine and returns server_path after export/upload.", ], ) _agent_import_jobs[job.job_id] = job return _persist_agent_import_job(job) @app.get("/projects/{project_id}/imports/agent-jobs", response_model=list[AgentImportJob]) async def list_project_agent_import_jobs(project_id: str) -> list[AgentImportJob]: return [ job for job in sorted(_agent_import_jobs.values(), key=lambda item: item.created_at, reverse=True) if job.project_id == project_id ] @app.get("/agent/jobs/next", response_model=AgentImportJob | None) async def claim_next_agent_import_job(agent_id: str, version: str | None = None) -> AgentImportJob | None: current_version = _current_windows_agent_version() if current_version and version is not None and version != current_version: return None for job in sorted(_agent_import_jobs.values(), key=lambda item: item.created_at): if job.agent_id != agent_id or job.status != AgentImportJobStatus.QUEUED: continue now = _current_timestamp() job.status = AgentImportJobStatus.RUNNING job.claimed_at = now job.updated_at = now job.logs.append(f"Claimed by agent {agent_id}.") return _persist_agent_import_job(job) return None @app.post("/agent/browse-requests", response_model=AgentBrowseRequest) async def create_agent_browse_request(request: AgentBrowseRequestCreate) -> AgentBrowseRequest: agent_id = request.agent_id.strip() if not agent_id: raise HTTPException(status_code=400, detail="agent_id is required.") from uuid import uuid4 now = _current_timestamp() browse_request = AgentBrowseRequest( request_id=f"agent-browse-{uuid4()}", agent_id=agent_id, path=request.path, created_at=now, updated_at=now, ) _agent_browse_requests[browse_request.request_id] = browse_request return _persist_agent_browse_request(browse_request) @app.get("/agent/browse-requests/{request_id}", response_model=AgentBrowseRequest) async def get_agent_browse_request(request_id: str) -> AgentBrowseRequest: browse_request = _agent_browse_requests.get(request_id) if browse_request is None: raise HTTPException(status_code=404, detail=f"Unknown agent browse request: {request_id}") return browse_request @app.get("/agent/browse/next", response_model=AgentBrowseRequest | None) async def claim_next_agent_browse_request(agent_id: str) -> AgentBrowseRequest | None: for browse_request in sorted(_agent_browse_requests.values(), key=lambda item: item.created_at): if browse_request.agent_id != agent_id or browse_request.status != AgentBrowseRequestStatus.QUEUED: continue now = _current_timestamp() browse_request.status = AgentBrowseRequestStatus.RUNNING browse_request.claimed_at = now browse_request.updated_at = now return _persist_agent_browse_request(browse_request) return None @app.post("/agent/browse/{request_id}/result", response_model=AgentBrowseRequest) async def complete_agent_browse_request(request_id: str, request: AgentBrowseResult) -> AgentBrowseRequest: browse_request = _agent_browse_requests.get(request_id) if browse_request is None: raise HTTPException(status_code=404, detail=f"Unknown agent browse request: {request_id}") browse_request.status = request.status browse_request.entries = request.entries browse_request.parent_path = request.parent_path browse_request.error = request.error browse_request.completed_at = _current_timestamp() browse_request.updated_at = browse_request.completed_at return _persist_agent_browse_request(browse_request) @app.post("/agent/heartbeat", response_model=AgentStatus) async def register_agent_heartbeat(request: AgentHeartbeatRequest) -> AgentStatus: agent_id = request.agent_id.strip() if not agent_id: raise HTTPException(status_code=400, detail="agent_id is required.") status = AgentStatus( agent_id=agent_id, status="online", last_seen_at=_current_timestamp(), host=request.host, user=request.user, version=request.version, started_at=request.started_at, network_roots=request.network_roots, platform_bins=request.platform_bins, ) return _persist_agent_status(status) @app.get("/agent/config", response_model=AgentConfigResponse) async def get_agent_config(agent_id: str) -> AgentConfigResponse: normalized_agent_id = agent_id.strip() if not normalized_agent_id: raise HTTPException(status_code=400, detail="agent_id is required.") projects: list[AgentProjectConfig] = [] for project_id, state in sorted(_project_setup.items()): settings = ProjectSettingsRequest.model_validate(state.get("settings", {})) agent = settings.agent if isinstance(settings.agent, dict) else {} configured_agent_ids = { str(agent.get("agent_id") or "").strip(), str(agent.get("cf_agent_id") or "").strip(), str(agent.get("edt_agent_id") or "").strip(), } if normalized_agent_id not in configured_agent_ids: continue server_import = settings.server_import if isinstance(settings.server_import, dict) else {} projects.append( AgentProjectConfig( project_id=project_id, project_name=settings.name or project_id, one_c_bin=str(agent.get("one_c_bin") or "") or None, edt_path=str(agent.get("edt_path") or "") or None, default_local_path=str(server_import.get("path") or "") or None, network_roots=[str(item) for item in agent.get("network_roots", []) if str(item).strip()] if isinstance(agent.get("network_roots"), list) else [], ) ) return AgentConfigResponse(agent_id=normalized_agent_id, projects=projects) @app.get("/agent/windows/manifest") async def windows_agent_manifest(request: Request) -> dict: script = _windows_agent_script_path() public_api_origin = _public_api_origin(request) agent_version = _current_windows_agent_version() or _file_sha256(script)[:12] return { "version": agent_version, "script_hash": _file_sha256(script)[:12], "script_url": f"{public_api_origin}/agent/windows/sfera-windows-agent.ps1", "api_url": public_api_origin, "install_url": f"{_public_origin(request)}/api/sfera/agent/windows/install.ps1", "install_cmd_url": f"{_public_origin(request)}/api/sfera/agent/windows/install.cmd", } @app.get("/agent/windows/sfera-windows-agent.ps1", response_class=PlainTextResponse) async def download_windows_agent_script() -> PlainTextResponse: return PlainTextResponse( _windows_agent_script_path().read_text(encoding="utf-8"), headers={"Content-Disposition": 'attachment; filename="sfera-windows-agent.ps1"'}, ) def _current_windows_agent_version() -> str | None: script = _windows_agent_script_path() script_text = script.read_text(encoding="utf-8") version_match = re.search(r'^\s*\$AgentVersion\s*=\s*"([^"]+)"', script_text, re.MULTILINE) return version_match.group(1) if version_match else None @app.get("/agent/windows/install.ps1", response_class=PlainTextResponse) async def download_windows_agent_installer(request: Request, agent_id: str | None = None) -> PlainTextResponse: public_origin = _public_origin(request) public_api_origin = _public_api_origin(request) default_agent_id = agent_id or "sfera-$env:COMPUTERNAME" script = f"""param( [string]$AgentId = "{default_agent_id}", [string]$InstallDir = "C:\\ProgramData\\SFERA\\WindowsAgent", [string]$TaskName = "SferaWindowsAgent", [string]$WatchdogTaskName = "SferaWindowsAgentWatchdog", [string]$ServiceName = "SferaWindowsAgent" ) $ErrorActionPreference = "Stop" $ServerUrl = "{public_origin}" $ApiUrl = "{public_api_origin}" if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{ throw "Run this installer in PowerShell as Administrator." }} New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $logPath = Join-Path $InstallDir "install.log" Start-Transcript -Path $logPath -Append | Out-Null try {{ $agentScript = Join-Path $InstallDir "sfera-windows-agent.ps1" $watchdogScript = Join-Path $InstallDir "sfera-windows-agent-watchdog.ps1" $configPath = Join-Path $InstallDir "agent-config.json" Invoke-WebRequest -UseBasicParsing -Uri "$ApiUrl/agent/windows/sfera-windows-agent.ps1" -OutFile $agentScript $config = [ordered]@{{ server_url = $ServerUrl; api_url = $ApiUrl; agent_id = $AgentId; poll_seconds = 5; network_roots = @() }} $config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding UTF8 $arguments = @("-STA", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$agentScript`"", "-ConfigPath", "`"$configPath`"") $powerShellPath = Join-Path $env:SystemRoot "System32\\WindowsPowerShell\\v1.0\\powershell.exe" if (!(Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {{ $powerShellPath = Join-Path $PSHOME "powershell.exe" }} $watchdogContent = @" `$ErrorActionPreference = "SilentlyContinue" `$agentScript = "$agentScript" `$configPath = "$configPath" `$powerShellPath = "$powerShellPath" `$agentArgs = @("-STA", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"`$agentScript`"", "-ConfigPath", "`"`$configPath`"") `$running = Get-CimInstance Win32_Process | Where-Object {{ `$_.CommandLine -and `$_.CommandLine.Contains("sfera-windows-agent.ps1") -and `$_.CommandLine.Contains(`$configPath) }} if (-not `$running) {{ Start-Process -FilePath `$powerShellPath -ArgumentList `$agentArgs -WindowStyle Hidden Add-Content -LiteralPath "$logPath" -Value ("[{{0}}] Watchdog started SFERA Windows Agent." -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss")) }} "@ $watchdogContent | Set-Content -LiteralPath $watchdogScript -Encoding UTF8 $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existingTask) {{ Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false }} $existingWatchdogTask = Get-ScheduledTask -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue if ($existingWatchdogTask) {{ Stop-ScheduledTask -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $WatchdogTaskName -Confirm:$false }} $existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($existing) {{ sc.exe stop $ServiceName | Out-Null sc.exe delete $ServiceName | Out-Null Start-Sleep -Seconds 2 }} $action = New-ScheduledTaskAction -Execute $powerShellPath -Argument ($arguments -join " ") $trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME" $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Highest Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description "SFERA bridge between local 1C/EDT/network folders and SFERA server." -Force | Out-Null $watchdogArguments = @("-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$watchdogScript`"") $watchdogAction = New-ScheduledTaskAction -Execute $powerShellPath -Argument ($watchdogArguments -join " ") $watchdogLogonTrigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME" $watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $WatchdogTaskName -Action $watchdogAction -Trigger $watchdogLogonTrigger -Settings $watchdogSettings -Principal $principal -Description "Keeps SFERA Windows Agent running after reboot or crash." -Force | Out-Null $watchdogTaskCommand = "`"$powerShellPath`" $($watchdogArguments -join " ")" schtasks.exe /Create /TN $WatchdogTaskName /TR $watchdogTaskCommand /SC MINUTE /MO 1 /F /RL HIGHEST | Out-Null Start-ScheduledTask -TaskName $TaskName Start-ScheduledTask -TaskName $WatchdogTaskName Start-Sleep -Seconds 5 $agentTaskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue $watchdogTaskInfo = Get-ScheduledTaskInfo -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue $agentProcess = Get-CimInstance Win32_Process | Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains("sfera-windows-agent.ps1") -and $_.CommandLine.Contains($configPath) }} | Select-Object -First 1 if ($agentTaskInfo) {{ Write-Host "AgentTaskResult=$($agentTaskInfo.LastTaskResult)" }} if ($watchdogTaskInfo) {{ Write-Host "WatchdogTaskResult=$($watchdogTaskInfo.LastTaskResult)" }} if ($agentProcess) {{ Write-Host "AgentProcessId=$($agentProcess.ProcessId)" }} else {{ Write-Warning "Agent process is not running after install. Check $logPath and Windows Task Scheduler history." }} Write-Host "SFERA Windows Agent installed as Windows Scheduled Task." Write-Host "ServerUrl=$ServerUrl" Write-Host "ApiUrl=$ApiUrl" Write-Host "AgentId=$AgentId" Write-Host "Config=$configPath" Write-Host "TaskName=$TaskName" Write-Host "WatchdogTaskName=$WatchdogTaskName" Write-Host "Log=$logPath" Write-Host "Open SFERA and wait 5-10 seconds for agent heartbeat." }} catch {{ Write-Error $_ throw }} finally {{ Stop-Transcript | Out-Null }} """ return PlainTextResponse( script, headers={"Content-Disposition": 'attachment; filename="install-sfera-windows-agent.ps1"'}, ) @app.get("/agent/windows/install.cmd", response_class=PlainTextResponse) async def download_windows_agent_cmd_installer(request: Request, agent_id: str | None = None) -> PlainTextResponse: public_origin = _public_origin(request) installer_url = f"{public_origin}/api/sfera/agent/windows/install.ps1" if agent_id: installer_url = f"{installer_url}?agent_id={quote(agent_id, safe='')}" escaped_installer_url = installer_url.replace("%", "%%") script = fr"""@echo off setlocal set "INSTALLER_URL={escaped_installer_url}" set "INSTALLER=%TEMP%\install-sfera-windows-agent.ps1" echo Downloading SFERA Windows Agent installer... powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing -Uri $env:INSTALLER_URL -OutFile $env:INSTALLER" if errorlevel 1 ( echo Failed to download installer. pause exit /b 1 ) echo Starting installer with administrator rights... powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -Verb RunAs -Wait -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$env:INSTALLER)" if errorlevel 1 ( echo Failed to start elevated installer. pause exit /b 1 ) echo Installer finished. echo. echo Installed files and last log: powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "if (Test-Path 'C:\ProgramData\SFERA\WindowsAgent') {{ Get-ScheduledTask -TaskName SferaWindowsAgent,SferaWindowsAgentWatchdog -ErrorAction SilentlyContinue | Select-Object TaskName,State | Format-Table -AutoSize; Get-ScheduledTaskInfo -TaskName SferaWindowsAgent -ErrorAction SilentlyContinue | Select-Object LastRunTime,LastTaskResult | Format-List; Get-Content 'C:\ProgramData\SFERA\WindowsAgent\install.log' -Tail 40 -ErrorAction SilentlyContinue }}" echo. pause """ return PlainTextResponse( script, media_type="application/octet-stream", headers={"Content-Disposition": 'attachment; filename="install-sfera-windows-agent.cmd"'}, ) @app.get("/agent/statuses", response_model=list[AgentStatus]) async def list_agent_statuses() -> list[AgentStatus]: statuses = [_agent_status_with_liveness(status.model_copy(deep=True)) for status in _agent_statuses.values()] return sorted(statuses, key=lambda status: status.last_seen_at or "", reverse=True) @app.get("/agent/status/{agent_id}", response_model=AgentStatus) async def get_agent_status(agent_id: str) -> AgentStatus: status = _agent_statuses.get(agent_id) if status is None: return AgentStatus(agent_id=agent_id, status="offline") return _agent_status_with_liveness(status) @app.get("/server/browse", response_model=ServerBrowseResponse) async def browse_server_folders(path: str | None = None) -> ServerBrowseResponse: target = Path(path or os.environ.get("SFERA_SERVER_BROWSE_ROOT", "/")) try: resolved = target.expanduser().resolve() if not resolved.exists(): return ServerBrowseResponse(path=str(target), error=f"Папка не найдена: {target}") if not resolved.is_dir(): return ServerBrowseResponse(path=str(resolved), error=f"Это не папка: {resolved}") entries = [ AgentFolderEntry(name=item.name, path=item.as_posix(), is_directory=True) for item in sorted(resolved.iterdir(), key=lambda item: item.name.lower()) if item.is_dir() ][:200] parent = resolved.parent.as_posix() if resolved.parent != resolved else None return ServerBrowseResponse(path=resolved.as_posix(), parent_path=parent, entries=entries) except PermissionError: return ServerBrowseResponse(path=str(target), error=f"Нет доступа к папке: {target}") except OSError as error: return ServerBrowseResponse(path=str(target), error=str(error)) @app.post("/server/smb/browse", response_model=ServerBrowseResponse) async def browse_server_smb_folders(request: ServerSmbBrowseRequest) -> ServerBrowseResponse: path = request.path.strip() if not path: return ServerBrowseResponse(path="", error="Укажите UNC путь к сетевой папке.") if not path.startswith("\\\\"): return ServerBrowseResponse(path=path, error="SMB-доступ ожидает UNC путь вида \\\\server\\share\\folder.") try: return _browse_smb_folders( path=path, username=(request.username or "").strip() or None, password=request.password or None, domain=(request.domain or "").strip() or None, ) except Exception as error: return ServerBrowseResponse(path=path, error=f"SMB доступ не выполнен: {error}") @app.post("/agent/jobs/{job_id}/logs", response_model=AgentImportJob) async def append_agent_import_job_logs(job_id: str, request: AgentImportJobLogRequest) -> AgentImportJob: job = _agent_import_jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}") _touch_agent_activity(job.agent_id) if request.status is not None: job.status = request.status job.logs.extend(request.logs) job.updated_at = _current_timestamp() return _persist_agent_import_job(job) @app.post("/agent/jobs/{job_id}/upload", response_model=AgentImportJob) async def upload_agent_import_job_payload(job_id: str, request: Request, filename: str = "payload.zip") -> AgentImportJob: job = _agent_import_jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}") _touch_agent_activity(job.agent_id) upload_root = _storage.root / "agent_uploads" / _safe_storage_name(job_id) import_root = _storage.root / "agent_imports" / _safe_storage_name(job_id) upload_root.mkdir(parents=True, exist_ok=True) if import_root.exists(): shutil.rmtree(import_root) import_root.mkdir(parents=True, exist_ok=True) upload_path = upload_root / _safe_storage_name(filename) size = 0 last_touch = time.monotonic() with upload_path.open("wb") as file: async for chunk in request.stream(): if not chunk: continue size += len(chunk) file.write(chunk) if time.monotonic() - last_touch >= 30: _touch_agent_activity(job.agent_id) last_touch = time.monotonic() if size == 0: try: upload_path.unlink() except FileNotFoundError: pass raise HTTPException(status_code=400, detail="Upload payload is empty.") if zipfile.is_zipfile(upload_path): job.server_path = import_root.as_posix() job.status = AgentImportJobStatus.RUNNING job.error = None job.metadata = { **job.metadata, "upload_path": upload_path.as_posix(), "upload_size": size, "upload_processing": True, } job.logs.append(f"Uploaded {filename} ({size} bytes). Server extraction queued.") job.updated_at = _current_timestamp() _persist_agent_import_job(job) threading.Thread( target=_run_agent_uploaded_zip_job, args=(job.job_id, upload_path.as_posix(), import_root.as_posix(), filename, size), daemon=True, ).start() else: target_path = import_root / upload_path.name shutil.copyfile(upload_path, target_path) job.server_path = target_path.as_posix() job.logs.append(f"Uploaded {filename} ({size} bytes) to {job.server_path}.") job.updated_at = _current_timestamp() _touch_agent_activity(job.agent_id) return _persist_agent_import_job(job) @app.post("/agent/jobs/{job_id}/result", response_model=AgentImportJob) async def complete_agent_import_job(job_id: str, request: AgentImportJobResult) -> AgentImportJob: job = _agent_import_jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}") _touch_agent_activity(job.agent_id) job.metadata = {**job.metadata, **request.metadata} if job.metadata.get("agent_action") == "INSTALL_SFERA_EXTENSION": job.status = request.status job.error = request.error job.logs.extend(request.logs) job.updated_at = _current_timestamp() if request.status in {AgentImportJobStatus.SUCCEEDED, AgentImportJobStatus.FAILED, AgentImportJobStatus.CANCELLED}: job.completed_at = job.updated_at if request.status == AgentImportJobStatus.SUCCEEDED: job.logs.append("SFERA extension install/update service job completed.") return _persist_agent_import_job(job) if ( request.status == AgentImportJobStatus.FAILED and job.import_summary is None and str(request.error or "").strip() ): timeout_markers = ("Время ожидания операции истекло", "timed out", "timeout") if any(marker.lower() in str(request.error).lower() for marker in timeout_markers) and ( job.server_path or job.metadata.get("upload_processing") or job.metadata.get("upload_path") ): job.logs.extend(request.logs) job.logs.append("Agent reported timeout after upload; server import will continue in background.") job.error = None job.updated_at = _current_timestamp() upload_path_raw = str(job.metadata.get("upload_path") or "") if upload_path_raw and not job.server_path: job.status = AgentImportJobStatus.RUNNING job.completed_at = None job.metadata = {**job.metadata, "upload_processing": True} _persist_agent_import_job(job) upload_path = Path(upload_path_raw) import_root = _storage.root / "agent_imports" / _safe_storage_name(job.job_id) upload_size = int(job.metadata.get("upload_size") or 0) threading.Thread( target=_run_agent_uploaded_zip_job, args=(job.job_id, upload_path.as_posix(), import_root.as_posix(), upload_path.name, upload_size), daemon=True, ).start() return job if job.metadata.get("upload_processing"): job.status = AgentImportJobStatus.RUNNING job.completed_at = None return _persist_agent_import_job(job) import_request = ImportRequest( source=job.source, path=job.server_path, credentials_ref=job.credentials_ref, metadata={ **job.metadata, "agent_id": job.agent_id, "agent_local_path": job.local_path, "agent_bin_path": job.bin_path, "agent_infobase": job.infobase, }, structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP}, mode=job.mode, ) operation_job = _create_agent_server_import_job(job, import_request) job.status = AgentImportJobStatus.RUNNING job.completed_at = None job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id} job.logs.append(f"Server import queued as {operation_job.job_id}.") _persist_agent_import_job(job) threading.Thread( target=_run_agent_server_import_job, args=(job.job_id, operation_job.job_id, job.project_id, import_request), daemon=True, ).start() return job if request.status == AgentImportJobStatus.SUCCEEDED and job.metadata.get("upload_processing"): job.status = AgentImportJobStatus.RUNNING job.error = None job.logs.extend(request.logs) job.logs.append("Agent upload completed; server extraction/import is running in background.") job.updated_at = _current_timestamp() job.completed_at = None return _persist_agent_import_job(job) job.status = request.status job.server_path = request.server_path or job.server_path job.error = request.error job.logs.extend(request.logs) job.updated_at = _current_timestamp() if request.status == AgentImportJobStatus.SUCCEEDED: if not job.server_path: job.status = AgentImportJobStatus.FAILED job.error = "Agent result requires server_path or prior upload." job.completed_at = job.updated_at else: import_request = ImportRequest( source=job.source, path=job.server_path, credentials_ref=job.credentials_ref, metadata={ **job.metadata, "agent_id": job.agent_id, "agent_local_path": job.local_path, "agent_bin_path": job.bin_path, "agent_infobase": job.infobase, }, structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP}, mode=job.mode, ) operation_job = _create_agent_server_import_job(job, import_request) job.status = AgentImportJobStatus.RUNNING job.error = None job.completed_at = None job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id} job.logs.append(f"Server import queued as {operation_job.job_id}.") _persist_agent_import_job(job) threading.Thread( target=_run_agent_server_import_job, args=(job.job_id, operation_job.job_id, job.project_id, import_request), daemon=True, ).start() return job else: job.completed_at = job.updated_at return _persist_agent_import_job(job) @app.get("/projects/{project_id}/normalized", response_model=NormalizedProject) async def get_normalized_project(project_id: str) -> NormalizedProject: normalized = _load_normalized_project(project_id) if normalized is None: raise HTTPException(status_code=404, detail="NormalizedProject not found") return normalized @app.get("/projects/{project_id}/normalized/summary", response_model=NormalizedProjectSummary) async def get_normalized_project_summary(project_id: str) -> NormalizedProjectSummary: normalized = _load_normalized_project(project_id) if normalized is None: raise HTTPException(status_code=404, detail="NormalizedProject not found") return _normalized_project_summary(normalized) @app.get("/projects/{project_id}/imports/quality", response_model=ImportQualityResponse) async def get_import_quality(project_id: str) -> ImportQualityResponse: return _import_quality_response(project_id) @app.get("/projects/{project_id}/normalized/object", response_model=NormalizedObjectDetail) async def get_normalized_object(project_id: str, qualified_name: str) -> NormalizedObjectDetail: normalized = _load_normalized_project(project_id) if normalized is None: raise HTTPException(status_code=404, detail="NormalizedProject not found") detail = _normalized_object_detail(normalized, qualified_name) if detail is None: raise HTTPException(status_code=404, detail=f"Normalized object not found: {qualified_name}") return detail @app.get("/projects/{project_id}/normalized/object/modules", response_model=list[ModuleSourceResponse]) async def get_normalized_object_modules(project_id: str, qualified_name: str) -> list[ModuleSourceResponse]: normalized = _load_normalized_project(project_id) if normalized is not None: modules = _normalized_module_sources_for_object(normalized, qualified_name) if modules: return modules snapshot = _project_snapshot_or_404(project_id) return _module_sources_for_object(snapshot, qualified_name) @app.get("/projects/{project_id}/bsl/completions", response_model=list[BslCompletionItemResponse]) async def get_bsl_completions( project_id: str, receiver: str | None = None, q: str = "", qualified_name: str | None = None, limit: int = 80, ) -> list[BslCompletionItemResponse]: normalized = _load_normalized_project(project_id) normalized_limit = min(max(1, limit), 250) suggestions: list[BslCompletionItemResponse] = [] if normalized is not None: suggestions.extend(_normalized_bsl_completion_items(normalized, receiver, qualified_name)) if not suggestions: snapshot = _project_snapshot_or_404(project_id) suggestions.extend(_snapshot_bsl_completion_items(snapshot, receiver)) normalized_query = q.strip().casefold() if normalized_query: suggestions = [item for item in suggestions if normalized_query in item.label.casefold()] unique: dict[str, BslCompletionItemResponse] = {} for item in suggestions: key = f"{item.kind}:{item.label}".casefold() unique.setdefault(key, item) return sorted(unique.values(), key=lambda item: (item.kind, item.label.casefold()))[:normalized_limit] @app.get("/metadata/catalog", response_model=MetadataCatalogResponse) async def metadata_catalog() -> MetadataCatalogResponse: return MetadataCatalogResponse( platform_family="1C:Enterprise 8.3 baseline, 8.5-compatible catalog shell", source="docs/1c-metadata-structure.md", common_branch_children=list(COMMON_BRANCH_CHILDREN), types=[ MetadataTypeSpecResponse( code=spec.code, russian_name=spec.russian_name, tree_branch=spec.tree_branch, icon=spec.icon, description=METADATA_TYPE_DESCRIPTIONS.get(spec.code, ""), documentation_url=METADATA_TYPE_DOCUMENTATION_URLS.get(spec.code, ""), child_groups=list(spec.child_groups), module_kinds=list(spec.module_kinds), properties=list(METADATA_TYPE_PROPERTIES.get(spec.code, spec.properties)), context_actions=list(METADATA_TYPE_CONTEXT_ACTIONS.get(spec.code, spec.context_actions)), ) for spec in METADATA_TYPE_SPECS ], child_object_types=[ MetadataChildObjectSpecResponse( code=spec.code, russian_name=spec.russian_name, parent_groups=list(spec.parent_groups), description=spec.description, documentation_url=spec.documentation_url, ) for spec in METADATA_CHILD_OBJECT_SPECS ], ) @app.get("/projects/{project_id}/metadata/tree", response_model=ProjectMetadataTreeResponse) async def project_metadata_tree(project_id: str, object_limit_per_branch: int = 200) -> ProjectMetadataTreeResponse: snapshot = _project_snapshot_or_404(project_id) normalized = _load_normalized_project(project_id) root = ( _project_metadata_tree_response_from_normalized(normalized, object_limit_per_branch=max(0, object_limit_per_branch)) if normalized is not None else _project_metadata_tree_response(snapshot, object_limit_per_branch=max(0, object_limit_per_branch)) ) return ProjectMetadataTreeResponse( project_id=project_id, root=root, ) @app.get("/projects/{project_id}/metadata/tree/children", response_model=MetadataTreeChildrenResponse) async def project_metadata_tree_children( project_id: str, node_id: str, offset: int = 0, limit: int = 50, ) -> MetadataTreeChildrenResponse: snapshot = _project_snapshot_or_404(project_id) normalized = _load_normalized_project(project_id) normalized_offset = max(0, offset) normalized_limit = min(max(1, limit), 250) normalized_children = ( _normalized_metadata_tree_children_for_node( normalized, node_id=node_id, offset=normalized_offset, limit=normalized_limit, ) if normalized is not None else None ) if normalized_children is None: children, total = _metadata_tree_children_for_node( snapshot, node_id=node_id, offset=normalized_offset, limit=normalized_limit, ) else: children, total = normalized_children return MetadataTreeChildrenResponse( project_id=project_id, parent_id=node_id, offset=normalized_offset, limit=normalized_limit, total=total, has_more=normalized_offset + len(children) < total, children=children, ) @app.get("/projects/{project_id}/metadata/tree/search", response_model=MetadataTreeSearchResponse) async def project_metadata_tree_search(project_id: str, q: str, limit: int = 80) -> MetadataTreeSearchResponse: snapshot = _project_snapshot_or_404(project_id) normalized_query = q.strip().casefold() normalized_limit = min(max(1, limit), 250) if len(normalized_query) < 2: return MetadataTreeSearchResponse(project_id=project_id, q=q, total=0, results=[]) matches = [ node for node in snapshot.nodes if _is_metadata_tree_search_node(node) and ( normalized_query in node.name.casefold() or normalized_query in node.qualified_name.casefold() ) ] matches.sort(key=lambda item: _metadata_search_rank(item, normalized_query)) page = matches[:normalized_limit] child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in page]) return MetadataTreeSearchResponse( project_id=project_id, q=q, total=len(matches), results=[_metadata_node_for_search_result(snapshot, node, child_count_index) for node in page], ) @app.get("/projects/{project_id}/metadata/tree/path", response_model=MetadataTreePathResponse) async def project_metadata_tree_path(project_id: str, node_id: str) -> MetadataTreePathResponse: snapshot = _project_snapshot_or_404(project_id) path = _metadata_tree_path_for_node(snapshot, node_id) if not path: raise HTTPException(status_code=404, detail=f"Metadata tree path not found: {node_id}") return MetadataTreePathResponse( project_id=project_id, node_id=node_id, path=path, steps=_metadata_tree_path_steps(snapshot, path), ) @app.get("/projects/{project_id}/flowchart", response_model=ProjectFlowchartResponse) async def project_flowchart( project_id: str, focus: str | None = None, depth: int = 1, limit: int = 80, ) -> ProjectFlowchartResponse: snapshot = _project_snapshot_or_404(project_id) normalized_depth = min(max(depth, 1), 3) normalized_limit = min(max(limit, 20), 240) if focus: focused = _flowchart_for_focus(snapshot, focus, normalized_depth, normalized_limit) if focused is not None: return focused return _flowchart_overview(snapshot, normalized_limit) @app.get("/runtime") async def runtime() -> dict[str, str]: return {"status": "configured", "adapter_url": os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010")} @app.get("/runtime/platform") async def runtime_platform() -> dict: url = os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010").rstrip("/") try: with urllib.request.urlopen(f"{url}/runtime/platform", timeout=5) as response: return json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock") return { "mode": mode, "platform_found": False, "execution_enabled": False, "capabilities": ["mock_normalized_project"] if mode == "mock" else [], "diagnostics": [f"Runtime adapter platform check unavailable: {error}"], } @app.get("/storage") async def storage() -> dict[str, str]: return {"status": "configured", "path": _storage.root.as_posix()} @app.post("/projects/index", response_model=IndexProjectResponse) async def index_project_endpoint(request: IndexProjectRequest) -> IndexProjectResponse: source_path = Path(request.path) if not source_path.exists(): raise HTTPException(status_code=404, detail=f"Path not found: {request.path}") snapshot = _index_and_store(source_path, request.project_id, structure_only=request.structure_only) return IndexProjectResponse(snapshot=_summary(snapshot)) @app.post("/projects/demo/index", response_model=IndexProjectResponse) async def index_demo_project() -> IndexProjectResponse: demo_path = Path("examples/bsl") if not demo_path.exists(): raise HTTPException(status_code=404, detail="Demo project not found") snapshot = _index_and_store(demo_path, "demo") return IndexProjectResponse(snapshot=_summary(snapshot)) @app.post("/projects/{project_id}/load", response_model=IndexProjectResponse) async def load_project(project_id: str) -> IndexProjectResponse: try: snapshot = _load_snapshot_into_memory(project_id) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=f"Stored snapshot not found: {project_id}") from error return IndexProjectResponse(snapshot=_summary(snapshot)) @app.post("/projects/{project_id}/incremental/file", response_model=IncrementalFileResponse) async def incremental_file(project_id: str, request: IncrementalFileRequest) -> IncrementalFileResponse: previous = _snapshots.get(project_id) if previous is None: try: previous = _storage.load_snapshot(project_id) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error changed_file = Path(request.path) if not changed_file.exists(): raise HTTPException(status_code=404, detail=f"Path not found: {request.path}") snapshot, delta = rebuild_changed_file(previous, changed_file) graph = InMemoryProjection() graph.project_snapshot(snapshot) _snapshots[project_id] = snapshot _graphs[project_id] = graph _storage.save_snapshot(snapshot) _append_and_persist_versions(snapshot) neo4j_projected = False neo4j_error: str | None = None if project_id in _neo4j_projected_projects: try: await _apply_delta_to_neo4j(project_id, delta) neo4j_projected = True except HTTPException as error: neo4j_error = str(error.detail) return IncrementalFileResponse( snapshot=_summary(snapshot), added_nodes=len(delta.added_nodes), updated_nodes=len(delta.updated_nodes), removed_nodes=len(delta.removed_nodes), added_edges=len(delta.added_edges), removed_edges=len(delta.removed_edges), neo4j_projected=neo4j_projected, neo4j_error=neo4j_error, ) @app.get("/storage/snapshots", response_model=list[StoredSnapshotInfo]) async def list_stored_snapshots() -> list[StoredSnapshotInfo]: return _storage.list_snapshot_refs() def _project_snapshot_or_404(project_id: str) -> SirSnapshot: snapshot = _snapshots.get(project_id) if snapshot is not None: return snapshot try: return _load_snapshot_into_memory(project_id) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error @app.get("/projects/{project_id}/snapshot", response_model=SnapshotSummary) async def get_snapshot(project_id: str) -> SnapshotSummary: return _summary(_project_snapshot_or_404(project_id)) @app.get("/projects/{project_id}/snapshot/export") async def export_snapshot(project_id: str) -> dict: return _project_snapshot_or_404(project_id).model_dump(mode="json") @app.get("/projects/{project_id}/versions", response_model=list[ObjectVersionSummary]) async def project_versions(project_id: str) -> list[ObjectVersionSummary]: snapshot = _project_snapshot_or_404(project_id) lineages = {node.lineage_id for node in snapshot.nodes} return [ _version_summary(version) for version in _versions.all_versions() if version.lineage_id in lineages ] @app.get("/versions/{lineage_id}", response_model=list[SemanticObjectVersion]) async def lineage_versions(lineage_id: str) -> list[SemanticObjectVersion]: return _versions.history(lineage_id) @app.get("/versions/{lineage_id}/diff", response_model=SemanticObjectDiff) async def lineage_version_diff( lineage_id: str, from_version_id: str, to_version_id: str, ) -> SemanticObjectDiff: before = _versions.find_version(from_version_id) after = _versions.find_version(to_version_id) if before is None: raise HTTPException(status_code=404, detail=f"Version not found: {from_version_id}") if after is None: raise HTTPException(status_code=404, detail=f"Version not found: {to_version_id}") if before.lineage_id != lineage_id or after.lineage_id != lineage_id: raise HTTPException(status_code=409, detail="Version lineage does not match requested lineage_id") return diff_versions(before, after) @app.get("/projects/{project_id}/impact/{routine_name}", response_model=ImpactResponse) async def get_impact(project_id: str, routine_name: str) -> ImpactResponse: graph = _graphs.get(project_id) if graph is None: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") impact = routine_impact(graph, routine_name) return ImpactResponse( routine_name=impact.routine_name, callers=[_named_node(node) for node in impact.callers], callees=[_named_node(node) for node in impact.callees], query_tables=[_named_node(node) for node in impact.query_tables], writes=[_named_node(node) for node in impact.writes], ) @app.get("/projects/{project_id}/objects/impact/{object_name}", response_model=ObjectImpactResponse) async def get_object_impact(project_id: str, object_name: str) -> ObjectImpactResponse: graph = _graphs.get(project_id) if graph is None: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") impact = object_impact(graph, object_name) if impact is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectImpactResponse( object_name=impact.object_name, object=_named_node(impact.object), modules=[_named_node(node) for node in impact.modules], routines=[_named_node(node) for node in impact.routines], forms=[_named_node(node) for node in impact.forms], commands=[_named_node(node) for node in impact.commands], attributes=[_named_node(node) for node in impact.attributes], tabular_sections=[_named_node(node) for node in impact.tabular_sections], tabular_section_columns={ section_lineage: [_named_node(node) for node in columns] for section_lineage, columns in impact.tabular_section_columns.items() }, roles=[_named_node(node) for node in impact.roles], role_access=[ RoleAccessResponse(role=_named_node(grant.role), permissions=grant.permissions) for grant in impact.role_access ], jobs=[_named_node(node) for node in impact.jobs], callees=[_named_node(node) for node in impact.callees], query_tables=[_named_node(node) for node in impact.query_tables], writes=[_named_node(node) for node in impact.writes], ) @app.get("/projects/{project_id}/objects/attributes/{object_name}", response_model=SearchResponse) async def get_object_attributes(project_id: str, object_name: str) -> SearchResponse: return _object_children_response(project_id, object_name, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) @app.get("/projects/{project_id}/objects/schema/{object_name}", response_model=ObjectSchemaResponse) async def get_object_schema(project_id: str, object_name: str) -> ObjectSchemaResponse: _snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return _object_schema_response(graph, object_node) @app.get("/projects/{project_id}/objects/tabular-sections/{object_name}", response_model=SearchResponse) async def get_object_tabular_sections(project_id: str, object_name: str) -> SearchResponse: return _object_children_response(project_id, object_name, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION) @app.get( "/projects/{project_id}/objects/tabular-sections/{object_name}/columns", response_model=list[TabularSectionColumnsResponse], ) async def get_object_tabular_section_columns(project_id: str, object_name: str) -> list[TabularSectionColumnsResponse]: _snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION) return [ TabularSectionColumnsResponse( tabular_section=_named_node(section), columns=[ _named_node(column) for column in _graph_children(graph, section, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) ], ) for section in sections ] def _object_schema_response(graph: InMemoryProjection, object_node) -> ObjectSchemaResponse: attributes = _graph_children(graph, object_node, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION) return ObjectSchemaResponse( object=_named_node(object_node), attributes=[_named_node(attribute) for attribute in attributes], tabular_sections=[ TabularSectionColumnsResponse( tabular_section=_named_node(section), columns=[ _named_node(column) for column in _graph_children(graph, section, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) ], ) for section in sections ], ) def _object_children_response( project_id: str, object_name: str, edge_kind: EdgeKind, child_kind: NodeKind, ) -> SearchResponse: _snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") children = _graph_children(graph, object_node, edge_kind, child_kind) return SearchResponse( results=[_named_node(node) for node in children] ) def _graph_children( graph: InMemoryProjection, source, edge_kind: EdgeKind, child_kind: NodeKind, ): children = [ graph.nodes[edge.target_lineage] for edge in graph.edges.values() if edge.kind == edge_kind and edge.source_lineage == source.lineage_id and edge.target_lineage in graph.nodes and graph.nodes[edge.target_lineage].kind == child_kind ] return sorted(children, key=lambda item: item.qualified_name) def _authoring_context(project_id: str, request: AuthoringContextRequest) -> AuthoringContextResponse: snapshot, graph = _snapshot_and_graph(project_id) object_node = ( _find_graph_node(graph, request.object_name, _ACCESS_TARGET_KINDS) if request.object_name else _first_node(snapshot, _ACCESS_TARGET_KINDS) ) routine_node = _find_routine_node(snapshot, request.routine_name, request.cursor_line) source_text = request.source_text or _source_text_for_node(routine_node) local_variables = _extract_local_variables(source_text, request.cursor_line) parameters = _extract_parameters(source_text, routine_node.name if routine_node else request.routine_name) object_attributes = _graph_children(graph, object_node, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) if object_node else [] tabular_sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION) if object_node else [] forms = _graph_children(graph, object_node, EdgeKind.HAS_FORM, NodeKind.FORM) if object_node else [] form_elements = [ element for form in forms for element in _graph_children(graph, form, EdgeKind.HAS_ELEMENT, NodeKind.FORM_ELEMENT) ] commands = _graph_children(graph, object_node, EdgeKind.HAS_COMMAND, NodeKind.COMMAND) if object_node else [] query_tables = _routine_query_tables(graph, routine_node) if routine_node else [] writes = _relation_targets(graph, routine_node, EdgeKind.WRITES) if routine_node else [] privacy_markers = [ marker for marker in _privacy.markers_for_project(project_id) if marker.target_id in { *(node.lineage_id for node in object_attributes), *(node.lineage_id for node in tabular_sections), *((object_node.lineage_id,) if object_node else ()), } ] review_findings = [ finding for finding in get_review_payload(snapshot) if not object_node or object_node.qualified_name in str(finding.get("message", "")) ][:8] return AuthoringContextResponse( project_id=project_id, object=_named_node(object_node) if object_node else None, routine=_named_node(routine_node) if routine_node else None, local_variables=local_variables, parameters=parameters, object_attributes=[_named_node(node) for node in object_attributes], tabular_sections=[_named_node(node) for node in tabular_sections], form_elements=[_named_node(node) for node in form_elements], commands=[_named_node(node) for node in commands], query_tables=[_named_node(node) for node in query_tables], writes=[_named_node(node) for node in writes], available_methods=_available_1c_methods(object_node, routine_node), privacy_markers=privacy_markers, review_findings=review_findings, ) def _authoring_insert_text( context: AuthoringContextResponse, request: AuthoringCompletionPreviewRequest, ) -> str: if request.intent == "register-write" and context.writes: register = context.writes[0].name return f" Движения.{register}.Записать();" if request.intent == "fill-check" and context.object_attributes: attribute = context.object_attributes[0].name return "\n".join( [ f" Если Не ЗначениеЗаполнено({attribute}) Тогда", f" ВызватьИсключение \"Не заполнен реквизит {attribute}\";", " КонецЕсли;", ] ) return "\n".join( [ " Если Отказ Тогда", " Возврат;", " КонецЕсли;", ] ) def _authoring_guard_checks( project_id: str, context: AuthoringContextResponse, request: AuthoringCompletionPreviewRequest, ) -> list[AuthoringGuardCheck]: used_tokens = _operations.summarize_ai_usage(user_id=request.user_id).total_tokens remaining = ( None if _ai_policy.token_limit_per_day is None else max(_ai_policy.token_limit_per_day - used_tokens, 0) ) token_status = "OK" if remaining is None or request.estimated_tokens <= remaining else "BLOCKED" return [ AuthoringGuardCheck( name="preview", status="REQUIRED", message="Semantic diff must be reviewed before apply", ), AuthoringGuardCheck( name="impact", status="OK" if context.routine or context.object else "WARNING", message="Impact context is available" if context.routine or context.object else "No object or routine context selected", ), AuthoringGuardCheck( name="review", status="WARNING" if context.review_findings else "OK", message=f"{len(context.review_findings)} review findings in context", ), _authoring_privacy_check(context), AuthoringGuardCheck( name="ai-token-budget", status=token_status, message="AI token budget is available" if token_status == "OK" else "AI token limit would be exceeded", ), AuthoringGuardCheck( name="apply", status="BLOCKED", message="Apply API is intentionally disabled in preview mode", ), ] def _authoring_task_session_check( project_id: str, task_id: str | None, session_id: str | None, user_id: str | None = None, ) -> AuthoringGuardCheck: if not task_id: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message="Task id is required for workspace apply", ) task = _collaboration.tasks.get(task_id) if task is None: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Task {task_id} was not found", ) if task.project_id != project_id: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Task {task_id} belongs to project {task.project_id}", ) if task.status.value in {"DONE", "CANCELED"}: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Task {task_id} is {task.status.value}", ) if not session_id: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message="Session id is required for workspace apply", ) session = _collaboration.sessions.get(session_id) if session is None: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Session {session_id} was not found", ) if session.task_id != task_id: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Session {session_id} belongs to task {session.task_id}", ) if session.finished_at is not None: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Session {session_id} is already finished", ) if user_id and session.user_id != user_id: return AuthoringGuardCheck( name="task-session", status="BLOCKED", message=f"Session {session_id} belongs to user {session.user_id}", ) return AuthoringGuardCheck( name="task-session", status="OK", message=f"Task {task_id} and session {session_id} are active for project {project_id}", ) def _authoring_rbac_check(user_id: str | None) -> AuthoringGuardCheck: if not user_id: return AuthoringGuardCheck( name="rbac", status="BLOCKED", message="User id is required for workspace apply", ) if not _rbac.is_allowed(user_id, Permission.APPLY_AUTHORING_CHANGE): return AuthoringGuardCheck( name="rbac", status="BLOCKED", message=f"User {user_id} is not allowed to apply authoring changes", ) return AuthoringGuardCheck( name="rbac", status="OK", message=f"User {user_id} can apply authoring changes", ) def _authoring_privacy_check(context: AuthoringContextResponse) -> AuthoringGuardCheck: blocked_markers = [ marker for marker in context.privacy_markers if marker.classification in {PrivacyClassification.PERSONAL_DATA, PrivacyClassification.SECRET} ] if blocked_markers: return AuthoringGuardCheck( name="privacy", status="BLOCKED", message=f"{len(blocked_markers)} sensitive privacy markers require explicit local-only handling before apply", ) if context.privacy_markers: return AuthoringGuardCheck( name="privacy", status="WARNING", message=f"{len(context.privacy_markers)} privacy markers in context", ) return AuthoringGuardCheck(name="privacy", status="OK", message="No privacy markers in context") def _authoring_target_node(snapshot: SirSnapshot, request: AuthoringSemanticDiffPreviewRequest): if request.target_lineage_id: found = next((node for node in snapshot.nodes if node.lineage_id == request.target_lineage_id), None) if found is not None: return found if request.routine_name: found = _find_routine_node(snapshot, request.routine_name, None) if found is not None: return found if request.object_name: wanted = request.object_name.lower() found = next( ( node for node in snapshot.nodes if node.kind in _ACCESS_TARGET_KINDS and (node.name.lower() == wanted or node.qualified_name.lower() == wanted) ), None, ) if found is not None: return found if request.source_path: path = str(Path(request.source_path)) found = next((node for node in snapshot.nodes if str(Path(node.source_ref.source_path)) == path), None) if found is not None: return found return None def _text_semantic_diff(original_text: str, proposed_text: str) -> list[AuthoringDiffLine]: original_lines = original_text.splitlines() proposed_lines = proposed_text.splitlines() matcher = SequenceMatcher(a=original_lines, b=proposed_lines) diff: list[AuthoringDiffLine] = [] for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes(): if tag == "equal": continue if tag in {"replace", "delete"}: diff.extend( AuthoringDiffLine(kind="REMOVE", text=line) for line in original_lines[old_start:old_end] ) if tag in {"replace", "insert"}: diff.extend( AuthoringDiffLine(kind="ADD", text=line) for line in proposed_lines[new_start:new_end] ) return diff def _affected_nodes_for_diff(snapshot: SirSnapshot, target, source_path: str | None): if target is None and source_path is None: return [] affected = [] source_path_value = str(Path(source_path)) if source_path else None for node in snapshot.nodes: same_target = target is not None and node.lineage_id == target.lineage_id same_file = source_path_value is not None and str(Path(node.source_ref.source_path)) == source_path_value inside_target_range = ( target is not None and node.source_ref.source_path == target.source_ref.source_path and target.source_ref.line_start is not None and target.source_ref.line_end is not None and node.source_ref.line_start is not None and target.source_ref.line_start <= node.source_ref.line_start <= target.source_ref.line_end ) if same_target or same_file or inside_target_range: affected.append(node) return sorted(affected, key=lambda item: (item.source_ref.line_start or 0, item.qualified_name))[:20] def _authoring_version_preview( target, proposed_text: str, task_id: str | None, session_id: str | None, ) -> AuthoringVersionPreview | None: if target is None: return None object_hash = stable_hash( json.dumps( { "lineage_id": target.lineage_id, "semantic_id": target.semantic_id, "proposed_text": proposed_text, }, ensure_ascii=False, sort_keys=True, ) ) current = _versions.latest(target.lineage_id) return AuthoringVersionPreview( lineage_id=target.lineage_id, semantic_id=target.semantic_id, current_version_id=current.version_id if current else None, next_version_id=f"version.{target.lineage_id}.{object_hash}", object_hash=object_hash, task_id=task_id, session_id=session_id, apply_available=False, ) def _authoring_semantic_diff_preview( project_id: str, request: AuthoringSemanticDiffPreviewRequest, ) -> AuthoringSemanticDiffPreviewResponse: snapshot, _graph = _snapshot_and_graph(project_id) target = _authoring_target_node(snapshot, request) diff = _text_semantic_diff(request.original_text, request.proposed_text) added_lines = sum(1 for line in diff if line.kind == "ADD") removed_lines = sum(1 for line in diff if line.kind == "REMOVE") affected_nodes = _affected_nodes_for_diff(snapshot, target, request.source_path) context = _authoring_context( project_id, AuthoringContextRequest( object_name=request.object_name, routine_name=request.routine_name, cursor_line=None, source_text=request.proposed_text, ), ) checks = _authoring_guard_checks( project_id, context, AuthoringCompletionPreviewRequest( object_name=request.object_name, routine_name=request.routine_name, source_text=request.proposed_text, estimated_tokens=request.estimated_tokens, user_id=request.user_id, ), ) checks.append(_authoring_task_session_check(project_id, request.task_id, request.session_id, request.user_id)) checks.append(_authoring_rbac_check(request.user_id)) version_preview = _authoring_version_preview(target, request.proposed_text, request.task_id, request.session_id) return AuthoringSemanticDiffPreviewResponse( project_id=project_id, target=_named_node(target) if target else None, changed=bool(diff), added_lines=added_lines, removed_lines=removed_lines, semantic_diff=diff, affected_nodes=[_named_node(node) for node in affected_nodes], checks=checks, version_preview=version_preview, ) def _persist_authoring_version( preview: AuthoringSemanticDiffPreviewResponse, request: AuthoringApplyChangeSetRequest, ) -> SemanticObjectVersion: if preview.version_preview is None: raise ValueError("version preview is required") previous = _versions.latest(preview.version_preview.lineage_id) version = SemanticObjectVersion( version_id=preview.version_preview.next_version_id, lineage_id=preview.version_preview.lineage_id, semantic_id=preview.version_preview.semantic_id, object_hash=preview.version_preview.object_hash, task_id=request.task_id, session_id=request.session_id, parent_version_id=previous.version_id if previous else None, payload={ "kind": "AUTHORING_CHANGE_SET", "original_text": request.original_text, "proposed_text": request.proposed_text, "semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff], "affected_nodes": [node.model_dump(mode="json") for node in preview.affected_nodes], "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, }, ) stored = _versions.upsert_version(version) _storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json")) return stored def _authoring_change_summaries(project_id: str) -> list[AuthoringChangeSummary]: summaries: list[AuthoringChangeSummary] = [] for payload in _storage.list_documents("authoring_changes"): if payload.get("project_id") != project_id: continue preview = payload.get("preview", {}) version = payload.get("version", {}) request = payload.get("request", {}) summaries.append( AuthoringChangeSummary( change_id=str(payload["change_id"]), project_id=str(payload["project_id"]), status=str(payload["status"]), target=preview.get("target"), version_id=str(version.get("version_id", "")), approved_by=str(payload.get("approved_by", "")), approval_note=payload.get("approval_note"), task_id=request.get("task_id"), session_id=request.get("session_id"), added_lines=int(preview.get("added_lines", 0)), removed_lines=int(preview.get("removed_lines", 0)), production_applied=bool(payload.get("production_applied", False)), ) ) return sorted(summaries, key=lambda item: item.change_id, reverse=True) def _authoring_change_payload(project_id: str, change_id: str) -> dict: for payload in _storage.list_documents("authoring_changes"): if payload.get("project_id") == project_id and payload.get("change_id") == change_id: return payload raise HTTPException(status_code=404, detail=f"Authoring change not found: {change_id}") def _authoring_rollback_preview(project_id: str, change_id: str) -> AuthoringRollbackPreviewResponse: payload = _authoring_change_payload(project_id, change_id) version_payload = payload["version"] version_body = version_payload["payload"] if "original_text" in version_body and "proposed_text" in version_body: semantic_diff = _text_semantic_diff(version_body["proposed_text"], version_body["original_text"]) elif version_body.get("kind") == "METADATA_OBJECT_DRAFT": semantic_diff = [ AuthoringDiffLine(kind="REMOVE", text=str(line.get("text", ""))) for line in reversed(version_body.get("semantic_diff", [])) if line.get("kind") == "ADD" ] else: raise HTTPException(status_code=409, detail=f"Rollback is not supported for version kind: {version_body.get('kind')}") object_hash = stable_hash( json.dumps( { "rollback_of": version_payload["version_id"], "semantic_diff": [line.model_dump(mode="json") for line in semantic_diff], }, ensure_ascii=False, sort_keys=True, ) ) rollback_version_id = f"version.{version_payload['lineage_id']}.{object_hash}" return AuthoringRollbackPreviewResponse( project_id=project_id, change_id=change_id, original_version_id=version_payload["version_id"], rollback_version_id=rollback_version_id, target=payload["preview"].get("target"), semantic_diff=semantic_diff, checks=[ AuthoringGuardCheck(name="preview", status="REQUIRED", message="Rollback diff must be reviewed before apply"), AuthoringGuardCheck(name="apply", status="READY", message="Rollback can be applied to SFERA workspace history"), ], apply_available=True, ) def _persist_authoring_rollback( project_id: str, change_payload: dict, preview: AuthoringRollbackPreviewResponse, request: AuthoringApplyRollbackRequest, ) -> tuple[SemanticObjectVersion, str]: version_payload = change_payload["version"] original_payload = version_payload["payload"] object_hash = preview.rollback_version_id.rsplit(".", 1)[-1] previous = _versions.latest(version_payload["lineage_id"]) if original_payload.get("kind") == "METADATA_OBJECT_DRAFT": rollback_payload = { "kind": "METADATA_OBJECT_DRAFT_ROLLBACK", "rollback_of_change_id": change_payload["change_id"], "rollback_of_version_id": version_payload["version_id"], "draft": original_payload.get("draft", {}), "semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff], "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, } else: rollback_payload = { "kind": "AUTHORING_ROLLBACK", "rollback_of_change_id": change_payload["change_id"], "rollback_of_version_id": version_payload["version_id"], "original_text": original_payload["proposed_text"], "proposed_text": original_payload["original_text"], "semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff], "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, } version = SemanticObjectVersion( version_id=preview.rollback_version_id, lineage_id=version_payload["lineage_id"], semantic_id=version_payload["semantic_id"], object_hash=object_hash, task_id=request.task_id or change_payload.get("request", {}).get("task_id"), session_id=request.session_id or change_payload.get("request", {}).get("session_id"), parent_version_id=previous.version_id if previous else version_payload["version_id"], payload=rollback_payload, ) stored = _versions.upsert_version(version) _storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json")) rollback_change_id = f"rollback.{change_payload['change_id']}" payload = { "change_id": rollback_change_id, "project_id": project_id, "status": "ROLLED_BACK_TO_WORKSPACE", "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, "request": request.model_dump(mode="json"), "preview": { "target": preview.target.model_dump(mode="json") if preview.target else None, "added_lines": sum(1 for line in preview.semantic_diff if line.kind == "ADD"), "removed_lines": sum(1 for line in preview.semantic_diff if line.kind == "REMOVE"), }, "rollback_preview": preview.model_dump(mode="json"), "version": stored.model_dump(mode="json"), } path = _storage.write_document("authoring_changes", rollback_change_id, payload) return stored, path.as_posix() _METADATA_SPEC_PREFIXES = { "COMMON_MODULE": "ОбщийМодуль.", "CONSTANT": "Константа.", "CATALOG": "Справочник.", "DOCUMENT": "Документ.", "DOCUMENT_JOURNAL": "ЖурналДокументов.", "ENUM": "Перечисление.", "REPORT": "Отчет.", "DATA_PROCESSOR": "Обработка.", "CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик.", "CHART_OF_ACCOUNTS": "ПланСчетов.", "CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета.", "INFORMATION_REGISTER": "РегистрСведений.", "ACCUMULATION_REGISTER": "РегистрНакопления.", "ACCOUNTING_REGISTER": "РегистрБухгалтерии.", "CALCULATION_REGISTER": "РегистрРасчета.", "BUSINESS_PROCESS": "БизнесПроцесс.", "TASK": "Задача.", "EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных.", "EXCHANGE_PLAN": "ПланОбмена.", "EVENT_SUBSCRIPTION": "ПодпискаНаСобытие.", "SCHEDULED_JOB": "РегламентноеЗадание.", } _METADATA_SPEC_NODE_KINDS = { "COMMON_MODULE": {NodeKind.COMMON_MODULE}, "CATALOG": {NodeKind.CATALOG}, "DOCUMENT": {NodeKind.DOCUMENT}, "INFORMATION_REGISTER": {NodeKind.REGISTER}, "ACCUMULATION_REGISTER": {NodeKind.REGISTER}, "ACCOUNTING_REGISTER": {NodeKind.REGISTER}, "CALCULATION_REGISTER": {NodeKind.REGISTER}, "BUSINESS_PROCESS": {NodeKind.BUSINESS_PROCESS}, "TASK": {NodeKind.TASK}, "EXCHANGE_PLAN": {NodeKind.EXCHANGE_PLAN}, "SCHEDULED_JOB": {NodeKind.SCHEDULED_JOB}, } if _EVENT_SUBSCRIPTION_KIND is not None: _METADATA_SPEC_NODE_KINDS["EVENT_SUBSCRIPTION"] = {_EVENT_SUBSCRIPTION_KIND} def _common_branch_spec_code(label: str) -> str | None: spec = METADATA_TYPE_BY_BRANCH.get(label) if spec is None: return None if spec.code in {"COMMON", "EXTENSION"}: return None return spec.code def _metadata_spec_by_code(spec_code: str): return METADATA_TYPE_BY_CODE.get(spec_code) def _metadata_tree_node( node_id: str, label: str, kind: str, icon: str, *, qualified_name: str | None = None, children: list[MetadataTreeNodeResponse] | None = None, count: int | None = None, has_more: bool = False, ) -> MetadataTreeNodeResponse: child_nodes = children or [] return MetadataTreeNodeResponse( id=node_id, label=label, kind=kind, icon=icon, qualified_name=qualified_name, count=count if count is not None else sum(child.count or 1 for child in child_nodes), loaded_count=len(child_nodes), has_more=has_more, children=child_nodes, ) def _configuration_like_empty_root(root_id: str, label: str, kind: str) -> MetadataTreeNodeResponse: common_children = [ _metadata_tree_node(f"{root_id}.common.{label}", label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label)) for label in COMMON_BRANCH_CHILDREN ] spec_branches = [ _metadata_tree_node(f"{root_id}.branch.{spec.code}", spec.tree_branch, spec.code, spec.icon, count=0) for spec in METADATA_TYPE_SPECS if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN ] return _metadata_tree_node( root_id, label, kind, "configuration", children=[ _metadata_tree_node(f"{root_id}.info", "Сведения", "CONFIGURATION_INFO", "configuration"), _metadata_tree_node(f"{root_id}.common", "Общие", "COMMON", "common", children=common_children), *spec_branches, ], ) def _metadata_common_branch( snapshot: SirSnapshot, label: str, object_limit_per_branch: int, ) -> MetadataTreeNodeResponse: spec_code = _common_branch_spec_code(label) if spec_code is None: return _metadata_tree_node( f"common.{label}", label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label), count=0, has_more=False, ) spec = _metadata_spec_by_code(spec_code) if spec is None: return _metadata_tree_node( f"common.{label}", label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label), count=0, has_more=False, ) object_nodes = sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) visible_nodes = object_nodes[:object_limit_per_branch] if object_limit_per_branch > 0 else [] child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in visible_nodes]) children = [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in visible_nodes] return _metadata_tree_node( f"common.{label}", label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label), children=children, count=len(object_nodes), has_more=len(visible_nodes) < len(object_nodes), ) def _conditional_configuration_like_roots(project_id: str | None) -> list[MetadataTreeNodeResponse]: if not project_id: return [] state = _project_setup.get(project_id, {}) settings = ProjectSettingsRequest.model_validate(state.get("settings", {})) source = state.get("current_source") or (settings.structure_source.value if settings.structure_source else None) roots: list[MetadataTreeNodeResponse] = [] if source == ImportSourceKind.CONTEXT_ONLY.value: roots.append(_configuration_like_empty_root("context-configuration", "Context-only configuration", "CONTEXT_CONFIGURATION")) if source == ImportSourceKind.REFERENCE_CONFIGURATION.value: roots.append(_configuration_like_empty_root("reference-configuration", "Reference configuration", "REFERENCE_CONFIGURATION")) return roots def _project_metadata_tree_response(snapshot: SirSnapshot, object_limit_per_branch: int = 200) -> MetadataTreeNodeResponse: common_children = [ _metadata_common_branch(snapshot, label, object_limit_per_branch) for label in COMMON_BRANCH_CHILDREN ] spec_branches = [ _metadata_tree_branch_for_spec(snapshot, spec, object_limit_per_branch) for spec in METADATA_TYPE_SPECS if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN ] main_configuration = _metadata_tree_node( "main-configuration", "Основная конфигурация", "MAIN_CONFIGURATION", "configuration", children=[ _metadata_tree_node("main-configuration.info", "Сведения", "CONFIGURATION_INFO", "configuration"), _metadata_tree_node("common", "Общие", "COMMON", "common", children=common_children), *spec_branches, ], ) sfera = _metadata_tree_node( "sfera", "SFERA", "SFERA_ROOT", "service", children=[ _metadata_tree_node("sfera-objects", "Наши объекты SFERA", "SFERA_OBJECTS", "service"), *[ _metadata_tree_node(f"sfera.{label}", label, "SFERA_SECTION", "service") for label in ("Блок-схема", "Задачи разработки", "Проверки", "Версии", "Инциденты", "Знания", "Настройки") ], ], ) return _metadata_tree_node( f"project.{snapshot.project_id}", "Проект", "PROJECT", "project", children=[ main_configuration, _metadata_tree_node("extension.placeholder", "Расширение: <Имя>", "EXTENSION", "extension"), *_conditional_configuration_like_roots(snapshot.project_id), sfera, _metadata_tree_node("environments", "Среды", "ENVIRONMENTS", "service"), ], ) def _project_metadata_tree_response_from_normalized( normalized: NormalizedProject, object_limit_per_branch: int = 200, ) -> MetadataTreeNodeResponse: main_configuration = _normalized_configuration_tree_root( "main-configuration", "Основная конфигурация", "MAIN_CONFIGURATION", normalized.configuration.groups, object_limit_per_branch, ) visible_extensions = ( normalized.configuration.extensions[:object_limit_per_branch] if object_limit_per_branch > 0 else normalized.configuration.extensions ) extension_roots = [ _normalized_configuration_tree_root( f"extension.{stable_hash(extension.name)}", f"Расширение: {extension.name}", "EXTENSION", extension.groups, object_limit_per_branch, icon="extension", info={ "qualified_name": extension.qualified_name, "version": extension.version, **dict(getattr(extension, "attributes", {}) or {}), }, ) for extension in visible_extensions ] if not extension_roots: extension_roots = [ _metadata_tree_node("extension.placeholder", "Расширение: <Имя>", "EXTENSION", "extension") ] sfera = _metadata_tree_node( "sfera", "SFERA", "SFERA_ROOT", "service", children=[ _metadata_tree_node("sfera-objects", "Наши объекты SFERA", "SFERA_OBJECTS", "service"), *[ _metadata_tree_node(f"sfera.{label}", label, "SFERA_SECTION", "service") for label in ("Блок-схема", "Задачи разработки", "Проверки", "Версии", "Инциденты", "Знания", "Настройки") ], ], ) return _metadata_tree_node( f"project.{normalized.project_id or 'normalized'}", "Проект", "PROJECT", "project", children=[ main_configuration, *extension_roots, *_conditional_configuration_like_roots(normalized.project_id), sfera, _metadata_tree_node("environments", "Среды", "ENVIRONMENTS", "service"), ], ) def _normalized_configuration_tree_root( root_id: str, label: str, kind: str, groups, object_limit_per_branch: int, *, icon: str = "configuration", info: dict | None = None, ) -> MetadataTreeNodeResponse: common_children = [ _normalized_common_branch(groups, common_label, object_limit_per_branch, root_id=root_id) for common_label in COMMON_BRANCH_CHILDREN ] group_branches = [ _normalized_group_tree_branch(group, object_limit_per_branch, root_id=root_id) for group in groups if group.name != "Расширения" and group.name not in COMMON_BRANCH_CHILDREN ] info_label = "Сведения" if info: version = info.get("version") or info.get("Version") if version: info_label = f"Сведения ({version})" return _metadata_tree_node( root_id, label, kind, icon, children=[ _metadata_tree_node(f"{root_id}.info", info_label, "CONFIGURATION_INFO", icon), _metadata_tree_node(f"{root_id}.common", "Общие", "COMMON", "common", children=common_children), *group_branches, ], ) def _normalized_group_tree_branch(group, object_limit_per_branch: int, *, root_id: str = "main-configuration") -> MetadataTreeNodeResponse: visible_objects = group.objects[:object_limit_per_branch] if object_limit_per_branch > 0 else [] node_id = _normalized_branch_node_id(root_id, group.name) return _metadata_tree_node( node_id, group.name, ",".join(group.object_kinds), "metadata", children=[_normalized_object_tree_node(item) for item in visible_objects], count=len(group.objects), has_more=len(visible_objects) < len(group.objects), ) def _normalized_common_branch(groups, label: str, object_limit_per_branch: int, *, root_id: str = "main-configuration") -> MetadataTreeNodeResponse: node_id = _normalized_common_node_id(root_id, label) group = next((item for item in groups if item.name == label), None) if group is None: return _metadata_tree_node( node_id, label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label), count=0, has_more=False, ) visible_objects = group.objects[:object_limit_per_branch] if object_limit_per_branch > 0 else [] return _metadata_tree_node( node_id, label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label), children=[_normalized_object_tree_node(item) for item in visible_objects], count=len(group.objects), has_more=len(visible_objects) < len(group.objects), ) def _normalized_object_tree_node(item) -> MetadataTreeNodeResponse: spec = METADATA_TYPE_BY_CODE.get(item.object_kind) expected_groups = spec.child_groups if spec is not None else _NORMALIZED_CHILD_GROUPS_BY_LABEL.keys() child_groups = [ (label, len(getattr(item, field_name, [])), icon) for label in expected_groups for field_name, icon in [_NORMALIZED_CHILD_GROUPS_BY_LABEL.get(label, ("", "metadata"))] if field_name and len(getattr(item, field_name, [])) > 0 ] children = [ _metadata_tree_node( f"normalized.{stable_hash(item.qualified_name)}.{label}", label, "METADATA_CHILD_GROUP", icon, count=count, has_more=count > 0, ) for label, count, icon in child_groups if count > 0 ] return _metadata_tree_node( f"normalized.{stable_hash(item.qualified_name)}", item.name, item.object_kind, _normalized_object_icon(item.object_kind), qualified_name=item.qualified_name, children=children, count=len(children), ) def _normalized_metadata_tree_children_for_node( normalized: NormalizedProject, *, node_id: str, offset: int, limit: int, ) -> tuple[list[MetadataTreeNodeResponse], int] | None: if node_id.startswith("normalized.branch."): group = _normalized_group_by_branch_id(normalized, node_id) if group is None: raise HTTPException(status_code=404, detail=f"Metadata branch not found: {node_id}") return _slice_normalized_objects(group.objects, offset, limit) if node_id.startswith("common."): group = _normalized_group_by_name(normalized, node_id.removeprefix("common.")) if group is None: return [], 0 return _slice_normalized_objects(group.objects, offset, limit) extension_scope = _normalized_extension_scope_for_node(normalized, node_id) if extension_scope is not None: extension, scoped_id = extension_scope if scoped_id.startswith("branch."): group = _normalized_group_by_branch_id_for_groups(extension.groups, scoped_id) if group is None: raise HTTPException(status_code=404, detail=f"Extension metadata branch not found: {node_id}") return _slice_normalized_objects(group.objects, offset, limit) if scoped_id.startswith("common."): group = _normalized_group_by_name_for_groups(extension.groups, scoped_id.removeprefix("common.")) if group is None: return [], 0 return _slice_normalized_objects(group.objects, offset, limit) child_group = _normalized_child_group_for_node(normalized, node_id) if child_group is not None: parts, icon = child_group page = parts[offset : offset + limit] return [_normalized_part_tree_node(part, icon) for part in page], len(parts) if node_id.startswith("normalized."): raise HTTPException(status_code=404, detail=f"Metadata tree node not found: {node_id}") return None def _normalized_group_by_branch_id(normalized: NormalizedProject, node_id: str): return _normalized_group_by_branch_id_for_groups(normalized.configuration.groups, node_id.removeprefix("normalized.")) def _normalized_group_by_branch_id_for_groups(groups, node_id: str): return next( ( group for group in groups if node_id == f"branch.{stable_hash(group.name)}" ), None, ) def _normalized_group_by_name(normalized: NormalizedProject, name: str): return _normalized_group_by_name_for_groups(normalized.configuration.groups, name) def _normalized_group_by_name_for_groups(groups, name: str): return next((group for group in groups if group.name == name), None) def _normalized_extension_scope_for_node(normalized: NormalizedProject, node_id: str): prefix = "extension." if not node_id.startswith(prefix): return None payload = node_id.removeprefix(prefix) if "." not in payload: return None extension_hash, scoped_id = payload.split(".", 1) extension = next( (item for item in normalized.configuration.extensions if stable_hash(item.name) == extension_hash), None, ) if extension is None: return None return extension, scoped_id def _normalized_branch_node_id(root_id: str, group_name: str) -> str: if root_id == "main-configuration": return f"normalized.branch.{stable_hash(group_name)}" return f"{root_id}.branch.{stable_hash(group_name)}" def _normalized_common_node_id(root_id: str, label: str) -> str: if root_id == "main-configuration": return f"common.{label}" return f"{root_id}.common.{label}" def _slice_normalized_objects(objects, offset: int, limit: int) -> tuple[list[MetadataTreeNodeResponse], int]: page = objects[offset : offset + limit] return [_normalized_object_tree_node(item) for item in page], len(objects) def _normalized_child_group_for_node(normalized: NormalizedProject, node_id: str): prefix = "normalized." if not node_id.startswith(prefix): return None payload = node_id.removeprefix(prefix) if "." not in payload: return None object_hash, label = payload.split(".", 1) item = next( ( candidate for group in _normalized_all_groups(normalized) for candidate in group.objects if stable_hash(candidate.qualified_name) == object_hash ), None, ) if item is None: return None group = _NORMALIZED_CHILD_GROUPS_BY_LABEL.get(label) if group is None: return None field_name, icon = group return getattr(item, field_name, []), icon def _normalized_all_groups(normalized: NormalizedProject): groups = list(normalized.configuration.groups) for extension in normalized.configuration.extensions: groups.extend(extension.groups) return groups _NORMALIZED_CHILD_GROUPS_BY_LABEL = { "Реквизиты": ("attributes", "attribute"), "Измерения": ("dimensions", "attribute"), "Ресурсы": ("resources", "attribute"), "Табличные части": ("tabular_sections", "table"), "Формы": ("forms", "form"), "Команды": ("commands", "command"), "Макеты": ("layouts", "layout"), "Табличные документы": ("tabular_documents", "table"), "СКД": ("data_composition_schemas", "layout"), "Варианты отчета": ("report_variants", "report"), "Настройки": ("report_settings", "settings"), "Хранилище вариантов": ("report_storages", "settings"), "Хранилище настроек": ("report_storages", "settings"), "Справка": ("help_items", "common"), "Модули": ("modules", "module"), "Модуль": ("modules", "module"), "Модуль формы": ("modules", "module"), "Модуль объекта": ("modules", "module"), "Модуль менеджера": ("modules", "module"), "Модуль набора записей": ("modules", "module"), "Права": ("rights", "role"), "События": ("events", "event"), "Движения": ("movements", "movement"), "Значения": ("values", "enum"), "Предопределенные данные": ("predefined", "metadata"), "Шаблоны URL": ("url_templates", "http"), "Методы": ("methods", "module"), "Метод": ("methods", "module"), "Операции": ("operations", "service"), "Параметры": ("parameters", "attribute"), "Каналы": ("channels", "service"), "Сообщения": ("messages", "service"), "Таблицы": ("tables", "table"), "Кубы": ("cubes", "table"), "Функции": ("methods", "module"), "Графы": ("fields", "attribute"), "Элементы": ("fields", "attribute"), } def _normalized_part_tree_node(part, fallback_icon: str) -> MetadataTreeNodeResponse: qualified_name = part.qualified_name or part.name children = [_normalized_part_tree_node(child, _normalized_part_icon(child, fallback_icon)) for child in getattr(part, "children", [])] part_key = f"{qualified_name}.{part.kind}.{part.source_path or ''}" return _metadata_tree_node( f"normalized.part.{stable_hash(part_key)}", part.name, part.kind, _normalized_part_icon(part, fallback_icon), qualified_name=qualified_name, children=children, count=len(children), ) def _normalized_part_icon(part, fallback_icon: str) -> str: kind = getattr(part, "kind", "").upper() if kind == "FORM": return "form" if kind == "COMMAND": return "command" if kind == "MODULE": return "module" if kind in {"METHOD", "OPERATION"}: return "module" if kind in {"DATA_COMPOSITION_SCHEMA", "LAYOUT"}: return "layout" if kind in {"REPORT_VARIANT", "REPORT"}: return "report" if kind in {"REPORT_SETTING", "REPORT_STORAGE"}: return "settings" if kind == "HELP": return "common" if kind in {"URL_TEMPLATE", "HTTP_SERVICE"}: return "http" if kind in {"PARAMETER", "FIELD", "DIMENSION", "RESOURCE"}: return "attribute" if kind in {"ENUM_VALUE", "PREDEFINED"}: return "enum" if kind in {"CHANNEL", "MESSAGE"}: return "service" if kind in {"RIGHTS", "ROLE"}: return "role" if "TABULAR" in kind: return "table" if "EVENT" in kind: return "event" return fallback_icon def _normalized_object_icon(object_kind: str) -> str: kind = object_kind.upper() if "EXCHANGE_PLAN" in kind or "ПЛАНОБМЕНА" in kind: return "exchange-plan" if "SCHEDULED_JOB" in kind or "РЕГЛАМЕНТНОЕЗАДАНИЕ" in kind: return "scheduled-job" if "EVENT_SUBSCRIPTION" in kind or "ПОДПИСКА" in kind: return "event" if "CATALOG" in kind: return "catalog" if "DOCUMENT" in kind: return "document" if "REGISTER" in kind: return "register" if "REPORT" in kind: return "report" if "PROCESSING" in kind: return "processing" if "FORM" in kind: return "form" if "COMMAND" in kind: return "command" if "TASK" in kind: return "task" if "ENUM" in kind: return "enum" if "COMMON MODULE" in kind or "COMMON_MODULE" in kind: return "module" if "EXTENSION" in kind: return "extension" return "metadata" def _metadata_tree_branch_for_spec(snapshot: SirSnapshot, spec, object_limit_per_branch: int) -> MetadataTreeNodeResponse: object_nodes = sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) visible_nodes = object_nodes[:object_limit_per_branch] if object_limit_per_branch > 0 else [] child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in visible_nodes]) children = [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in visible_nodes] return _metadata_tree_node( f"branch.{spec.code}", spec.tree_branch, spec.code, spec.icon, children=children, count=len(object_nodes), has_more=len(visible_nodes) < len(object_nodes), ) def _metadata_object_tree_node(snapshot: SirSnapshot, node, spec, child_count_index: dict[str, Counter[EdgeKind]] | None = None) -> MetadataTreeNodeResponse: groups = (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания") return _metadata_tree_node( node.lineage_id, node.name, node.kind.value, spec.icon, qualified_name=node.qualified_name, children=[ _metadata_tree_node( f"{node.lineage_id}.{group}", group, "METADATA_CHILD_GROUP", _metadata_icon_for_child_group(group), count=_metadata_child_group_count_from_index(child_count_index, node.lineage_id, group), has_more=_metadata_child_group_count_from_index(child_count_index, node.lineage_id, group) > 0, ) for group in groups ], count=len(groups), ) def _metadata_tree_children_for_node( snapshot: SirSnapshot, *, node_id: str, offset: int, limit: int, ) -> tuple[list[MetadataTreeNodeResponse], int]: if node_id.startswith("branch."): spec_code = node_id.removeprefix("branch.") spec = _metadata_spec_by_code(spec_code) if spec is None: raise HTTPException(status_code=404, detail=f"Metadata branch not found: {node_id}") object_nodes = sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) page = object_nodes[offset : offset + limit] child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in page]) return [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in page], len(object_nodes) if node_id == "common": children = [ _metadata_common_branch(snapshot, label, 200) for label in COMMON_BRANCH_CHILDREN ] return _slice_metadata_children(children, offset, limit) if node_id.startswith("common."): return _common_branch_children(snapshot, node_id.removeprefix("common."), offset, limit) group_owner_id, group_name = _split_metadata_group_node_id(node_id) if group_owner_id and group_name: return _metadata_child_group_children(snapshot, group_owner_id, group_name, offset, limit) owner_node = _find_snapshot_node(snapshot, node_id) if owner_node is not None: spec = _metadata_spec_for_node(owner_node) if spec is None: return [], 0 children = [ _metadata_tree_node( f"{owner_node.lineage_id}.{group}", group, "METADATA_CHILD_GROUP", _metadata_icon_for_child_group(group), count=_metadata_child_group_count(snapshot, owner_node.lineage_id, group), has_more=_metadata_child_group_count(snapshot, owner_node.lineage_id, group) > 0, ) for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания") ] return _slice_metadata_children(children, offset, limit) raise HTTPException(status_code=404, detail=f"Metadata tree node not found: {node_id}") def _slice_metadata_children( children: list[MetadataTreeNodeResponse], offset: int, limit: int, ) -> tuple[list[MetadataTreeNodeResponse], int]: return children[offset : offset + limit], len(children) def _common_branch_children( snapshot: SirSnapshot, label: str, offset: int, limit: int, ) -> tuple[list[MetadataTreeNodeResponse], int]: spec_code = _common_branch_spec_code(label) if spec_code is None: return [], 0 spec = _metadata_spec_by_code(spec_code) if spec is None: return [], 0 object_nodes = sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) page = object_nodes[offset : offset + limit] return [_metadata_object_tree_node(snapshot, node, spec) for node in page], len(object_nodes) def _split_metadata_group_node_id(node_id: str) -> tuple[str | None, str | None]: known_groups = { group for spec in METADATA_TYPE_SPECS for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания") } for group in sorted(known_groups, key=len, reverse=True): suffix = f".{group}" if node_id.endswith(suffix): return node_id[: -len(suffix)], group return None, None def _metadata_child_group_children( snapshot: SirSnapshot, owner_id: str, group_name: str, offset: int, limit: int, ) -> tuple[list[MetadataTreeNodeResponse], int]: edge_kinds = _edge_kinds_for_metadata_group(group_name) if not edge_kinds: return [], 0 target_ids = [ edge.target_lineage for edge in snapshot.edges if edge.source_lineage == owner_id and edge.kind in edge_kinds and (edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE") ] nodes_by_id = {node.lineage_id: node for node in snapshot.nodes} child_nodes = sorted( [nodes_by_id[target_id] for target_id in target_ids if target_id in nodes_by_id], key=lambda item: item.qualified_name, ) if group_name == "Процедуры": child_nodes = [node for node in child_nodes if node.kind == NodeKind.PROCEDURE] elif group_name == "Функции": child_nodes = [node for node in child_nodes if node.kind == NodeKind.FUNCTION] page = child_nodes[offset : offset + limit] return [_metadata_leaf_tree_node(snapshot, node) for node in page], len(child_nodes) def _edge_kinds_for_metadata_group(group_name: str) -> set[EdgeKind]: lowered = group_name.lower() if any(token in lowered for token in ("реквизит", "измерен", "ресурс", "граф", "значен")): return {EdgeKind.HAS_ATTRIBUTE} if "таблич" in lowered: return {EdgeKind.HAS_TABULAR_SECTION} if "форм" in lowered: return {EdgeKind.HAS_FORM} if "команд" in lowered: return {EdgeKind.HAS_COMMAND} if "права" in lowered: return {EdgeKind.HAS_ROLE} if "модул" in lowered: return {EdgeKind.CONTAINS} if "процедур" in lowered or "функц" in lowered: return {EdgeKind.DECLARES} return set() def _metadata_leaf_tree_node(snapshot: SirSnapshot, node) -> MetadataTreeNodeResponse: child_groups = [] if node.kind == NodeKind.TABULAR_SECTION: child_groups.append("Реквизиты") if node.kind == NodeKind.FORM: child_groups.extend(["Элементы", "Команды", "События", "Модуль формы"]) if node.kind == NodeKind.MODULE: child_groups.extend(["Процедуры", "Функции"]) return _metadata_tree_node( node.lineage_id, node.name, node.kind.value, _metadata_icon_for_node_kind(node.kind), qualified_name=node.qualified_name, children=[ _metadata_tree_node( f"{node.lineage_id}.{group}", group, "METADATA_CHILD_GROUP", _metadata_icon_for_child_group(group), has_more=True, ) for group in child_groups ], count=len(child_groups), ) def _metadata_node_for_search_result( snapshot: SirSnapshot, node, child_count_index: dict[str, Counter[EdgeKind]] | None = None, ) -> MetadataTreeNodeResponse: spec = _metadata_spec_for_node(node) if spec: return _metadata_object_tree_node(snapshot, node, spec, child_count_index) return _metadata_leaf_tree_node(snapshot, node) _FLOWCHART_KIND_LABELS = { NodeKind.PROJECT: "Проект", NodeKind.CATALOG: "Справочники", NodeKind.DOCUMENT: "Документы", NodeKind.COMMON_MODULE: "Общие модули", NodeKind.MODULE: "Модули BSL", NodeKind.PROCEDURE: "Процедуры", NodeKind.FUNCTION: "Функции", NodeKind.FORM: "Формы", NodeKind.COMMAND: "Команды", NodeKind.ROLE: "Роли", NodeKind.ATTRIBUTE: "Реквизиты", NodeKind.TABULAR_SECTION: "Табличные части", NodeKind.QUERY: "Запросы", NodeKind.TABLE: "Таблицы", NodeKind.REGISTER: "Регистры", NodeKind.REPORT: "Отчеты", NodeKind.DATA_PROCESSOR: "Обработки", NodeKind.HTTP_SERVICE: "HTTP-сервисы", NodeKind.INTEGRATION_ENDPOINT: "Интеграции", NodeKind.SCHEDULED_JOB: "Регламентные задания", NodeKind.EXTENSION: "Расширения", } _FLOWCHART_EDGE_LABELS = { EdgeKind.CONTAINS: "содержит", EdgeKind.DECLARES: "объявляет", EdgeKind.CALLS: "вызывает", EdgeKind.OWNS_QUERY: "запрос", EdgeKind.READS_TABLE: "читает", EdgeKind.WRITES: "записывает", EdgeKind.HAS_FORM: "форма", EdgeKind.HAS_COMMAND: "команда", EdgeKind.HAS_ROLE: "роль", EdgeKind.HAS_ATTRIBUTE: "реквизит", EdgeKind.HAS_TABULAR_SECTION: "табличная часть", EdgeKind.HAS_ELEMENT: "элемент", EdgeKind.GRANTS_ACCESS: "права", EdgeKind.RUNS: "запускает", EdgeKind.USES_INTEGRATION: "интеграция", EdgeKind.HANDLES: "обработчик", } _FLOWCHART_IMPORTANT_EDGES = { EdgeKind.CALLS, EdgeKind.OWNS_QUERY, EdgeKind.READS_TABLE, EdgeKind.WRITES, EdgeKind.HAS_ROLE, EdgeKind.GRANTS_ACCESS, EdgeKind.RUNS, EdgeKind.USES_INTEGRATION, EdgeKind.HANDLES, } _FLOWCHART_LOGIC_NODE_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.CONSTANT, NodeKind.DOCUMENT_JOURNAL, NodeKind.ENUM, NodeKind.REPORT, NodeKind.DATA_PROCESSOR, NodeKind.CHART_OF_CHARACTERISTIC_TYPES, NodeKind.CHART_OF_ACCOUNTS, NodeKind.CHART_OF_CALCULATION_TYPES, NodeKind.COMMON_MODULE, NodeKind.EXCHANGE_PLAN, NodeKind.EXTERNAL_DATA_SOURCE, NodeKind.SCHEDULED_JOB, NodeKind.BUSINESS_PROCESS, NodeKind.TASK, NodeKind.SUBSYSTEM, NodeKind.HTTP_SERVICE, NodeKind.XDTO_PACKAGE, NodeKind.EXTENSION, NodeKind.INTEGRATION_ENDPOINT, NodeKind.ROLE, } _FLOWCHART_DETAIL_NODE_KINDS = _FLOWCHART_LOGIC_NODE_KINDS | { NodeKind.MODULE, NodeKind.PROCEDURE, NodeKind.FUNCTION, NodeKind.QUERY, NodeKind.TABLE, } def _flowchart_overview(snapshot: SirSnapshot, limit: int) -> ProjectFlowchartResponse: kind_counts = Counter(node.kind for node in snapshot.nodes if node.kind in _FLOWCHART_LOGIC_NODE_KINDS) kind_nodes = [ FlowchartNodeResponse( id=f"kind:{kind.value}", label=_FLOWCHART_KIND_LABELS.get(kind, kind.value), kind=kind.value, count=count, expandable=count > 0, ) for kind, count in kind_counts.most_common(limit) if count > 0 ] available_kind_ids = {node.id for node in kind_nodes} logic_edges = _flowchart_logical_edges(snapshot) edge_counts: Counter[tuple[NodeKind, NodeKind, EdgeKind]] = Counter() for source, target, edge_kind, _label in logic_edges: source_id = f"kind:{source.kind.value}" target_id = f"kind:{target.kind.value}" if source_id not in available_kind_ids or target_id not in available_kind_ids or source_id == target_id: continue edge_counts[(source.kind, target.kind, edge_kind)] += 1 flow_edges = [ FlowchartEdgeResponse( id=f"kind:{source.value}:{edge_kind.value}:{target.value}", source=f"kind:{source.value}", target=f"kind:{target.value}", kind=edge_kind.value, label=_FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value), count=count, ) for (source, target, edge_kind), count in edge_counts.most_common(limit * 2) ] return ProjectFlowchartResponse( project_id=snapshot.project_id, mode="overview", total_nodes=len(snapshot.nodes), total_edges=len(snapshot.edges), nodes=kind_nodes, edges=flow_edges, ) def _flowchart_for_focus( snapshot: SirSnapshot, focus: str, depth: int, limit: int, ) -> ProjectFlowchartResponse | None: nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes} normalized_focus = focus.strip().casefold() focus_node = next( ( node for node in snapshot.nodes if node.lineage_id == focus or node.qualified_name.casefold() == normalized_focus or node.name.casefold() == normalized_focus ), None, ) if focus_node is None: return None logic_edges = _flowchart_logical_edges(snapshot) adjacency: dict[str, list[tuple[str, EdgeKind, str]]] = {} for source, target, edge_kind, label in logic_edges: if source.lineage_id == target.lineage_id: continue adjacency.setdefault(source.lineage_id, []).append((target.lineage_id, edge_kind, label)) adjacency.setdefault(target.lineage_id, []).append((source.lineage_id, edge_kind, label)) selected_ids: set[str] = {focus_node.lineage_id} levels: dict[str, int] = {focus_node.lineage_id: 0} frontier = [focus_node.lineage_id] for level in range(1, depth + 1): next_frontier: list[str] = [] for lineage_id in frontier: for neighbor_id, _edge_kind, _label in adjacency.get(lineage_id, []): if neighbor_id in selected_ids or len(selected_ids) >= limit: continue selected_ids.add(neighbor_id) levels[neighbor_id] = level next_frontier.append(neighbor_id) frontier = next_frontier if not frontier or len(selected_ids) >= limit: break flow_nodes = [ FlowchartNodeResponse( id=node.lineage_id, label=node.name, kind=node.kind.value, qualified_name=node.qualified_name, level=levels.get(node.lineage_id, depth), expandable=bool(adjacency.get(node.lineage_id)), ) for node in sorted( (nodes_by_lineage[lineage_id] for lineage_id in selected_ids if nodes_by_lineage[lineage_id].kind in _FLOWCHART_DETAIL_NODE_KINDS), key=lambda item: (levels.get(item.lineage_id, depth), item.kind.value, item.qualified_name), ) ] flow_edges = [ FlowchartEdgeResponse( id=f"logic:{source.lineage_id}:{edge_kind.value}:{target.lineage_id}:{index}", source=source.lineage_id, target=target.lineage_id, kind=edge_kind.value, label=label or _FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value), ) for index, (source, target, edge_kind, label) in enumerate(logic_edges) if source.lineage_id in selected_ids and target.lineage_id in selected_ids ][: limit * 3] return ProjectFlowchartResponse( project_id=snapshot.project_id, mode="focus", focus=focus_node.lineage_id, total_nodes=len(snapshot.nodes), total_edges=len(snapshot.edges), nodes=flow_nodes, edges=flow_edges, ) def _flowchart_logical_edges(snapshot: SirSnapshot) -> list[tuple[object, object, EdgeKind, str]]: nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes} metadata_by_qname = { node.qualified_name: node for node in snapshot.nodes if node.kind in _FLOWCHART_LOGIC_NODE_KINDS } owner_by_lineage = _flowchart_owner_index(snapshot, nodes_by_lineage) result: list[tuple[object, object, EdgeKind, str]] = [] seen: set[tuple[str, str, EdgeKind, str]] = set() for edge in snapshot.edges: if edge.kind not in _FLOWCHART_IMPORTANT_EDGES: continue source = nodes_by_lineage.get(edge.source_lineage) target = nodes_by_lineage.get(edge.target_lineage) if source is None or target is None: continue logical_source = _flowchart_logical_node(source, owner_by_lineage, metadata_by_qname) logical_target = _flowchart_logical_node(target, owner_by_lineage, metadata_by_qname) if logical_source is None or logical_target is None or logical_source.lineage_id == logical_target.lineage_id: continue label = _flowchart_logical_edge_label(edge.kind, source, target, logical_source, logical_target) key = (logical_source.lineage_id, logical_target.lineage_id, edge.kind, label) if key in seen: continue seen.add(key) result.append((logical_source, logical_target, edge.kind, label)) return result def _flowchart_owner_index(snapshot: SirSnapshot, nodes_by_lineage: dict[str, object]) -> dict[str, object]: owner_by_lineage: dict[str, object] = {} for edge in snapshot.edges: source = nodes_by_lineage.get(edge.source_lineage) target = nodes_by_lineage.get(edge.target_lineage) if source is None or target is None: continue if edge.kind == EdgeKind.CONTAINS and edge.attributes.get("link_type") == "METADATA_MODULE": owner_by_lineage[target.lineage_id] = source elif edge.kind in {EdgeKind.HAS_COMMAND, EdgeKind.HAS_ROLE, EdgeKind.HAS_ATTRIBUTE, EdgeKind.HAS_TABULAR_SECTION, EdgeKind.HAS_FORM, EdgeKind.HAS_ELEMENT}: owner_by_lineage[target.lineage_id] = source for edge in snapshot.edges: source = nodes_by_lineage.get(edge.source_lineage) target = nodes_by_lineage.get(edge.target_lineage) if source is None or target is None: continue if edge.kind in {EdgeKind.DECLARES, EdgeKind.OWNS_QUERY}: owner_by_lineage[target.lineage_id] = owner_by_lineage.get(source.lineage_id, source) return owner_by_lineage def _flowchart_logical_node(node, owner_by_lineage: dict[str, object], metadata_by_qname: dict[str, object]): if node.kind in _FLOWCHART_LOGIC_NODE_KINDS: return node owner = owner_by_lineage.get(node.lineage_id) if owner is not None and owner.kind in _FLOWCHART_LOGIC_NODE_KINDS: return owner if node.kind == NodeKind.TABLE: return metadata_by_qname.get(node.qualified_name) or metadata_by_qname.get(node.name) return None def _flowchart_logical_edge_label(edge_kind: EdgeKind, source, target, logical_source, logical_target) -> str: base = _FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value) if edge_kind == EdgeKind.CALLS and source.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}: return f"{base}: {source.name}" if edge_kind in {EdgeKind.READS_TABLE, EdgeKind.WRITES}: return base if target.kind in {NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION}: return f"{base}: {target.name}" if source.lineage_id != logical_source.lineage_id and source.name: return f"{base}: {source.name}" if target.lineage_id != logical_target.lineage_id and target.name: return f"{base}: {target.name}" return base def _metadata_tree_path_for_node(snapshot: SirSnapshot, node_id: str) -> list[str]: node = _find_snapshot_node(snapshot, node_id) if node is None: group_owner_id, group_name = _split_metadata_group_node_id(node_id) if group_owner_id and group_name: owner_node = _find_snapshot_node(snapshot, group_owner_id) if owner_node is None: return [] return [*_metadata_tree_path_for_node(snapshot, group_owner_id), node_id] return [] spec = _metadata_spec_for_node(node) if spec is not None: common_branch = _common_branch_label_for_spec(spec.code) if common_branch: return ["main-configuration", "common", f"common.{common_branch}", node.lineage_id] return ["main-configuration", f"branch.{spec.code}", node.lineage_id] parent_edge = next((edge for edge in snapshot.edges if edge.target_lineage == node.lineage_id), None) if parent_edge is None: return [node.lineage_id] owner_node = _find_snapshot_node(snapshot, parent_edge.source_lineage) if owner_node is None: return [node.lineage_id] group_name = _metadata_group_name_for_edge(parent_edge.kind) return [*_metadata_tree_path_for_node(snapshot, owner_node.lineage_id), f"{owner_node.lineage_id}.{group_name}", node.lineage_id] def _metadata_tree_path_steps(snapshot: SirSnapshot, path: list[str]) -> list[MetadataTreePathStepResponse]: return [ MetadataTreePathStepResponse( parent_id=parent_id, child_id=child_id, offset=_metadata_tree_child_offset(snapshot, parent_id, child_id), ) for parent_id, child_id in zip(path, path[1:]) ] def _metadata_tree_child_offset(snapshot: SirSnapshot, parent_id: str, child_id: str) -> int: children = _metadata_tree_child_ids(snapshot, parent_id) try: return children.index(child_id) except ValueError: return 0 def _metadata_tree_child_ids(snapshot: SirSnapshot, parent_id: str) -> list[str]: if parent_id == "main-configuration": return [ "main-configuration.info", "common", *[ f"branch.{spec.code}" for spec in METADATA_TYPE_SPECS if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN ], ] if parent_id == "common": return [f"common.{label}" for label in COMMON_BRANCH_CHILDREN] if parent_id.startswith("branch."): spec_code = parent_id.removeprefix("branch.") spec = _metadata_spec_by_code(spec_code) if spec is None: return [] return [ node.lineage_id for node in sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) ] if parent_id.startswith("common."): spec_code = _common_branch_spec_code(parent_id.removeprefix("common.")) spec = _metadata_spec_by_code(spec_code) if spec is None: return [] return [ node.lineage_id for node in sorted( [node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)], key=lambda item: item.qualified_name, ) ] group_owner_id, group_name = _split_metadata_group_node_id(parent_id) if group_owner_id and group_name: return _metadata_child_group_child_ids(snapshot, group_owner_id, group_name) owner_node = _find_snapshot_node(snapshot, parent_id) if owner_node is None: return [] spec = _metadata_spec_for_node(owner_node) if spec is None: return [] return [ f"{owner_node.lineage_id}.{group}" for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания") ] def _metadata_child_group_child_ids(snapshot: SirSnapshot, owner_id: str, group_name: str) -> list[str]: edge_kinds = _edge_kinds_for_metadata_group(group_name) if not edge_kinds: return [] target_ids = [ edge.target_lineage for edge in snapshot.edges if edge.source_lineage == owner_id and edge.kind in edge_kinds ] nodes_by_id = {node.lineage_id: node for node in snapshot.nodes} return [ node.lineage_id for node in sorted( [nodes_by_id[target_id] for target_id in target_ids if target_id in nodes_by_id], key=lambda item: item.qualified_name, ) ] def _common_branch_label_for_spec(spec_code: str) -> str | None: if spec_code in {"COMMON", "EXTENSION"}: return None spec = METADATA_TYPE_BY_CODE.get(spec_code) if spec is not None and spec.tree_branch in COMMON_BRANCH_CHILDREN: return spec.tree_branch for label in COMMON_BRANCH_CHILDREN: branch = METADATA_TYPE_BY_BRANCH.get(label) if branch is not None and branch.code == spec_code and branch.code not in {"COMMON", "EXTENSION"}: return label return None def _metadata_group_name_for_edge(edge_kind: EdgeKind) -> str: return { EdgeKind.HAS_ATTRIBUTE: "Реквизиты", EdgeKind.HAS_TABULAR_SECTION: "Табличные части", EdgeKind.HAS_FORM: "Формы", EdgeKind.HAS_COMMAND: "Команды", EdgeKind.HAS_ROLE: "Права", EdgeKind.CONTAINS: "Модуль", EdgeKind.DECLARES: "Процедуры", }.get(edge_kind, "Связи") def _is_metadata_tree_search_node(node) -> bool: return _is_top_level_metadata_node(node) or node.kind in { NodeKind.ATTRIBUTE, NodeKind.COMMAND, NodeKind.FORM, NodeKind.FORM_ELEMENT, NodeKind.MODULE, NodeKind.PROCEDURE, NodeKind.FUNCTION, NodeKind.ROLE, NodeKind.TABULAR_SECTION, } def _metadata_search_rank(node, normalized_query: str) -> tuple[int, int, str]: name = node.name.casefold() qualified = node.qualified_name.casefold() if name == normalized_query: relevance = 0 elif name.startswith(normalized_query): relevance = 1 elif normalized_query in name: relevance = 2 elif qualified.startswith(normalized_query): relevance = 3 else: relevance = 4 depth = 0 if _is_top_level_metadata_node(node) else 1 return relevance, depth, node.qualified_name def _metadata_child_group_count(snapshot: SirSnapshot, owner_id: str, group_name: str) -> int: edge_kinds = _edge_kinds_for_metadata_group(group_name) if not edge_kinds: return 0 return sum( 1 for edge in snapshot.edges if edge.source_lineage == owner_id and edge.kind in edge_kinds and (edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE") ) def _metadata_child_group_count_from_index( child_count_index: dict[str, Counter[EdgeKind]] | None, owner_id: str, group_name: str, ) -> int: edge_kinds = _edge_kinds_for_metadata_group(group_name) if not edge_kinds or child_count_index is None: return 0 counts = child_count_index.get(owner_id) if counts is None: return 0 return sum(counts[kind] for kind in edge_kinds) def _metadata_child_count_index(snapshot: SirSnapshot, owner_ids: list[str]) -> dict[str, Counter[EdgeKind]]: owner_set = set(owner_ids) result: dict[str, Counter[EdgeKind]] = {owner_id: Counter() for owner_id in owner_set} for edge in snapshot.edges: if edge.source_lineage in owner_set and ( edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE" ): result[edge.source_lineage][edge.kind] += 1 return result def _find_snapshot_node(snapshot: SirSnapshot, lineage_id: str): return next((node for node in snapshot.nodes if node.lineage_id == lineage_id), None) def _metadata_spec_for_node(node): for spec in METADATA_TYPE_SPECS: if _node_matches_metadata_spec(node, spec): return spec return None def _metadata_icon_for_node_kind(kind: NodeKind) -> str: return _METADATA_ICON_BY_NODE_KIND.get(kind, "folder") def _node_matches_metadata_spec(node, spec) -> bool: if not _is_top_level_metadata_node(node): return False allowed_kinds = _METADATA_SPEC_NODE_KINDS.get(spec.code) if allowed_kinds is not None and node.kind not in allowed_kinds: return False prefix = _METADATA_SPEC_PREFIXES.get(spec.code) return bool(prefix and node.qualified_name.casefold().startswith(prefix.casefold())) def _is_top_level_metadata_node(node) -> bool: return node.qualified_name.count(".") == 1 def _metadata_icon_for_common_branch(label: str) -> str: lowered = label.lower() if "планы обмена" in lowered or "план обмена" in lowered: return "exchange-plan" if "подписки на события" in lowered or "подписка на событие" in lowered: return "event" if "регламентные задания" in lowered or "регламентное задание" in lowered: return "scheduled-job" if "модул" in label.lower(): return "module" if "форм" in label.lower(): return "form" if "команд" in label.lower(): return "command" if "web" in label.lower() or "http" in label.lower() or "сервис" in label.lower(): return "web" if "роль" in label.lower(): return "role" return "folder" def _metadata_icon_for_child_group(label: str) -> str: lowered = label.lower() if "реквизит" in lowered or "измерен" in lowered or "ресурс" in lowered: return "attribute" if "таблич" in lowered: return "table" if "форм" in lowered: return "form" if "команд" in lowered: return "command" if "модуль" in lowered: return "module" if "права" in lowered: return "role" return "folder" _METADATA_OBJECT_KIND_PREFIXES = { "COMMON_MODULE": "ОбщийМодуль", "COMMON MODULE": "ОбщийМодуль", "ОБЩИЙ МОДУЛЬ": "ОбщийМодуль", "DOCUMENT": "Документ", "ДОКУМЕНТ": "Документ", "CATALOG": "Справочник", "СПРАВОЧНИК": "Справочник", "CONSTANT": "Константа", "КОНСТАНТА": "Константа", "DOCUMENT_JOURNAL": "ЖурналДокументов", "DOCUMENT JOURNAL": "ЖурналДокументов", "ЖУРНАЛ ДОКУМЕНТОВ": "ЖурналДокументов", "ENUM": "Перечисление", "ENUMERATION": "Перечисление", "ПЕРЕЧИСЛЕНИЕ": "Перечисление", "REPORT": "Отчет", "ОТЧЕТ": "Отчет", "DATA_PROCESSOR": "Обработка", "DATA PROCESSOR": "Обработка", "PROCESSING": "Обработка", "ОБРАБОТКА": "Обработка", "CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик", "CHART OF CHARACTERISTIC TYPES": "ПланВидовХарактеристик", "ПЛАН ВИДОВ ХАРАКТЕРИСТИК": "ПланВидовХарактеристик", "CHART_OF_ACCOUNTS": "ПланСчетов", "CHART OF ACCOUNTS": "ПланСчетов", "ПЛАН СЧЕТОВ": "ПланСчетов", "CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета", "CHART OF CALCULATION TYPES": "ПланВидовРасчета", "ПЛАН ВИДОВ РАСЧЕТА": "ПланВидовРасчета", "INFORMATION_REGISTER": "РегистрСведений", "INFORMATION REGISTER": "РегистрСведений", "РЕГИСТР СВЕДЕНИЙ": "РегистрСведений", "REGISTER": "РегистрНакопления", "ACCUMULATION_REGISTER": "РегистрНакопления", "ACCUMULATION REGISTER": "РегистрНакопления", "РЕГИСТР": "РегистрНакопления", "РЕГИСТР НАКОПЛЕНИЯ": "РегистрНакопления", "ACCOUNTING_REGISTER": "РегистрБухгалтерии", "ACCOUNTING REGISTER": "РегистрБухгалтерии", "РЕГИСТР БУХГАЛТЕРИИ": "РегистрБухгалтерии", "CALCULATION_REGISTER": "РегистрРасчета", "CALCULATION REGISTER": "РегистрРасчета", "РЕГИСТР РАСЧЕТА": "РегистрРасчета", "BUSINESS_PROCESS": "БизнесПроцесс", "BUSINESS PROCESS": "БизнесПроцесс", "БИЗНЕС-ПРОЦЕСС": "БизнесПроцесс", "БИЗНЕС ПРОЦЕСС": "БизнесПроцесс", "TASK": "Задача", "ЗАДАЧА": "Задача", "1C_TASK": "Задача", "EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных", "EXTERNAL DATA SOURCE": "ВнешнийИсточникДанных", "ВНЕШНИЙ ИСТОЧНИК ДАННЫХ": "ВнешнийИсточникДанных", } _ONE_C_IDENTIFIER_RE = re.compile(r"^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*$") def _validate_one_c_identifier(name: str, field_name: str) -> str: value = name.strip() if not value: raise HTTPException(status_code=422, detail=f"{field_name} is required") if not _ONE_C_IDENTIFIER_RE.match(value): raise HTTPException(status_code=422, detail=f"{field_name} must be a valid 1C identifier: {name}") return value def _validate_unique_names(items: list[str], field_name: str) -> None: seen: set[str] = set() for item in items: normalized = item.strip().lower() if normalized in seen: raise HTTPException(status_code=422, detail=f"Duplicate {field_name}: {item}") seen.add(normalized) def _validate_metadata_object_draft(request: AuthoringMetadataObjectPreviewRequest) -> None: _validate_one_c_identifier(request.name, "metadata object name") attribute_names = [_validate_one_c_identifier(attribute.name, "attribute name") for attribute in request.attributes] _validate_unique_names(attribute_names, "attribute name") section_names = [_validate_one_c_identifier(section.name, "tabular section name") for section in request.tabular_sections] _validate_unique_names(section_names, "tabular section name") for section in request.tabular_sections: column_names = [_validate_one_c_identifier(attribute.name, f"{section.name} column name") for attribute in section.attributes] _validate_unique_names(column_names, f"{section.name} column name") form_names = [_validate_one_c_identifier(form, "form name") for form in request.forms] _validate_unique_names(form_names, "form name") command_names = [_validate_one_c_identifier(command.name, "command name") for command in request.commands] _validate_unique_names(command_names, "command name") for command in request.commands: if command.handler: _validate_one_c_identifier(command.handler, f"{command.name} command handler") def _metadata_object_kind_prefix(kind: str) -> str: normalized = kind.strip().replace("_", " ").upper() if normalized in _METADATA_OBJECT_KIND_PREFIXES: return _METADATA_OBJECT_KIND_PREFIXES[normalized] normalized = kind.strip().upper() if normalized in _METADATA_OBJECT_KIND_PREFIXES: return _METADATA_OBJECT_KIND_PREFIXES[normalized] raise HTTPException(status_code=422, detail=f"Unsupported 1C metadata object kind: {kind}") def _metadata_object_target(request: AuthoringMetadataObjectPreviewRequest) -> NamedNode: prefix = _metadata_object_kind_prefix(request.object_kind) qualified_name = f"{prefix}.{request.name}" object_hash = stable_hash( json.dumps( { "kind": prefix, "name": request.name, }, ensure_ascii=False, sort_keys=True, ) ) return NamedNode( lineage_id=f"metadata.{prefix}.{request.name}", kind="METADATA_OBJECT", name=request.name, qualified_name=qualified_name, ) def _metadata_object_diff_lines(request: AuthoringMetadataObjectPreviewRequest) -> list[AuthoringDiffLine]: prefix = _metadata_object_kind_prefix(request.object_kind) diff = [AuthoringDiffLine(kind="ADD", text=f"{prefix}.{request.name}")] if request.synonym: diff.append(AuthoringDiffLine(kind="ADD", text=f"Синоним: {request.synonym}")) for attribute in request.attributes: required = " обязательный" if attribute.required else "" diff.append(AuthoringDiffLine(kind="ADD", text=f"Реквизит.{attribute.name}: {attribute.type}{required}")) for section in request.tabular_sections: diff.append(AuthoringDiffLine(kind="ADD", text=f"ТабличнаяЧасть.{section.name}")) if section.synonym: diff.append(AuthoringDiffLine(kind="ADD", text=f"ТабличнаяЧасть.{section.name}.Синоним: {section.synonym}")) for attribute in section.attributes: required = " обязательный" if attribute.required else "" diff.append( AuthoringDiffLine( kind="ADD", text=f"ТабличнаяЧасть.{section.name}.Реквизит.{attribute.name}: {attribute.type}{required}", ) ) for form in request.forms: diff.append(AuthoringDiffLine(kind="ADD", text=f"Форма.{form}")) for command in request.commands: handler = f" -> {command.handler}" if command.handler else "" diff.append(AuthoringDiffLine(kind="ADD", text=f"Команда.{command.name}{handler}")) return diff def _metadata_object_version_preview( target: NamedNode, request: AuthoringMetadataObjectPreviewRequest, diff: list[AuthoringDiffLine], ) -> AuthoringVersionPreview: object_hash = stable_hash( json.dumps( { "target": target.model_dump(mode="json"), "draft": request.model_dump( mode="json", exclude={"expected_next_version_id", "approved_by", "approval_note", "apply_to_production"}, ), "diff": [line.model_dump(mode="json") for line in diff], }, ensure_ascii=False, sort_keys=True, ) ) current = _versions.latest(target.lineage_id) return AuthoringVersionPreview( lineage_id=target.lineage_id, semantic_id=f"metadata-object:{target.qualified_name}", current_version_id=current.version_id if current else None, next_version_id=f"version.{target.lineage_id}.{object_hash}", object_hash=object_hash, task_id=request.task_id, session_id=request.session_id, apply_available=True, ) def _authoring_metadata_object_preview( project_id: str, request: AuthoringMetadataObjectPreviewRequest, ) -> AuthoringMetadataObjectPreviewResponse: _snapshot_and_graph(project_id) _validate_metadata_object_draft(request) target = _metadata_object_target(request) diff = _metadata_object_diff_lines(request) checks = [ AuthoringGuardCheck(name="preview", status="REQUIRED", message="Metadata draft must be reviewed before apply"), AuthoringGuardCheck(name="workspace-history", status="READY", message="Draft can be saved to SFERA workspace history"), AuthoringGuardCheck(name="production-1c", status="BLOCKED", message="Production 1C metadata write is disabled"), _authoring_task_session_check(project_id, request.task_id, request.session_id, request.user_id), _authoring_rbac_check(request.user_id), ] return AuthoringMetadataObjectPreviewResponse( project_id=project_id, target=target, changed=bool(diff), added_lines=len(diff), removed_lines=0, semantic_diff=diff, checks=checks, version_preview=_metadata_object_version_preview(target, request, diff), ) def _persist_authoring_metadata_object( project_id: str, preview: AuthoringMetadataObjectPreviewResponse, request: AuthoringApplyMetadataObjectRequest, ) -> tuple[SemanticObjectVersion, str, str]: previous = _versions.latest(preview.version_preview.lineage_id) version = SemanticObjectVersion( version_id=preview.version_preview.next_version_id, lineage_id=preview.version_preview.lineage_id, semantic_id=preview.version_preview.semantic_id, object_hash=preview.version_preview.object_hash, task_id=request.task_id, session_id=request.session_id, parent_version_id=previous.version_id if previous else None, payload={ "kind": "METADATA_OBJECT_DRAFT", "draft": request.model_dump(mode="json", exclude={"expected_next_version_id", "approved_by", "approval_note", "apply_to_production"}), "semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff], "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, }, ) stored = _versions.upsert_version(version) _storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json")) change_id = f"metadata-change.{preview.version_preview.object_hash}" payload = { "change_id": change_id, "project_id": project_id, "status": "METADATA_DRAFT_APPLIED_TO_WORKSPACE", "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, "request": request.model_dump(mode="json"), "preview": preview.model_dump(mode="json"), "version": stored.model_dump(mode="json"), } path = _storage.write_document("authoring_changes", change_id, payload) return stored, change_id, path.as_posix() def get_review_payload(snapshot: SirSnapshot) -> list[dict]: findings = [finding.model_dump(mode="json") for finding in review_snapshot(snapshot)] findings.extend(_schema_knowledge_review(snapshot)) findings.extend(_ownership_review(snapshot)) findings.extend(_privacy_review(snapshot)) return findings def _first_node(snapshot: SirSnapshot, kinds: set[NodeKind]): return next((node for node in snapshot.nodes if node.kind in kinds), None) def _find_routine_node(snapshot: SirSnapshot, routine_name: str | None, cursor_line: int | None): routine_kinds = {NodeKind.PROCEDURE, NodeKind.FUNCTION} if routine_name: wanted = routine_name.lower() found = next( ( node for node in snapshot.nodes if node.kind in routine_kinds and (node.name.lower() == wanted or node.qualified_name.lower() == wanted) ), None, ) if found is not None: return found if cursor_line is not None: found = next( ( node for node in snapshot.nodes if node.kind in routine_kinds and node.source_ref.line_start is not None and node.source_ref.line_end is not None and node.source_ref.line_start <= cursor_line <= node.source_ref.line_end ), None, ) if found is not None: return found return next((node for node in snapshot.nodes if node.kind in routine_kinds), None) def _relation_targets(graph: InMemoryProjection, source, edge_kind: EdgeKind): return sorted( [ graph.nodes[edge.target_lineage] for edge in graph.edges.values() if edge.kind == edge_kind and source is not None and edge.source_lineage == source.lineage_id and edge.target_lineage in graph.nodes ], key=lambda item: item.qualified_name, ) def _routine_query_tables(graph: InMemoryProjection, routine) -> list: if routine is None: return [] query_lineages = { edge.target_lineage for edge in graph.edges.values() if edge.kind == EdgeKind.OWNS_QUERY and edge.source_lineage == routine.lineage_id and edge.target_lineage in graph.nodes } tables = [ graph.nodes[edge.target_lineage] for edge in graph.edges.values() if edge.kind == EdgeKind.READS_TABLE and edge.source_lineage in query_lineages and edge.target_lineage in graph.nodes ] return sorted({node.lineage_id: node for node in tables}.values(), key=lambda item: item.qualified_name) def _source_text_for_node(node) -> str: if node is None: return "" path = Path(node.source_ref.source_path) if not path.exists(): return "" return path.read_text(encoding="utf-8") def _extract_parameters(source_text: str, routine_name: str | None) -> list[str]: if not source_text or not routine_name: return [] pattern = re.compile(rf"(?:Процедура|Функция|Procedure|Function)\s+{re.escape(routine_name)}\s*\(([^)]*)\)", re.IGNORECASE) match = pattern.search(source_text) if match is None: return [] return [ item.strip().split("=")[0].strip() for item in match.group(1).split(",") if item.strip() ] def _extract_local_variables(source_text: str, cursor_line: int | None) -> list[str]: if not source_text: return [] lines = source_text.splitlines() scope = lines[:cursor_line] if cursor_line is not None and cursor_line > 0 else lines variables: set[str] = set() assignment_pattern = re.compile(r"^\s*([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*=", re.IGNORECASE) new_pattern = re.compile(r"^\s*([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*=\s*Новый\s+", re.IGNORECASE) for line in scope: for pattern in (new_pattern, assignment_pattern): match = pattern.match(line) if match: variables.add(match.group(1)) return sorted(variables) def _available_1c_methods(object_node, routine_node) -> list[str]: methods = ["ЗначениеЗаполнено", "ВызватьИсключение", "Записать", "Сообщить"] if object_node is not None and object_node.kind == NodeKind.DOCUMENT: methods.extend(["Движения", "Проведение", "Отказ"]) if routine_node is not None: methods.extend(["Возврат", "Новый Запрос", "ЭтотОбъект"]) return sorted(set(methods)) @app.get("/projects/{project_id}/access/objects/{object_name}/roles", response_model=ObjectAccessResponse) async def get_object_access(project_id: str, object_name: str) -> ObjectAccessResponse: graph = _graphs.get(project_id) if graph is None: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectAccessResponse( object=_named_node(object_node), grants=[ RoleAccessResponse(role=_named_node(role), permissions=edge.attributes) for role, edge in _object_access_grants(graph, object_node) ], ) @app.get("/projects/{project_id}/access/roles/{role_name}/objects", response_model=RoleObjectAccessResponse) async def get_role_access(project_id: str, role_name: str) -> RoleObjectAccessResponse: graph = _graphs.get(project_id) if graph is None: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") role = _find_graph_node(graph, role_name, {NodeKind.ROLE}) if role is None: raise HTTPException(status_code=404, detail=f"Role not found: {role_name}") grants = _role_object_grants(graph, role) return RoleObjectAccessResponse( role=_named_node(role), objects=[_named_node(target) for target, _edge in grants], grants=[ {"object": _named_node(target), "permissions": edge.attributes} for target, edge in grants ], ) @app.get("/projects/{project_id}/review") async def get_review(project_id: str) -> list[dict]: snapshot = _project_snapshot_or_404(project_id) return get_review_payload(snapshot) @app.get("/projects/{project_id}/search", response_model=SearchResponse) async def search(project_id: str, q: str, kind: str | None = None, limit: int = 20) -> SearchResponse: snapshot = _project_snapshot_or_404(project_id) kinds = {kind.upper()} if kind else None return SearchResponse( results=[ _named_node(result.node) for result in search_snapshot(snapshot, q, kinds=kinds, limit=limit) ] ) @app.get("/projects/{project_id}/symbols", response_model=list[SymbolResponse]) async def project_symbols(project_id: str, q: str = "", kind: str | None = None, limit: int = 50) -> list[SymbolResponse]: snapshot = _project_snapshot_or_404(project_id) normalized_limit = min(max(1, limit), 250) kinds = {kind.upper()} if kind else None if q.strip(): nodes = [result.node for result in search_snapshot(snapshot, q, kinds=kinds, limit=normalized_limit)] else: nodes = [ node for node in snapshot.nodes if kinds is None or node.kind.value in kinds ] nodes.sort(key=lambda item: (item.qualified_name.casefold(), item.kind.value)) nodes = nodes[:normalized_limit] return [_symbol_response(node) for node in nodes] @app.get("/projects/{project_id}/symbols/definition", response_model=SymbolResponse) async def project_symbol_definition(project_id: str, lineage_id: str) -> SymbolResponse: snapshot = _project_snapshot_or_404(project_id) node = _find_snapshot_node(snapshot, lineage_id) if node is None: raise HTTPException(status_code=404, detail=f"Symbol not found: {lineage_id}") return _symbol_response(node) @app.get("/projects/{project_id}/symbols/references", response_model=SymbolReferencesResponse) async def project_symbol_references( project_id: str, lineage_id: str, direction: str = "incoming", ) -> SymbolReferencesResponse: snapshot = _project_snapshot_or_404(project_id) node = _find_snapshot_node(snapshot, lineage_id) if node is None: raise HTTPException(status_code=404, detail=f"Symbol not found: {lineage_id}") normalized_direction = direction.casefold() if normalized_direction not in {"incoming", "outgoing", "both"}: raise HTTPException(status_code=422, detail="direction must be incoming, outgoing, or both") nodes = {item.lineage_id: item for item in snapshot.nodes} references: list[SymbolReferenceResponse] = [] for edge in snapshot.edges: include_incoming = normalized_direction in {"incoming", "both"} and edge.target_lineage == lineage_id include_outgoing = normalized_direction in {"outgoing", "both"} and edge.source_lineage == lineage_id if not include_incoming and not include_outgoing: continue source = nodes.get(edge.source_lineage) target = nodes.get(edge.target_lineage) references.append( SymbolReferenceResponse( edge_id=edge.edge_id, kind=edge.kind.value, direction="incoming" if include_incoming else "outgoing", source=_named_node(source) if source is not None else None, target=_named_node(target) if target is not None else None, location=_source_location(edge.source_ref), attributes=edge.attributes, ) ) references.sort(key=lambda item: ( item.location.source_path if item.location else "", item.location.line_start or 0 if item.location else 0, item.kind, item.source.qualified_name if item.source else "", item.target.qualified_name if item.target else "", )) return SymbolReferencesResponse(symbol=_symbol_response(node), references=references) @app.get("/projects/{project_id}/tables/usage", response_model=list[TableUsageResponse]) async def get_table_usage(project_id: str, table: str | None = None) -> list[TableUsageResponse]: snapshot = _project_snapshot_or_404(project_id) return [ TableUsageResponse( table=_named_node(item.table), queries=[_named_node(node) for node in item.queries], readers=[_named_node(node) for node in item.readers], writers=[_named_node(node) for node in item.writers], ) for item in table_usage(snapshot, table) ] @app.get("/projects/{project_id}/patterns", response_model=list[SemanticPattern]) async def get_project_patterns(project_id: str, min_support: int = 2) -> list[SemanticPattern]: snapshot = _project_snapshot_or_404(project_id) return mine_patterns(snapshot, min_support=min_support) @app.get("/projects/{project_id}/transactions/writes", response_model=list[TransactionWriteSetResponse]) async def get_transaction_writes( project_id: str, target: str | None = None, ) -> list[TransactionWriteSetResponse]: snapshot = _project_snapshot_or_404(project_id) if target: routines = {node.lineage_id for node in routines_touching_target(snapshot, target)} write_sets = [ item for item in transaction_write_sets(snapshot) if item.routine.lineage_id in routines ] else: write_sets = transaction_write_sets(snapshot) return [ TransactionWriteSetResponse( routine=_named_node(item.routine), writes=[_named_node(node) for node in item.writes], ) for item in write_sets ] @app.get("/projects/{project_id}/ui/forms", response_model=list[FormSemanticsResponse]) async def get_ui_forms(project_id: str) -> list[FormSemanticsResponse]: snapshot = _project_snapshot_or_404(project_id) return [ FormSemanticsResponse( form=_named_node(item.form), commands=[_named_node(node) for node in item.commands], elements=[_named_node(node) for node in item.elements], command_handlers={ command_lineage: _named_node(handler) for command_lineage, handler in item.command_handlers.items() }, ) for item in form_semantics(snapshot) ] @app.get("/projects/{project_id}/objects/ui/{object_name}", response_model=ObjectUiResponse) async def get_object_ui(project_id: str, object_name: str) -> ObjectUiResponse: snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") form_lineages = { edge.target_lineage for edge in graph.edges.values() if edge.kind == EdgeKind.HAS_FORM and edge.source_lineage == object_node.lineage_id } return ObjectUiResponse( object=_named_node(object_node), forms=_form_semantics_for_lineages(snapshot, form_lineages), ) @app.post("/projects/{project_id}/authoring/context", response_model=AuthoringContextResponse) async def authoring_context( project_id: str, request: AuthoringContextRequest, ) -> AuthoringContextResponse: return _authoring_context(project_id, request) @app.post("/projects/{project_id}/authoring/completion-preview", response_model=AuthoringCompletionPreviewResponse) async def authoring_completion_preview( project_id: str, request: AuthoringCompletionPreviewRequest, ) -> AuthoringCompletionPreviewResponse: context = _authoring_context(project_id, request) insert_text = _authoring_insert_text(context, request) checks = _authoring_guard_checks(project_id, context, request) return AuthoringCompletionPreviewResponse( allowed=all(check.status != "BLOCKED" for check in checks), insert_text=insert_text, semantic_diff=[ AuthoringDiffLine(kind="ADD", text=line) for line in insert_text.splitlines() if line.strip() ], checks=checks, context=context, ) @app.post("/projects/{project_id}/authoring/semantic-diff-preview", response_model=AuthoringSemanticDiffPreviewResponse) async def authoring_semantic_diff_preview( project_id: str, request: AuthoringSemanticDiffPreviewRequest, ) -> AuthoringSemanticDiffPreviewResponse: return _authoring_semantic_diff_preview(project_id, request) @app.post("/projects/{project_id}/authoring/apply-change-set", response_model=AuthoringApplyChangeSetResponse) async def authoring_apply_change_set( project_id: str, request: AuthoringApplyChangeSetRequest, ) -> AuthoringApplyChangeSetResponse: if request.apply_to_production: raise HTTPException(status_code=403, detail="Production 1C apply is not enabled") preview = _authoring_semantic_diff_preview(project_id, request) if not preview.changed: raise HTTPException(status_code=400, detail="No semantic diff to apply") if preview.version_preview is None: raise HTTPException(status_code=400, detail="No target object for versioned apply") if preview.version_preview.next_version_id != request.expected_next_version_id: raise HTTPException(status_code=409, detail="Expected version id does not match current preview") blocking_checks = [ check for check in preview.checks if check.status == "BLOCKED" and check.name not in {"apply"} ] if blocking_checks: raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]}) version = _persist_authoring_version(preview, request) change_id = f"change.{preview.version_preview.object_hash}" payload = { "change_id": change_id, "project_id": project_id, "status": "APPLIED_TO_WORKSPACE", "approved_by": request.approved_by, "approval_note": request.approval_note, "production_applied": False, "request": request.model_dump(mode="json"), "preview": preview.model_dump(mode="json"), "version": version.model_dump(mode="json"), } path = _storage.write_document("authoring_changes", change_id, payload) return AuthoringApplyChangeSetResponse( project_id=project_id, status="APPLIED_TO_WORKSPACE", change_id=change_id, version=version, preview=preview, persisted_path=path.as_posix(), production_applied=False, ) @app.post( "/projects/{project_id}/authoring/metadata-object-preview", response_model=AuthoringMetadataObjectPreviewResponse, ) async def authoring_metadata_object_preview( project_id: str, request: AuthoringMetadataObjectPreviewRequest, ) -> AuthoringMetadataObjectPreviewResponse: return _authoring_metadata_object_preview(project_id, request) @app.post( "/projects/{project_id}/authoring/apply-metadata-object", response_model=AuthoringApplyMetadataObjectResponse, ) async def authoring_apply_metadata_object( project_id: str, request: AuthoringApplyMetadataObjectRequest, ) -> AuthoringApplyMetadataObjectResponse: if request.apply_to_production: raise HTTPException(status_code=403, detail="Production 1C metadata apply is not enabled") preview = _authoring_metadata_object_preview(project_id, request) if not preview.changed: raise HTTPException(status_code=400, detail="No metadata draft to apply") if preview.version_preview.next_version_id != request.expected_next_version_id: raise HTTPException(status_code=409, detail="Expected version id does not match current metadata preview") blocking_checks = [ check for check in preview.checks if check.status == "BLOCKED" and check.name not in {"production-1c"} ] if blocking_checks: raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]}) version, change_id, path = _persist_authoring_metadata_object(project_id, preview, request) return AuthoringApplyMetadataObjectResponse( project_id=project_id, status="METADATA_DRAFT_APPLIED_TO_WORKSPACE", change_id=change_id, version=version, preview=preview, persisted_path=path, production_applied=False, ) @app.get("/projects/{project_id}/authoring/changes", response_model=list[AuthoringChangeSummary]) async def list_authoring_changes(project_id: str) -> list[AuthoringChangeSummary]: return _authoring_change_summaries(project_id) @app.get( "/projects/{project_id}/authoring/changes/{change_id}/rollback-preview", response_model=AuthoringRollbackPreviewResponse, ) async def authoring_rollback_preview(project_id: str, change_id: str) -> AuthoringRollbackPreviewResponse: return _authoring_rollback_preview(project_id, change_id) @app.post( "/projects/{project_id}/authoring/changes/{change_id}/apply-rollback", response_model=AuthoringApplyRollbackResponse, ) async def authoring_apply_rollback( project_id: str, change_id: str, request: AuthoringApplyRollbackRequest, ) -> AuthoringApplyRollbackResponse: if request.apply_to_production: raise HTTPException(status_code=403, detail="Production 1C rollback is not enabled") change_payload = _authoring_change_payload(project_id, change_id) preview = _authoring_rollback_preview(project_id, change_id) if preview.rollback_version_id != request.expected_rollback_version_id: raise HTTPException(status_code=409, detail="Expected rollback version id does not match current preview") if not preview.apply_available: raise HTTPException(status_code=409, detail="Rollback apply is not available") apply_checks = [ _authoring_task_session_check(project_id, request.task_id, request.session_id), _authoring_rbac_check(request.approved_by), ] blocking_checks = [check for check in apply_checks if check.status == "BLOCKED"] if blocking_checks: raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]}) version, path = _persist_authoring_rollback(project_id, change_payload, preview, request) return AuthoringApplyRollbackResponse( project_id=project_id, status="ROLLED_BACK_TO_WORKSPACE", change_id=change_id, rollback_change_id=f"rollback.{change_id}", version=version, preview=preview, persisted_path=path, production_applied=False, ) @app.get("/projects/{project_id}/jobs/scheduled", response_model=list[ScheduledJobResponse]) async def get_scheduled_jobs(project_id: str) -> list[ScheduledJobResponse]: snapshot = _project_snapshot_or_404(project_id) return [ ScheduledJobResponse( job_id=binding.job.job_id, name=binding.job.name, routine_name=binding.job.routine_name, schedule=binding.job.schedule, routine=_named_node(binding.routine) if binding.routine is not None else None, attributes=binding.job.attributes, ) for binding in snapshot_scheduled_jobs(snapshot) ] @app.get("/projects/{project_id}/integrations", response_model=list[IntegrationEndpointResponse]) async def get_integrations(project_id: str, kind: IntegrationKind | None = None) -> list[IntegrationEndpointResponse]: snapshot = _project_snapshot_or_404(project_id) topology = build_integration_topology(snapshot) endpoints = topology.by_kind(kind) if kind is not None else topology.endpoints return [ IntegrationEndpointResponse( endpoint_id=endpoint.endpoint_id, name=endpoint.name, kind=endpoint.kind.value, direction=endpoint.direction, owner=endpoint.owner, attributes=endpoint.attributes, ) for endpoint in endpoints ] @app.get("/graph/neo4j/status") async def neo4j_status() -> dict: try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() counts = await Neo4jProjection(driver).counts() return {"status": "ok", "uri": _neo4j_uri, **counts} except Exception as error: return {"status": "unavailable", "uri": _neo4j_uri, "error": str(error)} @app.post("/projects/{project_id}/graph/neo4j/project", response_model=Neo4jProjectionResponse) async def project_to_neo4j(project_id: str) -> Neo4jProjectionResponse: snapshot = _project_snapshot_or_404(project_id) counts = await _project_snapshot_to_neo4j(project_id, snapshot) _neo4j_projected_projects.add(project_id) return Neo4jProjectionResponse( project_id=project_id, nodes=counts["nodes"], edges=counts["edges"], status="projected", ) @app.get( "/projects/{project_id}/graph/neo4j/callees/{routine_name}", response_model=SearchResponse, ) async def neo4j_callees(project_id: str, routine_name: str) -> SearchResponse: rows = await _neo4j_routine_query(project_id, routine_name, outgoing=True) return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/callers/{routine_name}", response_model=SearchResponse, ) async def neo4j_callers(project_id: str, routine_name: str) -> SearchResponse: rows = await _neo4j_routine_query(project_id, routine_name, outgoing=False) return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/query-tables/{routine_name}", response_model=SearchResponse, ) async def neo4j_query_tables(project_id: str, routine_name: str) -> SearchResponse: rows = await _neo4j_relation_query(project_id, routine_name, relation="READS_TABLE") return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/writes/{routine_name}", response_model=SearchResponse, ) async def neo4j_writes(project_id: str, routine_name: str) -> SearchResponse: rows = await _neo4j_relation_query(project_id, routine_name, relation="WRITES") return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/integrations", response_model=list[IntegrationEndpointResponse], ) async def neo4j_integrations(project_id: str, kind: IntegrationKind | None = None) -> list[IntegrationEndpointResponse]: rows = await _neo4j_integrations_query(project_id, kind=kind.value if kind is not None else None) return [IntegrationEndpointResponse(**row) for row in rows] @app.get( "/projects/{project_id}/graph/neo4j/integrations/{integration_name:path}/modules", response_model=SearchResponse, ) async def neo4j_integration_modules(project_id: str, integration_name: str) -> SearchResponse: rows = await _neo4j_integration_modules_query(project_id, integration_name) return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/objects/schema/{object_name}", response_model=ObjectSchemaResponse, ) async def neo4j_object_schema(project_id: str, object_name: str) -> ObjectSchemaResponse: payload = await _neo4j_object_schema_query(project_id, object_name) if payload is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectSchemaResponse( object=NamedNode(**payload["object"]), attributes=[NamedNode(**row) for row in payload["attributes"]], tabular_sections=[ TabularSectionColumnsResponse( tabular_section=NamedNode(**row["tabular_section"]), columns=[NamedNode(**column) for column in row["columns"]], ) for row in payload["tabular_sections"] ], ) @app.get( "/projects/{project_id}/graph/neo4j/objects/attributes/{object_name}", response_model=SearchResponse, ) async def neo4j_object_attributes(project_id: str, object_name: str) -> SearchResponse: rows = await _neo4j_object_attributes_query(project_id, object_name) return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}", response_model=SearchResponse, ) async def neo4j_object_tabular_sections(project_id: str, object_name: str) -> SearchResponse: rows = await _neo4j_object_tabular_sections_query(project_id, object_name) return SearchResponse(results=[NamedNode(**row) for row in rows]) @app.get( "/projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}/columns", response_model=list[TabularSectionColumnsResponse], ) async def neo4j_object_tabular_section_columns( project_id: str, object_name: str, ) -> list[TabularSectionColumnsResponse]: rows = await _neo4j_object_tabular_section_columns_query(project_id, object_name) return [ TabularSectionColumnsResponse( tabular_section=NamedNode(**row["tabular_section"]), columns=[NamedNode(**column) for column in row["columns"]], ) for row in rows ] @app.get( "/projects/{project_id}/graph/neo4j/objects/impact/{object_name}", response_model=ObjectImpactResponse, ) async def neo4j_object_impact(project_id: str, object_name: str) -> ObjectImpactResponse: payload = await _neo4j_object_impact_query(project_id, object_name) if payload is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectImpactResponse( object_name=object_name, object=NamedNode(**payload["object"]), modules=[NamedNode(**row) for row in payload["modules"]], routines=[NamedNode(**row) for row in payload["routines"]], forms=[NamedNode(**row) for row in payload["forms"]], commands=[NamedNode(**row) for row in payload["commands"]], attributes=[NamedNode(**row) for row in payload.get("attributes", [])], tabular_sections=[NamedNode(**row) for row in payload.get("tabular_sections", [])], tabular_section_columns={ section_lineage: [NamedNode(**column) for column in columns] for section_lineage, columns in payload.get("tabular_section_columns", {}).items() }, roles=[NamedNode(**row) for row in payload.get("roles", [])], role_access=[ RoleAccessResponse(role=NamedNode(**grant["role"]), permissions=grant["permissions"]) for grant in payload.get("role_access", []) ], jobs=[NamedNode(**row) for row in payload.get("jobs", [])], callees=[NamedNode(**row) for row in payload["callees"]], query_tables=[NamedNode(**row) for row in payload["query_tables"]], writes=[NamedNode(**row) for row in payload["writes"]], ) @app.get( "/projects/{project_id}/graph/neo4j/objects/ui/{object_name}", response_model=ObjectUiResponse, ) async def neo4j_object_ui(project_id: str, object_name: str) -> ObjectUiResponse: payload = await _neo4j_object_ui_query(project_id, object_name) if payload is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectUiResponse( object=NamedNode(**payload["object"]), forms=[ FormSemanticsResponse( form=NamedNode(**form["form"]), commands=[NamedNode(**row) for row in form["commands"]], elements=[NamedNode(**row) for row in form["elements"]], command_handlers={ command_lineage: NamedNode(**handler) for command_lineage, handler in form["command_handlers"].items() }, ) for form in payload["forms"] ], ) @app.get( "/projects/{project_id}/graph/neo4j/access/objects/{object_name}/roles", response_model=ObjectAccessResponse, ) async def neo4j_object_access(project_id: str, object_name: str) -> ObjectAccessResponse: payload = await _neo4j_object_access_query(project_id, object_name) if payload is None: raise HTTPException(status_code=404, detail=f"Object not found: {object_name}") return ObjectAccessResponse( object=NamedNode(**payload["object"]), grants=[ RoleAccessResponse(role=NamedNode(**grant["role"]), permissions=grant["permissions"]) for grant in payload["grants"] ], ) @app.get( "/projects/{project_id}/graph/neo4j/access/roles/{role_name}/objects", response_model=RoleObjectAccessResponse, ) async def neo4j_role_access(project_id: str, role_name: str) -> RoleObjectAccessResponse: payload = await _neo4j_role_access_query(project_id, role_name) if payload is None: raise HTTPException(status_code=404, detail=f"Role not found: {role_name}") return RoleObjectAccessResponse( role=NamedNode(**payload["role"]), objects=[NamedNode(**row) for row in payload["objects"]], grants=[ {"object": NamedNode(**grant["object"]), "permissions": grant["permissions"]} for grant in payload["grants"] ], ) @app.post("/projects/{project_id}/runtime/signals") async def add_runtime_signal(project_id: str, request: RuntimeSignalRequest) -> dict[str, str]: if project_id not in _snapshots: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") overlay = _overlays.setdefault(project_id, RuntimeOverlay(project_id=project_id)) overlay.signals.append(request.signal) return {"status": "accepted"} @app.get("/projects/{project_id}/runtime/summary", response_model=list[RuntimeSummaryResponse]) async def get_runtime_summary(project_id: str) -> list[RuntimeSummaryResponse]: snapshot = _project_snapshot_or_404(project_id) overlay = _overlays.get(project_id, RuntimeOverlay(project_id=project_id)) return [ RuntimeSummaryResponse( node=_named_node(item.node), signal_count=item.signal_count, error_count=item.error_count, max_duration_ms=item.max_duration_ms, ) for item in summarize_runtime(snapshot, overlay) ] @app.post("/knowledge", response_model=KnowledgeRecord) async def upsert_knowledge(record: KnowledgeRecord) -> KnowledgeRecord: stored = _knowledge.upsert(record) _storage.write_document("knowledge", stored.record_id, stored.model_dump(mode="json")) return stored @app.post("/knowledge/packs", response_model=KnowledgePack) async def import_knowledge_pack(pack: KnowledgePack) -> KnowledgePack: stored = _knowledge.import_pack(pack) _storage.write_document("knowledge_packs", stored.pack_id, stored.model_dump(mode="json")) for record in stored.records: enriched = _knowledge.get(record.record_id) if enriched is not None: _storage.write_document("knowledge", enriched.record_id, enriched.model_dump(mode="json")) return stored @app.get("/knowledge/packs", response_model=list[KnowledgePack]) async def list_knowledge_packs() -> list[KnowledgePack]: return _knowledge.list_packs() @app.get("/knowledge/search", response_model=KnowledgeSearchResponse) async def search_knowledge( q: str, scope: KnowledgeScope | None = None, limit: int = 20, ) -> KnowledgeSearchResponse: return KnowledgeSearchResponse( results=[result.record for result in _knowledge.search(q, scope=scope, limit=limit)] ) @app.get( "/projects/{project_id}/knowledge/coverage", response_model=list[KnowledgeCoverageResponse], ) async def get_knowledge_coverage(project_id: str) -> list[KnowledgeCoverageResponse]: snapshot = _project_snapshot_or_404(project_id) return [ KnowledgeCoverageResponse(node=_named_node(item.node), record_count=item.record_count) for item in _knowledge.coverage(snapshot) ] @app.get( "/projects/{project_id}/knowledge/schema-coverage", response_model=KnowledgeSchemaCoverageResponse, ) async def get_knowledge_schema_coverage(project_id: str) -> KnowledgeSchemaCoverageResponse: snapshot = _project_snapshot_or_404(project_id) schema_kinds = {NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION} items = [ KnowledgeCoverageResponse(node=_named_node(item.node), record_count=item.record_count) for item in _knowledge.coverage(snapshot) if item.node.kind in schema_kinds ] uncovered = [item.node for item in items if item.record_count == 0] return KnowledgeSchemaCoverageResponse( items=items, uncovered=uncovered, covered_count=sum(1 for item in items if item.record_count > 0), uncovered_count=len(uncovered), ) @app.post("/collaboration/users", response_model=User) async def upsert_user(user: User) -> User: stored = _collaboration.upsert_user(user) _storage.write_document("collaboration_users", stored.user_id, stored.model_dump(mode="json")) return stored @app.post("/collaboration/tasks", response_model=Task) async def upsert_task(task: Task) -> Task: stored = _collaboration.upsert_task(task) _storage.write_document("collaboration_tasks", stored.task_id, stored.model_dump(mode="json")) _collaboration.add_activity( ActivityEvent( event_id=f"activity.{task.task_id}.upsert", project_id=task.project_id, actor_user_id=task.assignee_user_id or "system", verb="UPSERT_TASK", target_id=task.task_id, ) ) return stored @app.post("/collaboration/sessions", response_model=ChangeSession) async def start_change_session(request: CollaborationSessionRequest) -> ChangeSession: try: stored = _collaboration.start_session(request.session) _storage.write_document( "collaboration_sessions", stored.session_id, stored.model_dump(mode="json"), ) return stored except KeyError as error: raise HTTPException(status_code=404, detail=str(error)) from error @app.post("/collaboration/sessions/{session_id}/finish", response_model=ChangeSession) async def finish_change_session(session_id: str) -> ChangeSession: try: stored = _collaboration.finish_session(session_id) except KeyError as error: raise HTTPException(status_code=404, detail=str(error)) from error _storage.write_document( "collaboration_sessions", stored.session_id, stored.model_dump(mode="json"), ) task = _collaboration.tasks.get(stored.task_id) _collaboration.add_activity( ActivityEvent( event_id=f"activity.session.{stored.session_id}.finish", project_id=task.project_id if task else "unknown", actor_user_id=stored.user_id, verb="FINISH_SESSION", target_id=stored.session_id, ) ) return stored @app.post("/projects/{project_id}/comments", response_model=Comment) async def add_project_comment(project_id: str, request: CommentRequest) -> Comment: _snapshot_and_graph(project_id) try: stored = _collaboration.add_comment( Comment( comment_id=request.comment_id, project_id=project_id, target_id=request.target_id, user_id=request.user_id, body=request.body, ) ) except KeyError as error: raise HTTPException(status_code=404, detail=str(error)) from error _storage.write_document("collaboration_comments", stored.comment_id, stored.model_dump(mode="json")) _collaboration.add_activity( ActivityEvent( event_id=f"activity.comment.{stored.comment_id}", project_id=project_id, actor_user_id=stored.user_id, verb="ADD_COMMENT", target_id=stored.target_id, ) ) return stored @app.get("/projects/{project_id}/comments", response_model=list[Comment]) async def project_comments(project_id: str) -> list[Comment]: _snapshot_and_graph(project_id) return _collaboration.comments_for_project(project_id) @app.get("/projects/{project_id}/comments/{target_id:path}", response_model=list[Comment]) async def target_comments(project_id: str, target_id: str) -> list[Comment]: _snapshot_and_graph(project_id) return _collaboration.comments_for_target(project_id, target_id) @app.post("/projects/{project_id}/ownership", response_model=Ownership) async def assign_project_owner(project_id: str, request: OwnershipRequest) -> Ownership: snapshot, _graph = _snapshot_and_graph(project_id) target = next((node for node in snapshot.nodes if node.lineage_id == request.target_id), None) if target is None: raise HTTPException(status_code=404, detail=f"Target lineage not found: {request.target_id}") try: stored = _collaboration.assign_owner( Ownership( project_id=project_id, target_id=request.target_id, owner_user_id=request.owner_user_id, role=request.role, assigned_by=request.assigned_by, attributes=request.attributes, ) ) except KeyError as error: raise HTTPException(status_code=404, detail=str(error)) from error document_id = f"{stored.project_id}.{stored.target_id}.{stored.role}.{stored.owner_user_id}" _storage.write_document("collaboration_ownership", document_id, stored.model_dump(mode="json")) _collaboration.add_activity( ActivityEvent( event_id=f"activity.ownership.{stored.project_id}.{stored.target_id}.{stored.role}.{stored.owner_user_id}", project_id=project_id, actor_user_id=stored.assigned_by or stored.owner_user_id, verb="ASSIGN_OWNER", target_id=stored.target_id, attributes={ "owner_user_id": stored.owner_user_id, "role": stored.role, "target_name": target.qualified_name, }, ) ) return stored @app.get("/projects/{project_id}/ownership", response_model=list[Ownership]) async def project_ownership(project_id: str) -> list[Ownership]: _snapshot_and_graph(project_id) return _collaboration.owners_for_project(project_id) @app.get( "/projects/{project_id}/objects/ownership/{object_name}", response_model=ObjectOwnershipResponse, ) async def object_ownership(project_id: str, object_name: str) -> ObjectOwnershipResponse: _snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _OWNERSHIP_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"1C object not found: {object_name}") return ObjectOwnershipResponse( object=_named_node(object_node), owners=_collaboration.owners_for_target(project_id, object_node.lineage_id), ) @app.get("/projects/{project_id}/activity") async def activity_feed(project_id: str) -> list[dict]: return [event.model_dump(mode="json") for event in _collaboration.activity_feed(project_id)] @app.get("/security/users/{user_id}/permissions/{permission}") async def check_permission(user_id: str, permission: Permission) -> dict[str, bool | str]: return { "user_id": user_id, "permission": permission.value, "allowed": _rbac.is_allowed(user_id, permission), } @app.get("/security/users/{user_id}/permissions") async def user_permissions(user_id: str) -> dict[str, list[str] | str]: return { "user_id": user_id, "permissions": sorted(permission.value for permission in _rbac.effective_permissions(user_id)), } @app.post("/security/users/{user_id}/roles/{role_id}") async def grant_role(user_id: str, role_id: str) -> dict[str, str]: try: _rbac.grant_role(user_id, role_id) except KeyError as error: raise HTTPException(status_code=404, detail=str(error)) from error _storage.write_document("security_users", user_id, _rbac.users[user_id].model_dump(mode="json")) return {"user_id": user_id, "role_id": role_id, "status": "granted"} @app.post("/projects/{project_id}/privacy/markers", response_model=PrivacyMarker) async def upsert_privacy_marker(project_id: str, request: PrivacyMarkerRequest) -> PrivacyMarker: snapshot, _graph = _snapshot_and_graph(project_id) target = next((node for node in snapshot.nodes if node.lineage_id == request.target_id), None) if target is None: raise HTTPException(status_code=404, detail=f"Target lineage not found: {request.target_id}") stored = _privacy.upsert_marker( PrivacyMarker( project_id=project_id, target_id=request.target_id, classification=request.classification, reason=request.reason, attributes=request.attributes, ) ) _storage.write_document( "privacy_markers", f"{stored.project_id}.{stored.target_id}", stored.model_dump(mode="json"), ) return stored @app.get("/projects/{project_id}/privacy/markers", response_model=list[PrivacyMarker]) async def project_privacy_markers(project_id: str) -> list[PrivacyMarker]: _snapshot_and_graph(project_id) return _privacy.markers_for_project(project_id) @app.get( "/projects/{project_id}/objects/privacy/{object_name}", response_model=ObjectPrivacyResponse, ) async def object_privacy(project_id: str, object_name: str) -> ObjectPrivacyResponse: _snapshot, graph = _snapshot_and_graph(project_id) object_node = _find_graph_node(graph, object_name, _PRIVACY_TARGET_KINDS) if object_node is None: raise HTTPException(status_code=404, detail=f"1C object not found: {object_name}") object_lineages = {object_node.lineage_id} for edge in graph.edges.values(): if edge.source_lineage == object_node.lineage_id and edge.kind in { EdgeKind.HAS_ATTRIBUTE, EdgeKind.HAS_TABULAR_SECTION, EdgeKind.HAS_FORM, }: object_lineages.add(edge.target_lineage) markers = [ marker for marker in _privacy.markers_for_project(project_id) if marker.target_id in object_lineages ] return ObjectPrivacyResponse(object=_named_node(object_node), markers=markers) @app.post("/operations/jobs", response_model=OperationJob) async def enqueue_job(job: OperationJob) -> OperationJob: stored = _operations.enqueue(job) return _persist_job(stored) @app.get("/operations/jobs", response_model=list[OperationJob]) async def list_jobs( status: OperationJobStatus | None = None, project_id: str | None = None, kind: str | None = None, ) -> list[OperationJob]: jobs = _operations.list_jobs(status) if project_id is not None: jobs = [job for job in jobs if job.payload.get("project_id") == project_id] if kind is not None: jobs = [job for job in jobs if job.kind == kind] return jobs @app.patch("/operations/jobs/{job_id}", response_model=OperationJob) async def update_job(job_id: str, request: JobUpdateRequest) -> OperationJob: try: stored = _operations.update_job( job_id, request.status, result=request.result, error=request.error, ) return _persist_job(stored) except KeyError as error: raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}") from error @app.post("/operations/jobs/{job_id}/run", response_model=OperationJob) async def run_job(job_id: str) -> OperationJob: job = _operations.jobs.get(job_id) if job is None: raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}") _persist_job(_operations.update_job(job_id, OperationJobStatus.RUNNING)) try: result = _run_operation_job(job) except Exception as error: failed = _operations.update_job(job_id, OperationJobStatus.FAILED, error=str(error)) _persist_job(failed) return failed succeeded = _operations.update_job(job_id, OperationJobStatus.SUCCEEDED, result=result) _persist_job(succeeded) return succeeded @app.post("/operations/metrics", response_model=MetricSample) async def record_metric(metric: MetricSample) -> MetricSample: stored = _operations.record_metric(metric) _storage.write_document("operations_metrics", stored.metric_id, stored.model_dump(mode="json")) return stored @app.get("/operations/metrics", response_model=list[MetricSample]) async def list_metrics(name: str | None = None) -> list[MetricSample]: return _operations.list_metrics(name) @app.post("/ai/usage", response_model=AiUsageRecord) async def record_ai_usage(usage: AiUsageRecord) -> AiUsageRecord: used_tokens = _operations.summarize_ai_usage(user_id=usage.user_id).total_tokens token_limit = _ai_policy.token_limit_per_day if token_limit is not None and used_tokens + usage.total_tokens > token_limit: raise HTTPException( status_code=429, detail=f"AI token limit exceeded for user {usage.user_id}: {token_limit}", ) stored = _operations.record_ai_usage(usage) _storage.write_document("ai_usage", stored.usage_id, stored.model_dump(mode="json")) _operations.record_metric( MetricSample( metric_id=f"metric.ai_usage.{stored.usage_id}", name="ai.tokens", value=float(stored.total_tokens), unit="tokens", labels={ "project_id": stored.project_id, "user_id": stored.user_id, "model": stored.model, "operation": stored.operation, }, ) ) return stored @app.get("/ai/usage", response_model=list[AiUsageRecord]) async def list_ai_usage( project_id: str | None = None, user_id: str | None = None, model: str | None = None, ) -> list[AiUsageRecord]: return _operations.list_ai_usage(project_id=project_id, user_id=user_id, model=model) @app.get("/ai/usage/summary", response_model=AiUsageSummary) async def ai_usage_summary( project_id: str | None = None, user_id: str | None = None, model: str | None = None, operation: str | None = None, ) -> AiUsageSummary: return _operations.summarize_ai_usage( project_id=project_id, user_id=user_id, model=model, operation=operation, ) @app.get("/ai/policy", response_model=AiPolicyResponse) async def ai_policy(user_id: str | None = None) -> AiPolicyResponse: used_tokens = _operations.summarize_ai_usage(user_id=user_id).total_tokens remaining = ( None if _ai_policy.token_limit_per_day is None else max(_ai_policy.token_limit_per_day - used_tokens, 0) ) return AiPolicyResponse(policy=_ai_policy, used_tokens=used_tokens, remaining_tokens=remaining) @app.post("/projects/{project_id}/ai/answer-policy", response_model=AiAnswerPolicyDecision) async def ai_answer_policy( project_id: str, request: AiAnswerPolicyRequest, ) -> AiAnswerPolicyDecision: _project_snapshot_or_404(project_id) used_tokens = _operations.summarize_ai_usage(user_id=request.user_id).total_tokens remaining = ( None if _ai_policy.token_limit_per_day is None else max(_ai_policy.token_limit_per_day - used_tokens, 0) ) reasons: list[str] = [] warnings: list[str] = [] if remaining is not None and request.estimated_tokens > remaining: reasons.append("AI token limit would be exceeded") lineage_set = set(request.related_lineages) related_knowledge = [ record for record in _knowledge.list_records() if lineage_set.intersection(record.related_lineages) ] if not related_knowledge and request.question.strip(): related_knowledge = [ result.record for result in _knowledge.search(request.question, limit=5) ] if request.require_knowledge and not related_knowledge: reasons.append("No knowledge context found for answer") elif not related_knowledge: warnings.append("No knowledge context found for answer") privacy_markers = [ marker for marker in _privacy.markers_for_project(project_id) if marker.target_id in lineage_set ] blocked_classifications = {PrivacyClassification.PERSONAL_DATA, PrivacyClassification.SECRET} if ( _ai_policy.privacy_mode == PrivacyMode.LOCAL_ONLY and not _ai_policy.allow_code_context and any(marker.classification in blocked_classifications for marker in privacy_markers) ): reasons.append("Privacy policy blocks sensitive 1C context") if privacy_markers and _ai_policy.privacy_mode == PrivacyMode.LOCAL_ONLY: warnings.append("Answer must stay inside local-only privacy mode") if request.model and not _ai_policy.allow_external_calls and request.model.casefold().startswith("external"): reasons.append("External AI model calls are disabled") return AiAnswerPolicyDecision( allowed=not reasons, reasons=reasons, warnings=warnings, knowledge_records=related_knowledge[:5], privacy_markers=privacy_markers, used_tokens=used_tokens, remaining_tokens=remaining, policy=_ai_policy, ) @app.get("/projects/{project_id}/report") async def project_report(project_id: str) -> dict: snapshot = _project_snapshot_or_404(project_id) payload = build_project_report(snapshot).model_dump(mode="json") owned_lineages = { ownership.target_id for ownership in _collaboration.owners_for_project(project_id) } unowned_objects = sorted( node.qualified_name for node in snapshot.nodes if node.kind in _OWNERSHIP_TARGET_KINDS and node.lineage_id not in owned_lineages ) privacy_markers = _privacy.markers_for_project(project_id) sensitive_candidates = _sensitive_privacy_candidates(snapshot) classified_lineages = {marker.target_id for marker in privacy_markers} ai_usage = _operations.summarize_ai_usage(project_id=project_id) payload.update( { "ownership_count": len(owned_lineages), "unowned_object_count": len(unowned_objects), "unowned_objects": unowned_objects, "privacy_marker_count": len(privacy_markers), "sensitive_candidate_count": len(sensitive_candidates), "unclassified_sensitive_count": sum( 1 for node in sensitive_candidates if node.lineage_id not in classified_lineages ), "ai_usage_request_count": ai_usage.request_count, "ai_usage_total_tokens": ai_usage.total_tokens, "ai_usage_cost": ai_usage.cost, } ) return payload @app.post("/marketplace/packages", response_model=MarketplacePackage) async def upsert_marketplace_package(package: MarketplacePackage) -> MarketplacePackage: stored = _operations.upsert_marketplace_package(package) _storage.write_document( "marketplace_packages", stored.package_id, stored.model_dump(mode="json"), ) return stored @app.get("/marketplace/packages", response_model=list[MarketplacePackage]) async def list_marketplace_packages(enabled_only: bool = False) -> list[MarketplacePackage]: return _operations.list_marketplace_packages(enabled_only) @app.get("/license", response_model=LicenseState) async def license_state() -> LicenseState: return _operations.license_state @app.get("/admin/summary") async def admin_summary() -> dict: return { "indexed_projects": len(_snapshots), "stored_snapshots": _storage.count_snapshots(), "knowledge_records": len(_knowledge.list_records()), "knowledge_packs": len(_knowledge.list_packs()), "users": len(_collaboration.users), "tasks": len(_collaboration.tasks), "comments": len(_collaboration.comments), "ownership": len(_collaboration.ownership), "privacy_markers": len(_privacy.markers), "ai_usage_records": len(_operations.ai_usage), "jobs": len(_operations.jobs), "metrics": len(_operations.metrics), "marketplace_packages": len(_operations.marketplace), } def _summary(snapshot: SirSnapshot) -> SnapshotSummary: return SnapshotSummary( snapshot_id=snapshot.snapshot_id, project_id=snapshot.project_id, snapshot_hash=snapshot.snapshot_hash, node_count=len(snapshot.nodes), edge_count=len(snapshot.edges), diagnostics_count=len(snapshot.diagnostics), unresolved_references_count=len(snapshot.unresolved_references), ) def _schema_knowledge_review(snapshot: SirSnapshot) -> list[dict]: schema_kinds = {NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION} coverage = [ item for item in _knowledge.coverage(snapshot) if item.node.kind in schema_kinds and item.record_count == 0 ] return [ { "finding_id": f"finding.knowledge.schema.uncovered.{item.node.lineage_id}", "title": "Missing 1C schema knowledge", "severity": DiagnosticSeverity.INFO.value, "message": f"Schema node {item.node.qualified_name} has no related knowledge records", "source_path": item.node.source_ref.source_path, "line_start": item.node.source_ref.line_start, } for item in coverage ] def _ownership_review(snapshot: SirSnapshot) -> list[dict]: owned_lineages = { ownership.target_id for ownership in _collaboration.owners_for_project(snapshot.project_id) } unowned_objects = sorted( ( node for node in snapshot.nodes if node.kind in _OWNERSHIP_TARGET_KINDS and node.lineage_id not in owned_lineages ), key=lambda item: item.qualified_name, ) return [ { "finding_id": f"finding.ownership.unassigned.{node.lineage_id}", "title": "Missing 1C object owner", "severity": DiagnosticSeverity.INFO.value, "message": f"1C object {node.qualified_name} has no assigned owner", "source_path": node.source_ref.source_path, "line_start": node.source_ref.line_start, } for node in unowned_objects ] def _privacy_review(snapshot: SirSnapshot) -> list[dict]: classified_lineages = { marker.target_id for marker in _privacy.markers_for_project(snapshot.project_id) } return [ { "finding_id": f"finding.privacy.unclassified.{node.lineage_id}", "title": "Unclassified sensitive 1C field", "severity": DiagnosticSeverity.INFO.value, "message": f"1C field {node.qualified_name} looks sensitive but has no privacy classification", "source_path": node.source_ref.source_path, "line_start": node.source_ref.line_start, } for node in _sensitive_privacy_candidates(snapshot) if node.lineage_id not in classified_lineages ] def _sensitive_privacy_candidates(snapshot: SirSnapshot) -> list: candidates = [] for node in snapshot.nodes: if node.kind != NodeKind.ATTRIBUTE: continue haystack = f"{node.name} {node.qualified_name}".casefold() if any(hint in haystack for hint in _SENSITIVE_NAME_HINTS): candidates.append(node) return sorted(candidates, key=lambda item: item.qualified_name) def _index_and_store(source_path: Path, project_id: str | None, *, structure_only: bool = False) -> SirSnapshot: snapshot = index_project(source_path, project_id=project_id, structure_only=structure_only) graph = InMemoryProjection() graph.project_snapshot(snapshot) _snapshots[snapshot.project_id] = snapshot _graphs[snapshot.project_id] = graph _storage.save_snapshot(snapshot) if not structure_only: _append_and_persist_versions(snapshot) return snapshot def _create_mock_import_fixture( project_id: str, source: ImportSourceKind, metadata: dict, ) -> Path: root = _storage.root / "imports" / project_id root.mkdir(parents=True, exist_ok=True) platform_version = metadata.get("platform_version", "8.3.24") compatibility_mode = metadata.get("compatibility_mode", "8.3.20") (root / "metadata.xml").write_text( f"""
ОстаткиТоваров РегистрНакопления.ОстаткиТоваров Номенклатура Количество
""".strip(), encoding="utf-8", ) (root / "ОбменССайтом.bsl").write_text( """ Процедура ВыполнитьОбмен() Экспорт Запрос = Новый Запрос("ВЫБРАТЬ Контрагенты.Ссылка ИЗ Справочник.Контрагенты КАК Контрагенты"); Результат = Запрос.Выполнить(); КонецПроцедуры """.strip() + "\n", encoding="utf-8", ) return root def _source_requires_runtime(source: ImportSourceKind) -> bool: return source in { ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE, ImportSourceKind.LIVE_INFOBASE, ImportSourceKind.ARCHIVE_DUMP, } def _looks_like_metadata_dump(source_path: Path) -> bool: if not source_path.exists() or not source_path.is_dir(): return False for pattern in ("*.xml", "*.mdo", "*.bsl"): try: next(source_path.rglob(pattern)) return True except StopIteration: continue return False def _call_runtime_adapter(project_id: str, request: ImportRequest) -> dict: url = os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010").rstrip("/") payload = { "project_id": project_id, "source_kind": request.source.value, "path": request.path, "credentials_ref": request.credentials_ref, "metadata": request.metadata, } data = json.dumps(payload).encode("utf-8") http_request = urllib.request.Request( f"{url}/runtime/import", data=data, method="POST", headers={"Content-Type": "application/json", "Accept": "application/json"}, ) try: with urllib.request.urlopen(http_request, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: if os.environ.get("RUNTIME_ADAPTER_MODE", "mock") == "mock": return { "status": "mock_indexed", "mode": "mock", "platform_found": False, "diagnostics": [f"Runtime adapter unavailable, API mock import used: {error}"], } raise HTTPException(status_code=503, detail=f"Runtime adapter unavailable: {error}") from error def _import_source_infos() -> list[ImportSourceInfo]: runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock") return [ ImportSourceInfo( kind=kind, title=str(payload["title"]), statuses=list(payload["statuses"]), runtime_mode=runtime_mode, available=runtime_mode != "mock" or ImportSourceStatus.REQUIRES_1C_PLATFORM not in payload["statuses"], ) for kind, payload in _IMPORT_SOURCE_REGISTRY.items() ] def _preflight_check(code: str, title: str, status: str, message: str) -> ImportPreflightCheck: return ImportPreflightCheck(code=code, title=title, status=status, message=message) def _project_settings_or_404(project_id: str) -> ProjectSettingsRequest: if project_id not in _project_setup and not _storage.has_snapshot(project_id): raise HTTPException(status_code=404, detail=f"Project not found: {project_id}") return ProjectSettingsRequest.model_validate(_project_setup.get(project_id, {}).get("settings", {})) def _agent_string_value(agent: dict, key: str) -> str: value = agent.get(key) return value.strip() if isinstance(value, str) else "" def _published_value(request_value: str | None, agent: dict, *keys: str) -> str: if request_value and request_value.strip(): return request_value.strip() for key in keys: value = _agent_string_value(agent, key) if value: return value return "" def _published_join_url(base_url: str, path: str) -> str: base = base_url.strip().rstrip("/") + "/" clean_path = path.strip() or "/odata/standard.odata" return urljoin(base, clean_path.lstrip("/")) def _published_base_url(server_url: str, infobase: str) -> str: server = server_url.strip().rstrip("/") if server and not urlsplit(server).scheme: server = f"http://{server}" if not infobase.strip(): return server return _published_join_url(server, infobase.strip()) def _published_http_service_path(http_path: str, service_root: str, health_path: str) -> str: explicit_path = http_path.strip().replace("\\", "/") if explicit_path: parts = [part for part in explicit_path.strip("/").split("/") if part] if parts and parts[0].lower() == "hs": return "/" + "/".join(parts) return "/hs/" + "/".join(parts) root = service_root.strip().strip("/").replace("\\", "/") or "sfera" endpoint = health_path.strip().strip("/").replace("\\", "/") or "health" return "/hs/" + "/".join(part for part in [root, endpoint] if part) def _published_fetch( url: str, username: str, password: str, timeout_seconds: float, accept: str, max_bytes: int = 50 * 1024 * 1024, method: str = "GET", body: bytes | None = None, extra_headers: dict[str, str] | None = None, ) -> tuple[int, str, bytes]: headers = {"Accept": accept, "User-Agent": "SFERA published-infobase-check"} if body is not None: headers["Content-Type"] = "application/json; charset=utf-8" if extra_headers: headers.update(extra_headers) if username or password: token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") headers["Authorization"] = f"Basic {token}" request = urllib.request.Request(url, data=body, headers=headers, method=method) with urllib.request.urlopen(request, timeout=max(1.0, min(timeout_seconds, 60.0))) as response: body = response.read(max_bytes + 1) if len(body) > max_bytes: raise ValueError(f"Response is larger than {max_bytes // (1024 * 1024)} MB.") return response.status, response.headers.get("content-type", ""), body def _xml_local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1] if "}" in tag else tag def _parse_odata_metadata(body: bytes) -> tuple[list[PublishedInfobaseEntity], int, int, int]: root = ET.fromstring(body) entity_sets: list[PublishedInfobaseEntity] = [] entity_types = 0 functions = 0 actions = 0 for element in root.iter(): local_name = _xml_local_name(element.tag) if local_name == "EntitySet": name = element.attrib.get("Name") or element.attrib.get("name") if name: entity_sets.append( PublishedInfobaseEntity(name=name, entity_type=element.attrib.get("EntityType") or element.attrib.get("entityType")) ) elif local_name == "EntityType": entity_types += 1 elif local_name == "Function": functions += 1 elif local_name == "Action": actions += 1 return entity_sets, entity_types, functions, actions def _published_error_message(error: Exception, *, endpoint_kind: str = "publication") -> str: if isinstance(error, urllib.error.HTTPError): if error.code == 401: return "Публикация требует авторизацию или логин/пароль неверные." if error.code == 403: return "Доступ к публикации запрещен для указанных учетных данных." if error.code == 404: if endpoint_kind == "sfera_extension": return ( "HTTP-сервис SFERA не найден в публикации. Если расширение установлено, проверьте default.vrd: " 'для нужен атрибут publishExtensionsByDefault="true", затем перезапустите публикацию/IIS.' ) return "Адрес публикации или OData путь не найден." return f"HTTP {error.code}: {error.reason}" if isinstance(error, urllib.error.URLError): return f"Не удалось подключиться: {error.reason}" return str(error) def _check_published_infobase( project_id: str, settings: ProjectSettingsRequest, request: PublishedInfobaseCheckRequest, ) -> PublishedInfobaseCheckResponse: agent = settings.agent if isinstance(settings.agent, dict) else {} server_url = _published_value(request.server_url, agent, "published_server_url", "published_server", "live_server_url") infobase = _published_value(request.infobase, agent, "published_infobase", "live_infobase") base_url = _published_value(request.base_url, agent, "published_base_url", "live_base_url", "live_connection") if not base_url and server_url: base_url = _published_base_url(server_url, infobase) username = _published_value(request.username, agent, "published_user", "live_user") password = _published_value(request.password, agent, "published_password", "live_password") odata_path = _published_value(request.odata_path, agent, "published_odata_path", "odata_path") or "/odata/standard.odata" extension_http_path_raw = _published_value( request.extension_http_path, agent, "published_extension_http_path", "sfera_extension_http_path", ) extension_service_root = _published_value( request.extension_service_root, agent, "published_extension_service_root", "sfera_extension_service_root", ) or "sfera" extension_health_path = _published_value( request.extension_health_path, agent, "published_extension_health_path", "sfera_extension_health_path", ) or "health" extension_http_path = _published_http_service_path(extension_http_path_raw, extension_service_root, extension_health_path) checks: list[ImportPreflightCheck] = [] if not base_url: return PublishedInfobaseCheckResponse( project_id=project_id, status="BLOCKED", ready=False, checks=[ _preflight_check( "base_url", "URL публикации", "BLOCKED", "Укажите URL опубликованной базы 1С, например http://server/upo_test.", ) ], ) if not urlsplit(base_url).scheme: base_url = f"http://{base_url}" odata_url = _published_join_url(base_url, odata_path) metadata_url = _published_join_url(odata_url, "$metadata") checks.append(_preflight_check("base_url", "URL публикации", "OK", f"Проверяем публикацию: {base_url.rstrip('/')}")) configured_agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE) can_install_extension = bool( configured_agent_id and (_agent_string_value(agent, "one_c_server") or server_url) and (_agent_string_value(agent, "one_c_infobase") or infobase) ) checks.append( _preflight_check( "extension_install", "Установка расширения", "OK" if can_install_extension else "WARNING", f"Расширение можно устанавливать через агент {configured_agent_id}." if can_install_extension else "Для автоматической установки расширения нужен выбранный CF/CFE агент и параметры подключения к базе 1С.", ) ) odata_available = True try: service_status, service_type, _ = _published_fetch( odata_url, username, password, request.timeout_seconds, "application/json,application/xml,text/xml,*/*", max_bytes=1024 * 1024, ) checks.append( _preflight_check( "odata", "OData service root", "OK" if 200 <= service_status < 400 else "WARNING", f"OData отвечает HTTP {service_status}. Content-Type: {service_type or 'не указан'}.", ) ) except Exception as error: odata_available = False checks.append(_preflight_check("odata", "OData service root", "BLOCKED", _published_error_message(error))) extension_url: str | None = _published_join_url(base_url, extension_http_path) try: extension_status, extension_type, extension_body = _published_fetch( extension_url, username, password, request.timeout_seconds, "application/json,text/plain,application/xml,*/*", max_bytes=1024 * 1024, ) body_preview = extension_body[:200].decode("utf-8", errors="replace").strip() checks.append( _preflight_check( "sfera_extension", "Расширение SFERA опубликовано", "OK" if 200 <= extension_status < 400 else "WARNING", f"HTTP-сервис проверяется по правилу /hs/<корень>/<путь>: {extension_http_path}. " f"Endpoint отвечает {extension_status}. Content-Type: {extension_type or 'не указан'}." + (f" Ответ: {body_preview}" if body_preview else ""), ) ) except Exception as error: checks.append( _preflight_check( "sfera_extension", "Расширение SFERA опубликовано", "WARNING", f"HTTP-сервис проверяется по правилу /hs/<корень>/<путь>: {extension_http_path}. " f"Endpoint не отвечает: {_published_error_message(error, endpoint_kind='sfera_extension')}", ) ) if not odata_available: return PublishedInfobaseCheckResponse( project_id=project_id, status="BLOCKED", ready=False, base_url=base_url.rstrip("/"), odata_url=odata_url, metadata_url=metadata_url, extension_url=extension_url, checks=checks, ) try: metadata_status, metadata_type, metadata_body = _published_fetch( metadata_url, username, password, request.timeout_seconds, "application/xml,text/xml,*/*", ) entity_sets, entity_types, functions, actions = _parse_odata_metadata(metadata_body) checks.append( _preflight_check( "metadata", "OData $metadata", "OK" if entity_sets or entity_types else "WARNING", f"$metadata HTTP {metadata_status}. Найдено наборов: {len(entity_sets)}, типов: {entity_types}. Content-Type: {metadata_type or 'не указан'}.", ) ) status = "READY" if entity_sets or entity_types else "WARNING" if any(check.status == "BLOCKED" for check in checks): status = "BLOCKED" elif any(check.status == "WARNING" for check in checks): status = "WARNING" return PublishedInfobaseCheckResponse( project_id=project_id, status=status, ready=status in {"READY", "WARNING"} and bool(entity_sets or entity_types), base_url=base_url.rstrip("/"), odata_url=odata_url, metadata_url=metadata_url, extension_url=extension_url, entity_sets_count=len(entity_sets), entity_types_count=entity_types, functions_count=functions, actions_count=actions, entity_sets=entity_sets[:25], checks=checks, ) except Exception as error: checks.append(_preflight_check("metadata", "OData $metadata", "BLOCKED", _published_error_message(error))) return PublishedInfobaseCheckResponse( project_id=project_id, status="BLOCKED", ready=False, base_url=base_url.rstrip("/"), odata_url=odata_url, metadata_url=metadata_url, extension_url=extension_url, checks=checks, ) _SFERA_EXTENSION_MUTATION_OPERATIONS = { "data.write", "data.delete", "metadata.apply", "admin.command", } def _published_connection_from_settings(settings: ProjectSettingsRequest) -> dict[str, str]: agent = settings.agent if isinstance(settings.agent, dict) else {} server_url = _published_value(None, agent, "published_server_url", "published_server", "live_server_url") infobase = _published_value(None, agent, "published_infobase", "live_infobase") base_url = _published_value(None, agent, "published_base_url", "live_base_url", "live_connection") if not base_url and server_url: base_url = _published_base_url(server_url, infobase) if base_url and not urlsplit(base_url).scheme: base_url = f"http://{base_url}" service_root = (_published_value(None, agent, "published_extension_service_root", "sfera_extension_service_root") or "sfera").strip("/") extension_path = f"/hs/{service_root}" return { "base_url": base_url.rstrip("/") if base_url else "", "username": _published_value(None, agent, "published_user", "live_user", "one_c_user", "cf_user"), "password": _published_value(None, agent, "published_password", "live_password", "one_c_password", "cf_password"), "extension_root_url": _published_join_url(base_url, extension_path).rstrip("/") if base_url else "", "token": _published_value(None, agent, "sfera_extension_token", "published_extension_token"), "allow_mutation": str(agent.get("sfera_extension_allow_mutation") is True).lower(), } def _sfera_extension_operation_path(operation: str) -> str: mapping = { "health": "health", "metadata.snapshot": "v1/metadata", "data.read": "v1/data/read", "data.write": "v1/data/write", "query.execute": "v1/query", "metadata.apply": "v1/metadata/apply", "admin.command": "v1/admin/command", } return mapping.get(operation, "v1/call") def _call_sfera_extension( project_id: str, settings: ProjectSettingsRequest, request: SferaExtensionCallRequest, ) -> SferaExtensionCallResponse: connection = _published_connection_from_settings(settings) checks: list[ImportPreflightCheck] = [] operation = request.operation.strip() if not connection["base_url"]: return SferaExtensionCallResponse( project_id=project_id, operation=operation, status="BLOCKED", checks=[_preflight_check("base_url", "URL публикации", "BLOCKED", "Заполните сервер публикации и имя базы.")], ) checks.append(_preflight_check("base_url", "URL публикации", "OK", connection["base_url"])) mutation_requested = operation in _SFERA_EXTENSION_MUTATION_OPERATIONS and not request.dry_run mutation_allowed_by_project = connection["allow_mutation"] == "true" if mutation_requested and (not request.allow_mutation or not mutation_allowed_by_project): checks.append( _preflight_check( "mutation_guard", "Защита изменения данных", "BLOCKED", "Операция изменения заблокирована. Включите разрешение в проекте и передайте allow_mutation=true.", ) ) return SferaExtensionCallResponse( project_id=project_id, operation=operation, status="BLOCKED", dry_run=request.dry_run, extension_url=connection["extension_root_url"], checks=checks, ) checks.append( _preflight_check( "mutation_guard", "Защита изменения данных", "OK" if not mutation_requested else "WARNING", "Dry-run или read-only операция." if not mutation_requested else "Изменение разрешено настройками проекта и явным флагом.", ) ) operation_path = _sfera_extension_operation_path(operation) url = _published_join_url(connection["extension_root_url"], operation_path) payload = { "request_id": f"sfera-{uuid4()}", "project_id": project_id, "operation": operation, "dry_run": request.dry_run, "allow_mutation": request.allow_mutation, "payload": request.payload, } headers = {"X-SFERA-Project": project_id} if connection["token"]: headers["X-SFERA-Token"] = connection["token"] method = "GET" if operation == "health" else "POST" body = None if method == "GET" else json.dumps(payload, ensure_ascii=False).encode("utf-8") try: status_code, content_type, body = _published_fetch( url, connection["username"], connection["password"], request.timeout_seconds, "application/json,text/plain,*/*", method=method, body=body, extra_headers=headers, ) try: result = json.loads(body.decode("utf-8-sig")) if not isinstance(result, dict): result = {"value": result} except Exception: result = {"text": body[:100_000].decode("utf-8", errors="replace")} checks.append( _preflight_check( "extension_call", "Вызов расширения SFERA", "OK" if 200 <= status_code < 400 else "WARNING", f"{operation} -> HTTP {status_code}. Content-Type: {content_type or 'не указан'}.", ) ) return SferaExtensionCallResponse( project_id=project_id, operation=operation, status="READY" if 200 <= status_code < 400 else "WARNING", ready=200 <= status_code < 400, dry_run=request.dry_run, extension_url=url, result=result, checks=checks, ) except Exception as error: checks.append( _preflight_check( "extension_call", "Вызов расширения SFERA", "BLOCKED", _published_error_message(error, endpoint_kind="sfera_extension"), ) ) return SferaExtensionCallResponse( project_id=project_id, operation=operation, status="BLOCKED", ready=False, dry_run=request.dry_run, extension_url=url, checks=checks, ) def _import_check_response(project_id: str, source: ImportSourceKind, request: ImportRequest) -> ImportCheckResponse: registry = _IMPORT_SOURCE_REGISTRY[source] statuses = set(registry["statuses"]) runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock") checks: list[ImportPreflightCheck] = [ _preflight_check("source", "Import source", "OK", f"{source.value}: {registry['title']}"), ] path_required = source in { ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE, ImportSourceKind.XML_DUMP, ImportSourceKind.EDT_PROJECT, ImportSourceKind.ARCHIVE_DUMP, ImportSourceKind.FILE_TREE, ImportSourceKind.REFERENCE_CONFIGURATION, } if path_required: if request.path: path = Path(request.path) checks.append( _preflight_check( "path", "Source path", "OK" if path.exists() else "WARNING", f"Path {'exists' if path.exists() else 'not found, mock import can still run'}: {request.path}", ) ) else: checks.append( _preflight_check( "path", "Source path", "WARNING", "Path is empty; API mock import will be used unless runtime supplies a dump.", ) ) if ImportSourceStatus.REQUIRES_1C_PLATFORM in statuses: checks.append( _preflight_check( "runtime", "1C runtime", "OK" if runtime_mode != "mock" else "WARNING", f"Runtime mode is {runtime_mode}. mock mode will not execute Designer CLI.", ) ) if ImportSourceStatus.REQUIRES_CREDENTIALS in statuses: checks.append( _preflight_check( "credentials", "Credentials", "OK" if request.credentials_ref else "BLOCKED", "Credentials ref configured." if request.credentials_ref else "Credentials ref is required for live infobase.", ) ) if ImportSourceStatus.REQUIRES_AGENT in statuses: endpoint = str(request.metadata.get("agent_endpoint") or "") checks.append( _preflight_check( "agent", "Agent endpoint", "OK" if endpoint else "BLOCKED", f"Agent endpoint configured: {endpoint}" if endpoint else "Agent endpoint is required for agent snapshot.", ) ) if source == ImportSourceKind.CONTEXT_ONLY: description = str(request.metadata.get("context_description") or "") checks.append( _preflight_check( "context", "Context description", "OK" if description else "WARNING", "Context description configured." if description else "Context-only import has no ecosystem description yet.", ) ) if source == ImportSourceKind.REFERENCE_CONFIGURATION: checks.append( _preflight_check( "reference", "Reference configuration", "OK" if request.path or request.metadata.get("context_description") else "WARNING", "Reference configuration source configured." if request.path or request.metadata.get("context_description") else "Reference configuration can use a path or short description.", ) ) blocked = any(check.status == "BLOCKED" for check in checks) warning = any(check.status == "WARNING" for check in checks) return ImportCheckResponse( project_id=project_id, source=source, status="BLOCKED" if blocked else "WARNING" if warning else "READY", ready=not blocked, checks=checks, ) def _project_setup_response(project_id: str) -> ProjectSetupResponse: state = _project_setup.get(project_id, {}) if "import_history" not in state: try: state["import_history"] = _storage.read_document("project_import_history", project_id).get("items", []) except FileNotFoundError: pass if "last_import" not in state and state.get("import_history"): state["last_import"] = state["import_history"][0] settings = ProjectSettingsRequest.model_validate(state.get("settings", {})) status_value = state.get("status") if status_value: status = ProjectSetupStatus(status_value) elif project_id in _snapshots: status = ProjectSetupStatus.INDEXED elif _project_has_stored_snapshot(project_id): status = ProjectSetupStatus.INDEXED elif settings.structure_source is None: status = ProjectSetupStatus.NOT_CONFIGURED else: status = ProjectSetupStatus.IMPORT_REQUIRED last_import = state.get("last_import") current_source = state.get("current_source") or (last_import or {}).get("source") or ( settings.structure_source.value if settings.structure_source is not None else None ) message = ( "Проект не проиндексирован. Выберите адаптер загрузки данных 1С." if status in {ProjectSetupStatus.NOT_CONFIGURED, ProjectSetupStatus.IMPORT_REQUIRED} else "Данные 1С загружены в модель SFERA. Запустите индексацию перед рабочим экраном." if status == ProjectSetupStatus.STRUCTURE_INDEXED else "Данные 1С загружены в модель SFERA." ) return ProjectSetupResponse( project_id=project_id, status=status, message=message, settings=settings, current_source=ImportSourceKind(current_source) if current_source else None, last_import=ImportSummary.model_validate(last_import) if last_import else None, import_history=[ImportSummary.model_validate(item) for item in state.get("import_history", [])], import_sources=_import_source_infos(), ) def _project_has_stored_snapshot(project_id: str) -> bool: return _storage.has_snapshot(project_id) async def _html5_form_data(request: Request) -> dict[str, list[str]]: body = (await request.body()).decode("utf-8") return parse_qs(body, keep_blank_values=True) def _form_value(form: dict[str, list[str]], key: str) -> str | None: values = form.get(key) if not values: return None value = values[0].strip() return value or None def _html5_metadata_payload(form: dict[str, list[str]]) -> dict: return { "object_kind": _form_value(form, "object_kind") or "DOCUMENT", "name": _form_value(form, "name") or "", "synonym": _form_value(form, "synonym"), "attributes": _html5_metadata_attributes(_form_value(form, "attributes") or ""), "tabular_sections": _html5_metadata_tabular_sections(_form_value(form, "tabular_sections") or ""), "forms": _html5_csv_values(_form_value(form, "forms") or ""), "commands": _html5_metadata_commands(_form_value(form, "commands") or ""), "task_id": _form_value(form, "task_id"), "session_id": _form_value(form, "session_id"), "user_id": _form_value(form, "user_id"), "_raw_attributes": _form_value(form, "attributes") or "", "_raw_tabular_sections": _form_value(form, "tabular_sections") or "", "_raw_forms": _form_value(form, "forms") or "", "_raw_commands": _form_value(form, "commands") or "", } def _html5_metadata_request_payload(payload: dict) -> dict: return {key: value for key, value in payload.items() if not key.startswith("_raw_")} def _html5_csv_values(raw: str) -> list[str]: return [item.strip() for item in raw.replace("\n", ",").split(",") if item.strip()] def _html5_metadata_attributes(raw: str) -> list[dict]: attributes: list[dict] = [] for item in _html5_csv_values(raw): name, _, type_name = item.partition(":") if name.strip(): attributes.append({"name": name.strip(), "type": type_name.strip() or "Строка"}) return attributes def _html5_metadata_commands(raw: str) -> list[dict]: commands: list[dict] = [] for item in _html5_csv_values(raw): name, _, handler = item.partition(":") if name.strip(): commands.append({"name": name.strip(), "handler": handler.strip() or None}) return commands def _html5_metadata_tabular_sections(raw: str) -> list[dict]: sections: list[dict] = [] for item in _html5_csv_values(raw): name, _, attrs = item.partition("[") if not name.strip(): continue attributes = [] for attr in attrs.rstrip("]").split(";"): attr_name, _, attr_type = attr.partition(":") if attr_name.strip(): attributes.append({"name": attr_name.strip(), "type": attr_type.strip() or "Строка"}) sections.append({"name": name.strip(), "attributes": attributes}) return sections def _runtime_for_object_context(project_id: str, impact: ObjectImpactResponse) -> list[RuntimeSummaryResponse]: lineages = { item.lineage_id for group in [ [impact.object], impact.modules, impact.routines, impact.forms, impact.commands, impact.jobs, impact.writes, ] for item in group if item is not None } snapshot = _project_snapshot_or_404(project_id) overlay = _overlays.get(project_id, RuntimeOverlay(project_id=project_id)) return [ RuntimeSummaryResponse( node=_named_node(item.node), signal_count=item.signal_count, error_count=item.error_count, max_duration_ms=item.max_duration_ms, ) for item in summarize_runtime(snapshot, overlay) if item.node.lineage_id in lineages ] def _knowledge_for_object_context( schema: ObjectSchemaResponse, impact: ObjectImpactResponse, ui: ObjectUiResponse, ) -> list[KnowledgeRecord]: lineages: set[str] = {schema.object.lineage_id, impact.object.lineage_id} lineages.update(item.lineage_id for item in schema.attributes) for section in schema.tabular_sections: lineages.add(section.tabular_section.lineage_id) lineages.update(column.lineage_id for column in section.columns) for group in [ impact.modules, impact.routines, impact.forms, impact.commands, impact.roles, impact.jobs, impact.writes, impact.query_tables, ]: lineages.update(item.lineage_id for item in group) for form in ui.forms: lineages.add(form.form.lineage_id) lineages.update(command.lineage_id for command in form.commands) lineages.update(element.lineage_id for element in form.elements) lineages.update(handler.lineage_id for handler in form.command_handlers.values()) records = [ record for record in _knowledge.list_records() if lineages.intersection(record.related_lineages) ] return sorted(records, key=lambda item: item.title.lower())[:12] def _integrations_for_object_context( project_id: str, impact: ObjectImpactResponse, ) -> list[IntegrationEndpointResponse]: owner_names = { name for group in [impact.modules, impact.routines] for item in group for name in [item.qualified_name, item.name] if name } if not owner_names: return [] snapshot = _project_snapshot_or_404(project_id) return [ IntegrationEndpointResponse( endpoint_id=endpoint.endpoint_id, name=endpoint.name, kind=endpoint.kind.value, direction=endpoint.direction, owner=endpoint.owner, attributes=endpoint.attributes, ) for endpoint in build_integration_topology(snapshot).endpoints if endpoint.owner in owner_names ] def _source_node_for_object_context( project_id: str, impact: ObjectImpactResponse, ) -> object | None: snapshot = _project_snapshot_or_404(project_id) for module in impact.modules: node = _find_snapshot_node(snapshot, module.lineage_id) if node is not None: return node return None def _review_for_object_context( project_id: str, schema: ObjectSchemaResponse, impact: ObjectImpactResponse, ui: ObjectUiResponse, ) -> list[dict]: snapshot = _project_snapshot_or_404(project_id) nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes} names: set[str] = set() lineages: set[str] = set() def add_node(node: NamedNode | None) -> None: if node is None: return lineages.add(node.lineage_id) names.add(node.name.casefold()) names.add(node.qualified_name.casefold()) add_node(schema.object) for attribute in schema.attributes: add_node(attribute) for section in schema.tabular_sections: add_node(section.tabular_section) for column in section.columns: add_node(column) for group in [ impact.modules, impact.routines, impact.forms, impact.commands, impact.roles, impact.jobs, impact.callees, impact.query_tables, impact.writes, ]: for node in group: add_node(node) for form in ui.forms: add_node(form.form) for node in [*form.commands, *form.elements, *form.command_handlers.values()]: add_node(node) source_paths = { node.source_ref.source_path for lineage_id in lineages if (node := nodes_by_lineage.get(lineage_id)) is not None and node.source_ref is not None } focused: list[dict] = [] for finding in get_review_payload(snapshot): source_path = finding.get("source_path") haystack = " ".join( str(finding.get(key) or "").casefold() for key in ["title", "message", "source_path"] ) if (source_path and source_path in source_paths) or any(name and name in haystack for name in names): focused.append(finding) return focused def _current_import_source(project_id: str) -> ImportSourceKind: setup = _project_setup_response(project_id) if setup.current_source is not None: return setup.current_source if setup.settings.structure_source is not None: return setup.settings.structure_source return ImportSourceKind.XML_DUMP def _html5_operation_jobs(project_id: str = "", status: str = "", kind: str = "") -> list[OperationJob]: normalized_project = project_id.strip().casefold() normalized_status = status.strip().casefold() normalized_kind = kind.strip().casefold() jobs = [] for job in _operations.jobs.values(): payload = job.payload or {} if normalized_project and str(payload.get("project_id") or "").casefold() != normalized_project: continue if normalized_status and _operation_value(getattr(job, "status", "")).casefold() != normalized_status: continue if normalized_kind and _operation_value(getattr(job, "kind", "")).casefold() != normalized_kind: continue jobs.append(job) return sorted(jobs, key=lambda job: job.updated_at, reverse=True)[:50] def _html5_latest_import_job(project_id: str) -> OperationJob | None: jobs = [ job for job in _operations.jobs.values() if job.payload.get("project_id") == project_id and _operation_value(getattr(job, "kind", "")) == "SERVER_IMPORT" ] return max(jobs, key=lambda job: job.updated_at) if jobs else None def _operation_value(value: object) -> str: return str(getattr(value, "value", value)) def _html5_sse_event(event: str, fragment: str) -> str: data = "\n".join(f"data: {line}" for line in fragment.splitlines()) return f"event: {event}\nretry: 5000\n{data}\n\n" def _html5_sse_if_changed(last_fragments: dict[str, str], event: str, fragment: str): if last_fragments.get(event) == fragment: return last_fragments[event] = fragment yield _html5_sse_event(event, fragment) def _html5_sse_comment(message: str) -> str: return f": {message}\n\n" def _project_summaries() -> list[ProjectSummaryResponse]: project_ids = set(_project_setup.keys()) stored_snapshots = _storage.list_snapshot_refs() project_ids.update(snapshot.project_id for snapshot in stored_snapshots) snapshot_project_ids = {snapshot.project_id for snapshot in stored_snapshots} result: list[ProjectSummaryResponse] = [] for project_id in sorted(project_ids, key=lambda item: item.lower()): state = _project_setup.get(project_id, {}) settings = ProjectSettingsRequest.model_validate(state.get("settings", {})) status_value = state.get("status") if status_value: status = ProjectSetupStatus(status_value) elif project_id in snapshot_project_ids: status = ProjectSetupStatus.INDEXED elif settings.structure_source is None: status = ProjectSetupStatus.NOT_CONFIGURED else: status = ProjectSetupStatus.IMPORT_REQUIRED result.append( ProjectSummaryResponse( project_id=project_id, name=settings.name or project_id, status=status, has_snapshot=project_id in snapshot_project_ids, ) ) return result def _normalize_project_id(project_id: str) -> str: return re.sub(r"[^\w_.-]+", "-", project_id.strip(), flags=re.UNICODE).strip("-") def _delete_project_data(project_id: str, request: ProjectDeleteRequest) -> list[str]: deleted: list[str] = [] had_indexed_data = ( project_id in _snapshots or project_id in _normalized_projects or _storage.has_snapshot(project_id) ) _project_setup.pop(project_id, None) _snapshots.pop(project_id, None) _graphs.pop(project_id, None) _neo4j_projected_projects.discard(project_id) _normalized_projects.pop(project_id, None) if _storage.delete_snapshot(project_id): deleted.append("snapshot") for collection in ["project_import_history", "normalized_projects"]: if _storage.delete_document(collection, project_id): deleted.append(collection) if request.delete_settings and _storage.delete_document("project_settings", project_id): deleted.append("project_settings") if request.delete_imports: for folder in [ _storage.root / "imports" / _safe_storage_name(project_id), _storage.root / "smb_imports" / _safe_storage_name(project_id), ]: if folder.exists(): shutil.rmtree(folder) deleted.append(folder.name) if request.delete_versions and had_indexed_data: count = _storage.delete_documents_matching("object_versions", lambda payload: payload.get("project_id") == project_id) if count: deleted.append(f"object_versions:{count}") if request.delete_collaboration: task_ids = {task_id for task_id, task in _collaboration.tasks.items() if task.project_id == project_id} for task_id in task_ids: _collaboration.tasks.pop(task_id, None) _collaboration.sessions = { session_id: session for session_id, session in _collaboration.sessions.items() if session.task_id not in task_ids } _collaboration.comments = { comment_id: comment for comment_id, comment in _collaboration.comments.items() if comment.project_id != project_id } _collaboration.ownership = { key: ownership for key, ownership in _collaboration.ownership.items() if ownership.project_id != project_id } _collaboration.activities = [event for event in _collaboration.activities if event.project_id != project_id] _privacy.markers = {key: marker for key, marker in _privacy.markers.items() if marker.project_id != project_id} for collection in [ "collaboration_comments", "collaboration_ownership", "collaboration_tasks", "collaboration_sessions", "authoring_changes", "privacy_markers", ]: if collection == "collaboration_sessions": count = _storage.delete_documents_matching(collection, lambda payload: payload.get("task_id") in task_ids) else: count = _storage.delete_documents_matching(collection, lambda payload: payload.get("project_id") == project_id) if count: deleted.append(f"{collection}:{count}") ai_usage_count = _storage.delete_documents_matching("ai_usage", lambda payload: payload.get("project_id") == project_id) if ai_usage_count: _operations.ai_usage = { usage_id: usage for usage_id, usage in _operations.ai_usage.items() if usage.project_id != project_id } deleted.append(f"ai_usage:{ai_usage_count}") operation_count = _storage.delete_documents_matching( "operations_jobs", lambda payload: payload.get("payload", {}).get("project_id") == project_id, ) if operation_count: _operations.jobs = { job_id: job for job_id, job in _operations.jobs.items() if job.payload.get("project_id") != project_id } deleted.append(f"operations_jobs:{operation_count}") for job_id, job in list(_agent_import_jobs.items()): if job.project_id == project_id: _agent_import_jobs.pop(job_id, None) _storage.delete_document("agent_import_jobs", job_id) deleted.append(f"agent_import_job:{job_id}") return deleted def _setup_status_after_import( summary: ImportSummary, request: ImportRequest, snapshot: SirSnapshot | None, ) -> ProjectSetupStatus: if request.structure_only or summary.status == "structure_indexed": return ProjectSetupStatus.STRUCTURE_INDEXED if summary.status in {"designer_dump_planned", "queued_for_remote_worker"}: return ProjectSetupStatus.IMPORTED if snapshot is not None: return ProjectSetupStatus.INDEXED return ProjectSetupStatus.IMPORTED def _prepare_project_full_replace(project_id: str) -> None: _snapshots.pop(project_id, None) _graphs.pop(project_id, None) _normalized_projects.pop(project_id, None) def _materialize_import_path( project_id: str, request: ImportRequest, errors: list[str], progress: ImportProgressCallback | None = None, ) -> Path | None: if not request.path: return None path = request.path.strip() if not path.startswith("\\\\"): return Path(path) credentials = request.metadata.get("smb_credentials") if not isinstance(credentials, dict): errors.append("Для UNC/SMB пути нужны учетные данные SMB: логин и пароль.") return None username = str(credentials.get("username") or "").strip() password = str(credentials.get("password") or "") if not username or not password: errors.append("Для UNC/SMB пути нужны учетные данные SMB: логин и пароль.") return None try: target = _storage.root / "smb_imports" / _safe_storage_name(project_id) if target.exists(): _emit_import_progress(progress, "Очистка предыдущей временной копии SMB", stage="copying") shutil.rmtree(target) target.mkdir(parents=True, exist_ok=True) _emit_import_progress(progress, f"Копирование SMB-каталога: {path}", stage="copying", bytes_copied=0, files_copied=0) _copy_smb_tree_to_local( source=path, target=target, username=username, password=password, domain=str(credentials.get("domain") or "") or None, progress=progress, ) _emit_import_progress(progress, "SMB-каталог скопирован", stage="copying_done") return target except Exception as error: errors.append(f"SMB import copy failed: {error}") return None def _import_path_is_blocked(request: ImportRequest, errors: list[str]) -> bool: return bool(errors and request.path and request.path.strip().startswith("\\\\")) def _sanitize_import_metadata(metadata: dict) -> dict: cleaned = dict(metadata) if "smb_credentials" in cleaned: credentials = cleaned.get("smb_credentials") cleaned["smb_credentials"] = { "username": credentials.get("username") if isinstance(credentials, dict) else None, "domain": credentials.get("domain") if isinstance(credentials, dict) else None, "password": "", } for key in ("one_c_password", "password"): if key in cleaned: cleaned[key] = "" return cleaned def _import_project_sync_preview(project_id: str, request: ImportRequest) -> ImportSummary: errors: list[str] = [] source_path = _materialize_import_path(project_id, request, errors) safe_metadata = _sanitize_import_metadata(request.metadata) snapshot: SirSnapshot | None = None next_normalized: NormalizedProject | None = None runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock") if _import_path_is_blocked(request, errors): return _import_summary_from_snapshot( project_id=project_id, source=request.source, status="sync_preview_blocked", snapshot=None, errors=errors, metadata=safe_metadata, runtime_mode=runtime_mode, runtime_diagnostics=["SYNC_PREVIEW is preview-only; no project data was replaced."], normalized=None, mode=ImportMode.SYNC_PREVIEW, applied=False, sync_preview=_build_import_sync_preview(None, None), ) if request.run_indexing and source_path is not None and source_path.exists(): snapshot = index_project(source_path, project_id=project_id, structure_only=request.structure_only) try: next_normalized = normalize_one_c_project(source_path, project_id=project_id) except Exception as error: errors.append(f"Normalized sync preview failed: {error}") elif request.run_indexing: if source_path is not None and not source_path.exists(): errors.append(f"Path not found, mock sync preview used: {request.path}") mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata) snapshot = index_project(mock_root, project_id=project_id, structure_only=request.structure_only) next_normalized = normalize_one_c_project(mock_root, project_id=project_id) current_normalized = _load_normalized_project(project_id) sync_preview = _build_import_sync_preview(current_normalized, next_normalized) summary = _import_summary_from_snapshot( project_id=project_id, source=request.source, status="sync_preview", snapshot=snapshot, errors=errors, metadata=safe_metadata, runtime_mode=runtime_mode, runtime_diagnostics=["SYNC_PREVIEW is preview-only; no project data was replaced."], normalized=next_normalized, mode=ImportMode.SYNC_PREVIEW, applied=False, sync_preview=sync_preview, ) return summary def _build_import_sync_preview( current: NormalizedProject | None, incoming: NormalizedProject | None, ) -> ImportSyncPreview: current_index = _normalized_object_hash_index(current) incoming_index = _normalized_object_hash_index(incoming) items: list[ImportSyncDiffItem] = [] for qualified_name in sorted(set(current_index) | set(incoming_index)): before = current_index.get(qualified_name) after = incoming_index.get(qualified_name) if before is None and after is not None: change_kind = "ADD" elif before is not None and after is None: change_kind = "REMOVE" elif before is not None and after is not None and before["hash"] != after["hash"]: change_kind = "UPDATE" else: change_kind = "UNCHANGED" source = after or before or {} items.append( ImportSyncDiffItem( qualified_name=qualified_name, name=str(source.get("name", qualified_name)), object_kind=str(source.get("object_kind", "UNKNOWN")), group_name=source.get("group_name"), change_kind=change_kind, before_hash=before["hash"] if before is not None else None, after_hash=after["hash"] if after is not None else None, ) ) added_count = sum(1 for item in items if item.change_kind == "ADD") removed_count = sum(1 for item in items if item.change_kind == "REMOVE") changed_count = sum(1 for item in items if item.change_kind == "UPDATE") unchanged_count = sum(1 for item in items if item.change_kind == "UNCHANGED") return ImportSyncPreview( message=( "Synchronization is not applied yet. This preview shows what would change; " "use FULL_REPLACE to replace current project data." ), added_count=added_count, removed_count=removed_count, changed_count=changed_count, unchanged_count=unchanged_count, items=[item for item in items if item.change_kind != "UNCHANGED"], ) def _normalized_object_hash_index(normalized: NormalizedProject | None) -> dict[str, dict]: if normalized is None: return {} result: dict[str, dict] = {} for group in normalized.configuration.groups: for item in group.objects: payload = item.model_dump(mode="json") result[item.qualified_name] = { "name": item.name, "object_kind": item.object_kind, "group_name": group.name, "hash": stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True)), } return result def _append_import_history(project_id: str, summary: ImportSummary) -> None: state = _project_setup.setdefault(project_id, {}) history = list(state.get("import_history", [])) history.insert(0, summary.model_dump(mode="json")) state["import_history"] = history[:20] _storage.write_document("project_import_history", project_id, {"project_id": project_id, "items": state["import_history"]}) def _normalize_and_store_import(project_id: str, source_path: Path) -> NormalizedProject | None: try: normalized = normalize_one_c_project(source_path, project_id=project_id) except Exception: return None return _save_normalized_project(project_id, normalized) def _normalized_from_runtime_result(project_id: str, runtime_result: dict) -> NormalizedProject | None: payload = runtime_result.get("normalized_project") if not isinstance(payload, dict): return None try: normalized = NormalizedProject.model_validate(payload) except Exception: return None if normalized.project_id is None: normalized.project_id = project_id return _save_normalized_project(project_id, normalized) def _save_normalized_project(project_id: str, normalized: NormalizedProject) -> NormalizedProject: _normalized_projects[project_id] = normalized _storage.write_document("normalized_projects", project_id, normalized.model_dump(mode="json")) return normalized def _load_normalized_project(project_id: str) -> NormalizedProject | None: if project_id in _normalized_projects: return _normalized_projects[project_id] try: payload = _storage.read_document("normalized_projects", project_id) except FileNotFoundError: return None normalized = NormalizedProject.model_validate(payload) _normalized_projects[project_id] = normalized return normalized def _normalized_project_summary(normalized: NormalizedProject) -> NormalizedProjectSummary: all_groups = _normalized_all_groups(normalized) objects = [item for group in all_groups for item in group.objects] return NormalizedProjectSummary( project_id=normalized.project_id, source_path=normalized.source_path, group_count=len(all_groups), object_count=len(objects), attribute_count=sum( len(item.attributes) + sum(_normalized_part_descendant_count(section, "ATTRIBUTE") for section in item.tabular_sections) + sum(_normalized_part_descendant_count(form, "ATTRIBUTE") for form in item.forms) for item in objects ), tabular_section_count=sum(len(item.tabular_sections) for item in objects), form_count=sum(len(item.forms) for item in objects), command_count=sum(len(item.commands) for item in objects), role_count=sum(1 for item in objects if item.object_kind == "ROLE"), rights_count=sum(len(item.rights) for item in objects), module_count=sum(len(item.modules) for item in objects), layout_count=sum(len(item.layouts) for item in objects), movement_count=sum(len(item.movements) for item in objects), extension_count=len(normalized.configuration.extensions), extensions=[item.name for item in normalized.configuration.extensions], groups=[ NormalizedGroupSummary( name=group.name, object_kind=", ".join(group.object_kinds), object_count=len(group.objects), ) for group in all_groups ], ) def _normalized_part_descendant_count(part, kind: str | None = None) -> int: children = getattr(part, "children", []) return sum( (1 if kind is None or child.kind == kind else 0) + _normalized_part_descendant_count(child, kind) for child in children ) def _quality_check( code: str, title: str, passed: bool, message: str, value: int | str | None = None, severity: str = "WARNING", ) -> ImportQualityCheck: return ImportQualityCheck( code=code, title=title, severity="INFO" if passed else severity, passed=passed, message=message, value=value, ) def _import_quality_response(project_id: str) -> ImportQualityResponse: normalized = _load_normalized_project(project_id) summary = _normalized_project_summary(normalized) if normalized is not None else None state = _project_setup.get(project_id, {}) status = str(state.get("status", ProjectSetupStatus.NOT_CONFIGURED.value)) checks: list[ImportQualityCheck] = [] checks.append( _quality_check( "normalized_project", "NormalizedProject", summary is not None, "NormalizedProject сохранен" if summary is not None else "NormalizedProject не найден", summary.project_id if summary is not None else None, severity="ERROR", ) ) if summary is not None: checks.extend( [ _quality_check( "metadata_groups", "Metadata groups", summary.group_count >= 5, f"Найдено групп metadata: {summary.group_count}", summary.group_count, ), _quality_check( "metadata_objects", "Metadata objects", summary.object_count > 0, f"Найдено объектов: {summary.object_count}", summary.object_count, severity="ERROR", ), _quality_check( "forms", "Forms", summary.form_count > 0, f"Найдено форм: {summary.form_count}", summary.form_count, ), _quality_check( "modules", "Modules", summary.module_count > 0, f"Найдено модулей: {summary.module_count}", summary.module_count, ), _quality_check( "roles", "Roles", summary.role_count > 0, f"Найдено ролей: {summary.role_count}", summary.role_count, ), _quality_check( "rights", "Rights", summary.rights_count > 0, f"Найдено прав: {summary.rights_count}", summary.rights_count, ), _quality_check( "extensions", "Extensions", True, f"Найдено расширений: {summary.extension_count}", summary.extension_count, severity="INFO", ), ] ) weighted_checks = [check for check in checks if check.code != "extensions"] passed = sum(1 for check in weighted_checks if check.passed) score = round((passed / len(weighted_checks)) * 100) if weighted_checks else 0 ready_for_ide = status == ProjectSetupStatus.INDEXED.value and all( check.passed for check in checks if check.severity == "ERROR" ) return ImportQualityResponse( project_id=project_id, status=status, score=score, ready_for_ide=ready_for_ide, summary=summary, checks=checks, ) def _normalized_object_detail(normalized: NormalizedProject, qualified_name: str) -> NormalizedObjectDetail | None: for group in normalized.configuration.groups: for item in group.objects: if item.qualified_name == qualified_name: return NormalizedObjectDetail( project_id=normalized.project_id, group_name=group.name, object=item, ) return None def _normalized_module_sources_for_object(normalized: NormalizedProject, qualified_name: str) -> list[ModuleSourceResponse]: normalized_query = qualified_name.strip().casefold() if not normalized_query: return [] selected_module = None selected_owner = None selected_object = None for group in _normalized_all_groups(normalized): for item in group.objects: if item.qualified_name.casefold() == normalized_query or item.name.casefold() == normalized_query: selected_object = item break for module in item.modules: module_keys = { str(module.qualified_name or "").casefold(), str(module.name or "").casefold(), str(module.source_path or "").casefold(), } if normalized_query in module_keys: selected_module = module selected_owner = item break if selected_object is not None or selected_module is not None: break if selected_object is not None or selected_module is not None: break if selected_module is not None: return [_normalized_module_source_response(selected_module, selected_owner)] if selected_object is None: return [] return sorted( [_normalized_module_source_response(module, selected_object) for module in selected_object.modules], key=lambda item: (item.module_role, item.name), ) def _normalized_bsl_completion_items( normalized: NormalizedProject, receiver: str | None, qualified_name: str | None, ) -> list[BslCompletionItemResponse]: receiver_key = (receiver or "").strip().casefold() qualified_key = (qualified_name or "").strip().casefold() items: list[BslCompletionItemResponse] = [] for group in _normalized_all_groups(normalized): for metadata_object in group.objects: object_names = { metadata_object.name.casefold(), metadata_object.qualified_name.casefold(), } if receiver_key and receiver_key in object_names: items.extend( BslCompletionItemResponse( label=part.name, kind=_completion_kind_for_part(part.kind), detail=f"{metadata_object.qualified_name}: {part.kind}", insert_text=part.name, ) for part in [ *metadata_object.attributes, *metadata_object.resources, *metadata_object.dimensions, *metadata_object.tabular_sections, *metadata_object.commands, ] ) for module in metadata_object.modules: module_names = { str(module.name or "").casefold(), str(module.qualified_name or "").casefold(), str(module.source_path or "").casefold(), f"{metadata_object.name}.{module.name}".casefold(), f"{metadata_object.qualified_name}.{module.name}".casefold(), } if receiver_key and receiver_key not in module_names and receiver_key not in object_names: continue if not receiver_key and qualified_key and qualified_key not in module_names and qualified_key not in object_names: continue source_text = str((module.attributes or {}).get("source_text", "")) for routine in _normalized_module_routines(source_text): if receiver_key and not routine.export: continue items.append( BslCompletionItemResponse( label=routine.name, kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE", detail=f"{module.qualified_name or module.name}{' · Export' if routine.export else ''}", insert_text=f"{routine.name}()", ) ) return items def _snapshot_bsl_completion_items(snapshot: SirSnapshot, receiver: str | None) -> list[BslCompletionItemResponse]: receiver_key = (receiver or "").strip().casefold() nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes} module_lineages: set[str] = set() if receiver_key: for node in snapshot.nodes: if node.kind == NodeKind.MODULE and ( node.name.casefold() == receiver_key or node.qualified_name.casefold() == receiver_key or str(node.attributes.get("source_path", "")).casefold() == receiver_key ): module_lineages.add(node.lineage_id) else: module_lineages = {node.lineage_id for node in snapshot.nodes if node.kind == NodeKind.MODULE} items: list[BslCompletionItemResponse] = [] for module_lineage in module_lineages: module = nodes_by_lineage.get(module_lineage) if module is None: continue for routine in _module_routines(snapshot, module_lineage, nodes_by_lineage): if receiver_key and not routine.export: continue items.append( BslCompletionItemResponse( label=routine.name, kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE", detail=f"{module.qualified_name}{' · Export' if routine.export else ''}", insert_text=f"{routine.name}()", ) ) return items def _completion_kind_for_part(kind: str) -> str: normalized = kind.upper() if normalized in {"ATTRIBUTE", "RESOURCE", "DIMENSION", "FIELD"}: return "PROPERTY" if normalized in {"COMMAND", "METHOD", "OPERATION"}: return "METHOD" if normalized in {"TABULAR_SECTION", "TABLE"}: return "COLLECTION" return "VALUE" def _normalized_module_source_response(module, owner) -> ModuleSourceResponse: attributes = module.attributes or {} source_text = str(attributes.get("source_text", "")) routines = _normalized_module_routines(source_text) return ModuleSourceResponse( name=module.name, qualified_name=module.qualified_name or module.name, module_role=str(module.module_kind or attributes.get("module_role") or "MODULE"), source_path=module.source_path or "", source_text=source_text, routines_count=len(routines), routines=routines, ) def _normalized_module_routines(source_text: str) -> list[ModuleRoutineResponse]: if not source_text: return [] declarations: list[tuple[int, re.Match[str]]] = [] pattern = re.compile(r"^\s*(Процедура|Функция|Procedure|Function)\s+([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(([^)]*)\)\s*(.*)$", re.IGNORECASE | re.MULTILINE) for match in pattern.finditer(source_text): line_start = source_text.count("\n", 0, match.start()) + 1 declarations.append((line_start, match)) routines: list[ModuleRoutineResponse] = [] for index, (line_start, match) in enumerate(declarations): line_end = declarations[index + 1][0] - 1 if index + 1 < len(declarations) else len(source_text.splitlines()) kind_label = match.group(1).casefold() tail = match.group(4) or "" routines.append( ModuleRoutineResponse( name=match.group(2), kind="FUNCTION" if kind_label in {"функция", "function"} else "PROCEDURE", line_start=line_start, line_end=line_end, export=bool(re.search(r"\b(Экспорт|Export)\b", tail, re.IGNORECASE)), ) ) return routines def _module_sources_for_object(snapshot: SirSnapshot, qualified_name: str) -> list[ModuleSourceResponse]: nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes} selected_routine = next( ( node for node in snapshot.nodes if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION} and (node.qualified_name == qualified_name or node.name == qualified_name) ), None, ) if selected_routine is not None: module_edge = next( ( edge for edge in snapshot.edges if edge.kind == EdgeKind.DECLARES and edge.target_lineage == selected_routine.lineage_id ), None, ) if module_edge is not None: module = nodes_by_lineage.get(module_edge.source_lineage) if module is not None: return _module_source_response(snapshot, module, nodes_by_lineage) selected_command = next( ( node for node in snapshot.nodes if node.kind in {NodeKind.COMMAND, NodeKind.FORM, NodeKind.FORM_ELEMENT} and (node.qualified_name == qualified_name or node.name == qualified_name) ), None, ) if selected_command is not None: handler_edge = next( ( edge for edge in snapshot.edges if edge.kind == EdgeKind.HANDLES and edge.source_lineage == selected_command.lineage_id ), None, ) if handler_edge is not None: routine = nodes_by_lineage.get(handler_edge.target_lineage) module_edge = next( ( edge for edge in snapshot.edges if edge.kind == EdgeKind.DECLARES and routine is not None and edge.target_lineage == routine.lineage_id ), None, ) if module_edge is not None: module = nodes_by_lineage.get(module_edge.source_lineage) if module is not None: return _module_source_response(snapshot, module, nodes_by_lineage) selected_module = next( ( node for node in snapshot.nodes if node.kind == NodeKind.MODULE and ( node.qualified_name == qualified_name or node.name == qualified_name or node.source_ref.source_path == qualified_name or _module_metadata_qualified_name(snapshot, node, nodes_by_lineage) == qualified_name ) ), None, ) if selected_module is not None: return _module_source_response(snapshot, selected_module, nodes_by_lineage) owner = next( ( node for node in snapshot.nodes if node.qualified_name == qualified_name and node.kind in _MODULE_OWNER_NODE_KINDS ), None, ) if owner is None: return [] module_edges = [ edge for edge in snapshot.edges if edge.kind == EdgeKind.CONTAINS and edge.source_lineage == owner.lineage_id and edge.attributes.get("link_type") == "METADATA_MODULE" ] modules: list[ModuleSourceResponse] = [] for edge in module_edges: module = nodes_by_lineage.get(edge.target_lineage) if module is None or module.kind != NodeKind.MODULE: continue routines = _module_routines(snapshot, module.lineage_id, nodes_by_lineage) modules.append( ModuleSourceResponse( name=module.name, qualified_name=module.qualified_name, module_role=str(edge.attributes.get("module_role", "MODULE")), source_path=module.source_ref.source_path, source_text=str(module.attributes.get("source_text", "")), routines_count=len(routines), routines=routines, ) ) return sorted(modules, key=lambda item: (item.module_role, item.name)) def _module_source_response( snapshot: SirSnapshot, module, nodes_by_lineage: dict[str, object], ) -> list[ModuleSourceResponse]: owner_edge = next( ( edge for edge in snapshot.edges if edge.kind == EdgeKind.CONTAINS and edge.target_lineage == module.lineage_id and edge.attributes.get("link_type") == "METADATA_MODULE" ), None, ) routines = _module_routines(snapshot, module.lineage_id, nodes_by_lineage) return [ ModuleSourceResponse( name=module.name, qualified_name=module.qualified_name, module_role=str(owner_edge.attributes.get("module_role", "MODULE")) if owner_edge else "MODULE", source_path=module.source_ref.source_path, source_text=str(module.attributes.get("source_text", "")), routines_count=len(routines), routines=routines, ) ] def _module_metadata_qualified_name( snapshot: SirSnapshot, module, nodes_by_lineage: dict[str, object], ) -> str | None: owner_edge = next( ( edge for edge in snapshot.edges if edge.kind == EdgeKind.CONTAINS and edge.target_lineage == module.lineage_id and edge.attributes.get("link_type") == "METADATA_MODULE" ), None, ) if owner_edge is None: return None owner = nodes_by_lineage.get(owner_edge.source_lineage) if owner is None: return None role = str(owner_edge.attributes.get("module_role", "MODULE")) form_name = str(owner_edge.attributes.get("form_name", "")) suffix = { "OBJECT_MODULE": "МодульОбъекта", "MANAGER_MODULE": "МодульМенеджера", "RECORD_SET_MODULE": "МодульНабораЗаписей", "FORM_MODULE": f"Форма.{form_name}.Модуль" if form_name else "МодульФормы", "MODULE": "Модуль", }.get(role, module.name) return f"{owner.qualified_name}.{suffix}" def _module_routines( snapshot: SirSnapshot, module_lineage: str, nodes_by_lineage: dict[str, object], ) -> list[ModuleRoutineResponse]: routines: list[ModuleRoutineResponse] = [] for edge in snapshot.edges: if edge.kind != EdgeKind.DECLARES or edge.source_lineage != module_lineage: continue routine = nodes_by_lineage.get(edge.target_lineage) if routine is None or getattr(routine, "kind", None) not in {NodeKind.PROCEDURE, NodeKind.FUNCTION}: continue calls = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.CALLS) queries = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.OWNS_QUERY) writes = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.WRITES) impact_level, impact_reasons = _routine_impact_markers(calls, queries, writes) routines.append( ModuleRoutineResponse( name=routine.name, kind=routine.kind.value, line_start=routine.source_ref.line_start, line_end=routine.source_ref.line_end, export=bool(routine.attributes.get("export", False)), calls_count=len(calls), queries_count=len(queries), writes_count=len(writes), calls=calls, queries=queries, writes=writes, impact_level=impact_level, impact_reasons=impact_reasons, ) ) return sorted(routines, key=lambda item: item.line_start or 0) def _routine_impact_markers(calls: list[str], queries: list[str], writes: list[str]) -> tuple[str, list[str]]: reasons: list[str] = [] if writes: reasons.append("writes data") if queries: reasons.append("reads query tables") if len(calls) >= 3: reasons.append("fan-out calls") if writes and (queries or len(calls) >= 2): level = "HIGH" elif writes or queries or len(calls) >= 3: level = "MEDIUM" else: level = "LOW" return level, reasons def _routine_relation_values( snapshot: SirSnapshot, nodes_by_lineage: dict[str, object], routine_lineage: str, relation: EdgeKind, ) -> list[str]: values: list[str] = [] for edge in snapshot.edges: if edge.kind != relation or edge.source_lineage != routine_lineage: continue target = nodes_by_lineage.get(edge.target_lineage) if target is None: continue if relation == EdgeKind.OWNS_QUERY: query_text = str(target.attributes.get("query_text", "")).strip() values.append(query_text or target.name) else: values.append(target.qualified_name or target.name) return values def _import_summary_from_snapshot( *, project_id: str, source: ImportSourceKind, status: str, snapshot: SirSnapshot | None, errors: list[str], metadata: dict, runtime_mode: str, runtime_diagnostics: list[str], normalized: NormalizedProject | None, mode: ImportMode = ImportMode.FULL_REPLACE, applied: bool = True, sync_preview: ImportSyncPreview | None = None, ) -> ImportSummary: normalized_summary = _normalized_project_summary(normalized) if normalized is not None else None empty_counts = snapshot is None and normalized_summary is None and not applied if snapshot is None: return ImportSummary( source=source, mode=mode, applied=applied, status=status, last_import=_current_timestamp(), source_path=None, runtime_mode=runtime_mode, runtime_diagnostics=runtime_diagnostics, errors=errors, diagnostics_count=0, diagnostics=[], object_count=normalized_summary.object_count if normalized_summary is not None else 0 if empty_counts else 12, module_count=normalized_summary.module_count if normalized_summary is not None else 0 if empty_counts else 4, form_count=normalized_summary.form_count if normalized_summary is not None else 0 if empty_counts else 3, role_count=normalized_summary.role_count if normalized_summary is not None else 0 if empty_counts else 2, extensions=( normalized_summary.extensions if normalized_summary is not None else [] if empty_counts else list(metadata.get("extensions", ["DemoExtension"])) ), platform_version=metadata.get("platform_version", "mock-8.3"), compatibility_mode=metadata.get("compatibility_mode", "mock"), normalized_summary=normalized_summary, sync_preview=sync_preview, ) object_kinds = { NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.REGISTER, NodeKind.REPORT, NodeKind.DATA_PROCESSOR, NodeKind.COMMON_MODULE, NodeKind.EXCHANGE_PLAN, NodeKind.BUSINESS_PROCESS, NodeKind.TASK, NodeKind.SUBSYSTEM, NodeKind.HTTP_SERVICE, NodeKind.XDTO_PACKAGE, NodeKind.EXTENSION, } return ImportSummary( source=source, mode=mode, applied=applied, status=status, last_import=_current_timestamp(), source_path=snapshot.metadata.source_root, runtime_mode=runtime_mode, runtime_diagnostics=runtime_diagnostics, errors=errors, diagnostics_count=len(snapshot.diagnostics), diagnostics=[ f"{diagnostic.severity.value} {diagnostic.code}: {diagnostic.message}" for diagnostic in snapshot.diagnostics[:20] ], object_count=normalized_summary.object_count if normalized_summary is not None else sum(1 for node in snapshot.nodes if node.kind in object_kinds), module_count=normalized_summary.module_count if normalized_summary is not None else sum(1 for node in snapshot.nodes if node.kind == NodeKind.MODULE), form_count=normalized_summary.form_count if normalized_summary is not None else sum(1 for node in snapshot.nodes if node.kind == NodeKind.FORM), role_count=normalized_summary.role_count if normalized_summary is not None else sum(1 for node in snapshot.nodes if node.kind == NodeKind.ROLE), extensions=normalized_summary.extensions if normalized_summary is not None else list(metadata.get("extensions", [])), platform_version=metadata.get("platform_version"), compatibility_mode=metadata.get("compatibility_mode"), snapshot=_summary(snapshot), normalized_summary=normalized_summary, sync_preview=sync_preview, ) def _current_timestamp() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).isoformat() def _repo_root() -> Path: return Path(__file__).resolve().parents[4] def _windows_agent_script_path() -> Path: path = _repo_root() / "scripts" / "windows-agent" / "sfera-windows-agent.ps1" if not path.exists(): raise HTTPException(status_code=404, detail=f"Windows agent script not found: {path}") return path def _build_sfera_extension_package() -> bytes: source_root = _repo_root() / "integrations" / "1c" / "sfera-extension" if not source_root.exists(): raise HTTPException(status_code=404, detail=f"SFERA extension sources not found: {source_root}") from io import BytesIO buffer = BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(source_root.rglob("*")): if not path.is_file(): continue archive.write(path, path.relative_to(source_root).as_posix()) return buffer.getvalue() def _file_sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _public_origin(request: Request) -> str: forwarded = request.headers.get("x-sfera-public-origin") if forwarded: return forwarded.rstrip("/") return str(request.base_url).rstrip("/") def _public_api_origin(request: Request) -> str: configured = os.environ.get("SFERA_PUBLIC_API_URL") if configured: return configured.rstrip("/") origin = _public_origin(request) try: parsed = urlsplit(origin) if parsed.port == 49230: netloc = f"{parsed.hostname}:49280" return urlunsplit((parsed.scheme, netloc, "", "", "")).rstrip("/") except ValueError: pass return origin def _load_snapshot_into_memory(project_id: str) -> SirSnapshot: snapshot = _storage.load_snapshot(project_id) graph = InMemoryProjection() graph.project_snapshot(snapshot) _snapshots[snapshot.project_id] = snapshot _graphs[snapshot.project_id] = graph if len(snapshot.nodes) <= 5000: _append_and_persist_versions(snapshot) return snapshot def _snapshot_and_graph(project_id: str) -> tuple[SirSnapshot, InMemoryProjection]: snapshot = _snapshots.get(project_id) graph = _graphs.get(project_id) if snapshot is None or graph is None: try: snapshot = _load_snapshot_into_memory(project_id) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error graph = _graphs[project_id] return snapshot, graph def _find_graph_node(graph: InMemoryProjection, name: str, kinds: set[NodeKind]): wanted = name.lower() return next( ( node for node in graph.nodes.values() if node.kind in kinds and (node.name.lower() == wanted or node.qualified_name.lower() == wanted) ), None, ) def _object_access_grants(graph: InMemoryProjection, object_node) -> list[tuple]: grants = [] for edge in graph.edges.values(): if edge.kind != EdgeKind.GRANTS_ACCESS or edge.target_lineage != object_node.lineage_id: continue role = graph.nodes.get(edge.source_lineage) if role is not None and role.kind == NodeKind.ROLE: grants.append((role, edge)) return sorted(grants, key=lambda item: item[0].qualified_name) def _role_object_grants(graph: InMemoryProjection, role) -> list[tuple]: grants = [] for edge in graph.edges.values(): if edge.kind != EdgeKind.GRANTS_ACCESS or edge.source_lineage != role.lineage_id: continue target = graph.nodes.get(edge.target_lineage) if target is not None and target.kind in _ACCESS_TARGET_KINDS: grants.append((target, edge)) return sorted(grants, key=lambda item: item[0].qualified_name) async def _project_snapshot_to_neo4j(project_id: str, snapshot: SirSnapshot) -> dict[str, int]: try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() projection = Neo4jProjection(driver) await projection.clear_project(project_id) await projection.project_snapshot(snapshot) return await projection.counts(project_id) except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _apply_delta_to_neo4j(project_id: str, delta: SirDelta) -> dict[str, int]: try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() projection = Neo4jProjection(driver) await projection.apply_delta(delta, project_id=project_id) return await projection.counts(project_id) except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error def _run_operation_job(job: OperationJob) -> dict: if job.kind == "INDEX_PROJECT": path = job.payload.get("path") if not path: raise ValueError("INDEX_PROJECT job requires payload.path") source_path = Path(path) if not source_path.exists(): raise FileNotFoundError(path) snapshot = _index_and_store(source_path, job.payload.get("project_id")) return {"snapshot": _summary(snapshot).model_dump(mode="json")} if job.kind == "LOAD_PROJECT": project_id = job.payload.get("project_id") if not project_id: raise ValueError("LOAD_PROJECT job requires payload.project_id") snapshot = _load_snapshot_into_memory(project_id) return {"snapshot": _summary(snapshot).model_dump(mode="json")} raise ValueError(f"Unsupported job kind: {job.kind}") async def _neo4j_routine_query(project_id: str, routine_name: str, *, outgoing: bool) -> list[dict]: pattern = ( "MATCH (source:SferaNode {project_id: $project_id, name: $routine_name})" "-[r:SEMANTIC_EDGE {kind: 'CALLS'}]->" "(target:SferaNode {project_id: $project_id})" if outgoing else "MATCH (source:SferaNode {project_id: $project_id})" "-[r:SEMANTIC_EDGE {kind: 'CALLS'}]->" "(target:SferaNode {project_id: $project_id, name: $routine_name})" ) return_node = "target" if outgoing else "source" query = f""" {pattern} RETURN {return_node}.lineage_id AS lineage_id, {return_node}.kind AS kind, {return_node}.name AS name, {return_node}.qualified_name AS qualified_name ORDER BY qualified_name """ try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run( query, project_id=project_id, routine_name=routine_name, ) return [dict(record) async for record in result] except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_relation_query(project_id: str, routine_name: str, *, relation: str) -> list[dict]: if relation == "READS_TABLE": query = """ MATCH (routine:SferaNode {project_id: $project_id, name: $routine_name}) -[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]-> (query:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]-> (target:SferaNode {project_id: $project_id}) RETURN target.lineage_id AS lineage_id, target.kind AS kind, target.name AS name, target.qualified_name AS qualified_name ORDER BY qualified_name """ elif relation == "WRITES": query = """ MATCH (routine:SferaNode {project_id: $project_id, name: $routine_name}) -[:SEMANTIC_EDGE {kind: 'WRITES'}]-> (target:SferaNode {project_id: $project_id}) RETURN target.lineage_id AS lineage_id, target.kind AS kind, target.name AS name, target.qualified_name AS qualified_name ORDER BY qualified_name """ else: raise ValueError(f"Unsupported Neo4j relation query: {relation}") try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run( query, project_id=project_id, routine_name=routine_name, ) return [dict(record) async for record in result] except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_integrations_query(project_id: str, *, kind: str | None = None) -> list[dict]: query = """ MATCH (endpoint:SferaNode {project_id: $project_id, kind: 'INTEGRATION_ENDPOINT'}) WHERE $kind IS NULL OR endpoint.attributes_json CONTAINS $kind OPTIONAL MATCH (module:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'USES_INTEGRATION'}]->(endpoint) RETURN endpoint.lineage_id AS endpoint_id, endpoint.name AS name, endpoint.attributes_json AS attributes_json, module.qualified_name AS owner ORDER BY name """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, kind=kind) rows = [] async for record in result: attributes = json.loads(record["attributes_json"] or "{}") rows.append( { "endpoint_id": record["endpoint_id"], "name": record["name"], "kind": attributes.get("integration_kind", "UNKNOWN"), "direction": attributes.get("direction", "UNKNOWN"), "owner": record["owner"], "attributes": attributes, } ) return rows except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_integration_modules_query(project_id: str, integration_name: str) -> list[dict]: query = """ MATCH (module:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'USES_INTEGRATION'}]-> (endpoint:SferaNode {project_id: $project_id, kind: 'INTEGRATION_ENDPOINT'}) WHERE endpoint.name = $integration_name OR endpoint.qualified_name = $integration_name OR endpoint.attributes_json CONTAINS $integration_name RETURN module.lineage_id AS lineage_id, module.kind AS kind, module.name AS name, module.qualified_name AS qualified_name ORDER BY qualified_name """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, integration_name=integration_name) return [dict(record) async for record in result] except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_impact_query(project_id: str, object_name: str) -> dict | None: query = """ MATCH (object:SferaNode {project_id: $project_id}) WHERE object.name = $object_name OR object.qualified_name = $object_name OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'CONTAINS'}]-> (module:SferaNode {project_id: $project_id, kind: 'MODULE'}) OPTIONAL MATCH (module)-[:SEMANTIC_EDGE {kind: 'DECLARES'}]-> (routine:SferaNode {project_id: $project_id}) WHERE routine.kind IN ['PROCEDURE', 'FUNCTION'] OR routine IS NULL OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'RUNS'}]-> (jobRoutine:SferaNode {project_id: $project_id}) OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_FORM'}]-> (form:SferaNode {project_id: $project_id, kind: 'FORM'}) OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]-> (attribute:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'}) OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_TABULAR_SECTION'}]-> (tabularSection:SferaNode {project_id: $project_id, kind: 'TABULAR_SECTION'}) OPTIONAL MATCH (tabularSection)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]-> (tabularColumn:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'}) OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]-> (objectCommand:SferaNode {project_id: $project_id, kind: 'COMMAND'}) OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]-> (formCommand:SferaNode {project_id: $project_id, kind: 'COMMAND'}) OPTIONAL MATCH (objectCommand)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]-> (objectCommandHandler:SferaNode {project_id: $project_id}) WHERE objectCommandHandler.kind IN ['PROCEDURE', 'FUNCTION'] OR objectCommandHandler IS NULL OPTIONAL MATCH (formCommand)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]-> (formCommandHandler:SferaNode {project_id: $project_id}) WHERE formCommandHandler.kind IN ['PROCEDURE', 'FUNCTION'] OR formCommandHandler IS NULL OPTIONAL MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'}) -[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]-> (object) OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'CALLS'}]-> (callee:SferaNode {project_id: $project_id}) OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]-> (:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]-> (table:SferaNode {project_id: $project_id}) OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'WRITES'}]-> (write:SferaNode {project_id: $project_id}) OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'CALLS'}]-> (objectHandlerCallee:SferaNode {project_id: $project_id}) OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'CALLS'}]-> (formHandlerCallee:SferaNode {project_id: $project_id}) OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]-> (:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]-> (objectHandlerTable:SferaNode {project_id: $project_id}) OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]-> (:SferaNode {project_id: $project_id}) -[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]-> (formHandlerTable:SferaNode {project_id: $project_id}) OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'WRITES'}]-> (objectHandlerWrite:SferaNode {project_id: $project_id}) OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'WRITES'}]-> (formHandlerWrite:SferaNode {project_id: $project_id}) RETURN object, collect(DISTINCT module) AS modules, collect(DISTINCT routine) + collect(DISTINCT jobRoutine) + collect(DISTINCT objectCommandHandler) + collect(DISTINCT formCommandHandler) AS routines, collect(DISTINCT form) AS forms, collect(DISTINCT objectCommand) + collect(DISTINCT formCommand) AS commands, collect(DISTINCT attribute) AS attributes, collect(DISTINCT tabularSection) AS tabular_sections, collect(DISTINCT {section: tabularSection, column: tabularColumn}) AS tabular_section_columns, collect(DISTINCT role) AS roles, collect(DISTINCT {role: role, attributes_json: access.attributes_json}) AS role_access, CASE WHEN object.kind = 'SCHEDULED_JOB' THEN [object] ELSE [] END AS jobs, collect(DISTINCT callee) + collect(DISTINCT objectHandlerCallee) + collect(DISTINCT formHandlerCallee) AS callees, collect(DISTINCT table) + collect(DISTINCT objectHandlerTable) + collect(DISTINCT formHandlerTable) AS query_tables, collect(DISTINCT write) + collect(DISTINCT objectHandlerWrite) + collect(DISTINCT formHandlerWrite) AS writes ORDER BY object.qualified_name LIMIT 1 """ try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) record = await result.single() if record is None: return None return { "object": _neo4j_node_to_named(record["object"]), "modules": _neo4j_nodes_to_named(record["modules"]), "routines": _neo4j_nodes_to_named(record["routines"]), "forms": _neo4j_nodes_to_named(record["forms"]), "commands": _neo4j_nodes_to_named(record["commands"]), "attributes": _neo4j_nodes_to_named(record["attributes"]), "tabular_sections": _neo4j_nodes_to_named(record["tabular_sections"]), "tabular_section_columns": _neo4j_tabular_section_columns_to_named( record["tabular_section_columns"] ), "roles": _neo4j_nodes_to_named(record["roles"]), "role_access": _neo4j_role_access_to_named(record["role_access"]), "jobs": _neo4j_nodes_to_named(record["jobs"]), "callees": _neo4j_nodes_to_named(record["callees"]), "query_tables": _neo4j_nodes_to_named(record["query_tables"]), "writes": _neo4j_nodes_to_named(record["writes"]), } except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_attributes_query(project_id: str, object_name: str) -> list[dict]: return await _neo4j_object_children_query( project_id, object_name, edge_kind="HAS_ATTRIBUTE", child_kind="ATTRIBUTE", ) async def _neo4j_object_schema_query(project_id: str, object_name: str) -> dict | None: object_node = await _neo4j_object_query(project_id, object_name) if object_node is None: return None return { "object": object_node, "attributes": await _neo4j_object_attributes_query(project_id, object_name), "tabular_sections": await _neo4j_object_tabular_section_columns_query(project_id, object_name), } async def _neo4j_object_query(project_id: str, object_name: str) -> dict | None: query = """ MATCH (object:SferaNode {project_id: $project_id}) WHERE object.name = $object_name OR object.qualified_name = $object_name RETURN object ORDER BY object.qualified_name LIMIT 1 """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) record = await result.single() if record is None: return None return _neo4j_node_to_named(record["object"]) except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_tabular_sections_query(project_id: str, object_name: str) -> list[dict]: return await _neo4j_object_children_query( project_id, object_name, edge_kind="HAS_TABULAR_SECTION", child_kind="TABULAR_SECTION", ) async def _neo4j_object_tabular_section_columns_query(project_id: str, object_name: str) -> list[dict]: query = """ MATCH (object:SferaNode {project_id: $project_id}) WHERE object.name = $object_name OR object.qualified_name = $object_name MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_TABULAR_SECTION'}]-> (section:SferaNode {project_id: $project_id, kind: 'TABULAR_SECTION'}) OPTIONAL MATCH (section)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]-> (column:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'}) RETURN section, collect(DISTINCT column) AS columns ORDER BY section.qualified_name """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) return [ { "tabular_section": _neo4j_node_to_named(record["section"]), "columns": _neo4j_nodes_to_named(record["columns"]), } async for record in result ] except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_children_query( project_id: str, object_name: str, *, edge_kind: str, child_kind: str, ) -> list[dict]: query = f""" MATCH (object:SferaNode {{project_id: $project_id}}) WHERE object.name = $object_name OR object.qualified_name = $object_name MATCH (object)-[:SEMANTIC_EDGE {{kind: '{edge_kind}'}}]-> (child:SferaNode {{project_id: $project_id, kind: '{child_kind}'}}) RETURN child.lineage_id AS lineage_id, child.kind AS kind, child.name AS name, child.qualified_name AS qualified_name ORDER BY qualified_name """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) return [dict(record) async for record in result] except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_ui_query(project_id: str, object_name: str) -> dict | None: query = """ MATCH (object:SferaNode {project_id: $project_id}) WHERE object.name = $object_name OR object.qualified_name = $object_name OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_FORM'}]-> (form:SferaNode {project_id: $project_id, kind: 'FORM'}) OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]-> (command:SferaNode {project_id: $project_id, kind: 'COMMAND'}) OPTIONAL MATCH (command)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]-> (handler:SferaNode {project_id: $project_id}) WHERE handler.kind IN ['PROCEDURE', 'FUNCTION'] OR handler IS NULL OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_ELEMENT'}]-> (element:SferaNode {project_id: $project_id, kind: 'FORM_ELEMENT'}) RETURN object, collect(DISTINCT form) AS forms, collect(DISTINCT {form: form, command: command, handler: handler}) AS command_links, collect(DISTINCT {form: form, element: element}) AS element_links ORDER BY object.qualified_name LIMIT 1 """ try: async with AsyncGraphDatabase.driver( _neo4j_uri, auth=(_neo4j_user, _neo4j_password), ) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) record = await result.single() if record is None: return None return { "object": _neo4j_node_to_named(record["object"]), "forms": _neo4j_object_ui_forms( record["forms"], record["command_links"], record["element_links"], ), } except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_object_access_query(project_id: str, object_name: str) -> dict | None: query = """ MATCH (object:SferaNode {project_id: $project_id}) WHERE object.name = $object_name OR object.qualified_name = $object_name OPTIONAL MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'}) -[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]->(object) RETURN object, collect(DISTINCT {role: role, attributes_json: access.attributes_json}) AS grants ORDER BY object.qualified_name LIMIT 1 """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, object_name=object_name) record = await result.single() if record is None: return None return { "object": _neo4j_node_to_named(record["object"]), "grants": _neo4j_role_access_to_named(record["grants"]), } except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error async def _neo4j_role_access_query(project_id: str, role_name: str) -> dict | None: query = """ MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'}) WHERE role.name = $role_name OR role.qualified_name = $role_name OPTIONAL MATCH (role)-[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]-> (object:SferaNode {project_id: $project_id}) RETURN role, collect(DISTINCT object) AS objects, collect(DISTINCT {object: object, attributes_json: access.attributes_json}) AS grants ORDER BY role.qualified_name LIMIT 1 """ try: async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver: await driver.verify_connectivity() async with driver.session() as session: result = await session.run(query, project_id=project_id, role_name=role_name) record = await result.single() if record is None: return None return { "role": _neo4j_node_to_named(record["role"]), "objects": _neo4j_nodes_to_named(record["objects"]), "grants": _neo4j_object_access_to_named(record["grants"]), } except Exception as error: raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error def _neo4j_nodes_to_named(nodes: list) -> list[dict]: seen: dict[str, dict] = {} for node in nodes: if node is None: continue named = _neo4j_node_to_named(node) seen.setdefault(named["lineage_id"], named) return sorted(seen.values(), key=lambda row: row["qualified_name"]) def _neo4j_object_ui_forms(forms: list, command_links: list, element_links: list) -> list[dict]: by_lineage: dict[str, dict] = {} for form in forms: if form is None: continue named_form = _neo4j_node_to_named(form) by_lineage[named_form["lineage_id"]] = { "form": named_form, "commands": [], "elements": [], "command_handlers": {}, } command_seen: set[tuple[str, str]] = set() for link in command_links: if not link or link.get("form") is None or link.get("command") is None: continue form = _neo4j_node_to_named(link["form"]) command = _neo4j_node_to_named(link["command"]) bucket = by_lineage.setdefault( form["lineage_id"], {"form": form, "commands": [], "elements": [], "command_handlers": {}}, ) command_key = (form["lineage_id"], command["lineage_id"]) if command_key not in command_seen: command_seen.add(command_key) bucket["commands"].append(command) if link.get("handler") is not None: bucket["command_handlers"][command["lineage_id"]] = _neo4j_node_to_named(link["handler"]) element_seen: set[tuple[str, str]] = set() for link in element_links: if not link or link.get("form") is None or link.get("element") is None: continue form = _neo4j_node_to_named(link["form"]) element = _neo4j_node_to_named(link["element"]) bucket = by_lineage.setdefault( form["lineage_id"], {"form": form, "commands": [], "elements": [], "command_handlers": {}}, ) element_key = (form["lineage_id"], element["lineage_id"]) if element_key not in element_seen: element_seen.add(element_key) bucket["elements"].append(element) for bucket in by_lineage.values(): bucket["commands"] = sorted(bucket["commands"], key=lambda row: row["qualified_name"]) bucket["elements"] = sorted(bucket["elements"], key=lambda row: row["qualified_name"]) return sorted(by_lineage.values(), key=lambda row: row["form"]["qualified_name"]) def _neo4j_tabular_section_columns_to_named(links: list) -> dict[str, list[dict]]: result: dict[str, dict[str, dict]] = {} for link in links: if not link or link.get("section") is None or link.get("column") is None: continue section = _neo4j_node_to_named(link["section"]) column = _neo4j_node_to_named(link["column"]) result.setdefault(section["lineage_id"], {})[column["lineage_id"]] = column return { section_lineage: sorted(columns.values(), key=lambda row: row["qualified_name"]) for section_lineage, columns in result.items() } def _neo4j_role_access_to_named(grants: list) -> list[dict]: result: list[dict] = [] seen: set[str] = set() for grant in grants: if not grant or grant.get("role") is None: continue role = _neo4j_node_to_named(grant["role"]) if role["lineage_id"] in seen: continue seen.add(role["lineage_id"]) attributes_json = grant.get("attributes_json") or "{}" result.append( { "role": role, "permissions": json.loads(attributes_json), } ) return sorted(result, key=lambda row: row["role"]["qualified_name"]) def _neo4j_object_access_to_named(grants: list) -> list[dict]: result: list[dict] = [] seen: set[str] = set() for grant in grants: if not grant or grant.get("object") is None: continue object_node = _neo4j_node_to_named(grant["object"]) if object_node["lineage_id"] in seen: continue seen.add(object_node["lineage_id"]) attributes_json = grant.get("attributes_json") or "{}" result.append( { "object": object_node, "permissions": json.loads(attributes_json), } ) return sorted(result, key=lambda row: row["object"]["qualified_name"]) def _neo4j_node_to_named(node) -> dict: return { "lineage_id": node["lineage_id"], "kind": node["kind"], "name": node["name"], "qualified_name": node["qualified_name"], } def _form_semantics_response(item) -> FormSemanticsResponse: return FormSemanticsResponse( form=_named_node(item.form), commands=[_named_node(node) for node in item.commands], elements=[_named_node(node) for node in item.elements], command_handlers={ command_lineage: _named_node(handler) for command_lineage, handler in item.command_handlers.items() }, ) def _form_semantics_for_lineages(snapshot: SirSnapshot, form_lineages: set[str]) -> list[FormSemanticsResponse]: if not form_lineages: return [] nodes = {node.lineage_id: node for node in snapshot.nodes} forms = { lineage_id: FormSemanticsResponse(form=_named_node(node), commands=[], elements=[], command_handlers={}) for lineage_id, node in nodes.items() if lineage_id in form_lineages and node.kind == NodeKind.FORM } command_to_form: dict[str, FormSemanticsResponse] = {} for edge in snapshot.edges: form = forms.get(edge.source_lineage) target = nodes.get(edge.target_lineage) if form is None or target is None: continue if edge.kind == EdgeKind.HAS_COMMAND: named_target = _named_node(target) form.commands.append(named_target) command_to_form[target.lineage_id] = form elif edge.kind == EdgeKind.HAS_ELEMENT: form.elements.append(_named_node(target)) for edge in snapshot.edges: if edge.kind != EdgeKind.HANDLES: continue form = command_to_form.get(edge.source_lineage) handler = nodes.get(edge.target_lineage) if form is not None and handler is not None: form.command_handlers[edge.source_lineage] = _named_node(handler) return sorted(forms.values(), key=lambda item: item.form.qualified_name) def _persist_job(job: OperationJob) -> OperationJob: _storage.write_document("operations_jobs", job.job_id, job.model_dump(mode="json")) return job def _run_server_import_job(job_id: str, project_id: str, request: ImportRequest) -> None: job = _operations.jobs.get(job_id) if job is None: return _update_import_job_progress(job_id, "Импорт запущен.", stage="running", status=OperationJobStatus.RUNNING) try: summary = _execute_import_project( project_id, request, progress=lambda message, extra=None: _update_import_job_progress(job_id, message, **(extra or {})), ) except Exception as error: _update_import_job_progress( job_id, f"Импорт завершился ошибкой: {error}", stage="failed", status=OperationJobStatus.FAILED, error=str(error), finished_at=_current_timestamp(), ) return status = OperationJobStatus.SUCCEEDED if summary.applied is not False and not summary.errors else OperationJobStatus.FAILED _update_import_job_progress( job_id, "Импорт завершен." if status == OperationJobStatus.SUCCEEDED else "Импорт остановлен. Проверьте ошибки.", stage="done" if status == OperationJobStatus.SUCCEEDED else "failed", status=status, result={"import_summary": summary.model_dump(mode="json")}, error="; ".join(summary.errors) if summary.errors else None, finished_at=_current_timestamp(), ) def _create_agent_server_import_job(agent_job: AgentImportJob, request: ImportRequest) -> OperationJob: operation_job = OperationJob( job_id=f"server-import-{uuid4()}", kind="SERVER_IMPORT", status=OperationJobStatus.QUEUED, payload={ "project_id": agent_job.project_id, "agent_job_id": agent_job.job_id, "agent_id": agent_job.agent_id, "source": agent_job.source.value, "mode": request.mode.value, "path": request.path, "stage": "queued", "message": "Импорт с Windows Agent поставлен в очередь.", "logs": ["Импорт с Windows Agent поставлен в очередь."], "started_at": None, "finished_at": None, "bytes_copied": 0, "files_copied": 0, }, ) _persist_job(_operations.enqueue(operation_job)) return _operations.jobs[operation_job.job_id] def _append_agent_job_log(agent_job_id: str, message: str) -> AgentImportJob | None: agent_job = _agent_import_jobs.get(agent_job_id) if agent_job is None: return None if message and (not agent_job.logs or agent_job.logs[-1] != message): agent_job.logs.append(message) agent_job.updated_at = _current_timestamp() return _persist_agent_import_job(agent_job) def _run_agent_server_import_job(agent_job_id: str, operation_job_id: str, project_id: str, request: ImportRequest) -> None: agent_job = _agent_import_jobs.get(agent_job_id) if agent_job is None: return agent_job.status = AgentImportJobStatus.RUNNING agent_job.error = None agent_job.updated_at = _current_timestamp() _persist_agent_import_job(agent_job) _update_import_job_progress( operation_job_id, "Импорт с Windows Agent запущен.", stage="running", status=OperationJobStatus.RUNNING, ) def progress(message: str, extra: dict[str, Any] | None = None) -> None: _update_import_job_progress(operation_job_id, message, **(extra or {})) if message: _append_agent_job_log(agent_job_id, f"Server import: {message}") try: summary = _execute_import_project(project_id, request, progress=progress) except Exception as error: message = f"Server import failed: {error}" agent_job = _agent_import_jobs.get(agent_job_id) if agent_job is not None: agent_job.status = AgentImportJobStatus.FAILED agent_job.error = str(error) agent_job.logs.append(message) agent_job.updated_at = _current_timestamp() agent_job.completed_at = agent_job.updated_at _persist_agent_import_job(agent_job) _update_import_job_progress( operation_job_id, f"Импорт завершился ошибкой: {error}", stage="failed", status=OperationJobStatus.FAILED, error=str(error), finished_at=_current_timestamp(), ) return status = OperationJobStatus.SUCCEEDED if summary.applied is not False and not summary.errors else OperationJobStatus.FAILED agent_job = _agent_import_jobs.get(agent_job_id) if agent_job is not None: agent_job.status = AgentImportJobStatus.SUCCEEDED if status == OperationJobStatus.SUCCEEDED else AgentImportJobStatus.FAILED agent_job.import_summary = summary.model_dump(mode="json") agent_job.error = "; ".join(summary.errors) if summary.errors else None agent_job.logs.append(f"Server import applied with status {summary.status}.") agent_job.updated_at = _current_timestamp() agent_job.completed_at = agent_job.updated_at _persist_agent_import_job(agent_job) _update_import_job_progress( operation_job_id, "Импорт завершен." if status == OperationJobStatus.SUCCEEDED else "Импорт остановлен. Проверьте ошибки.", stage="done" if status == OperationJobStatus.SUCCEEDED else "failed", status=status, result={"import_summary": summary.model_dump(mode="json")}, error="; ".join(summary.errors) if summary.errors else None, finished_at=_current_timestamp(), ) def _run_agent_uploaded_zip_job(job_id: str, upload_path_raw: str, import_root_raw: str, filename: str, size: int) -> None: job = _agent_import_jobs.get(job_id) if job is None: return upload_path = Path(upload_path_raw) import_root = Path(import_root_raw) try: job.status = AgentImportJobStatus.RUNNING job.error = None job.logs.append("Server extraction started.") job.updated_at = _current_timestamp() _persist_agent_import_job(job) _touch_agent_activity(job.agent_id) if import_root.exists(): shutil.rmtree(import_root) import_root.mkdir(parents=True, exist_ok=True) last_touch = time.monotonic() def extraction_progress() -> None: nonlocal last_touch if time.monotonic() - last_touch >= 30: _touch_agent_activity(job.agent_id) last_touch = time.monotonic() with zipfile.ZipFile(upload_path) as archive: _extract_zip_safely(archive, import_root, progress_callback=extraction_progress) try: upload_path.unlink() except FileNotFoundError: pass job = _agent_import_jobs.get(job_id) if job is None: return job.server_path = import_root.as_posix() job.metadata = {**job.metadata, "upload_processing": False, "upload_path": None} job.logs.append(f"Uploaded and extracted {filename} ({size} bytes) to {job.server_path}.") job.updated_at = _current_timestamp() _persist_agent_import_job(job) _touch_agent_activity(job.agent_id) import_request = ImportRequest( source=job.source, path=job.server_path, credentials_ref=job.credentials_ref, metadata={ **job.metadata, "agent_id": job.agent_id, "agent_local_path": job.local_path, "agent_bin_path": job.bin_path, "agent_infobase": job.infobase, }, structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP}, mode=job.mode, ) operation_job = _create_agent_server_import_job(job, import_request) job.status = AgentImportJobStatus.RUNNING job.completed_at = None job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id} job.logs.append(f"Server import queued as {operation_job.job_id}.") job.updated_at = _current_timestamp() _persist_agent_import_job(job) _run_agent_server_import_job(job.job_id, operation_job.job_id, job.project_id, import_request) except Exception as error: job = _agent_import_jobs.get(job_id) if job is not None: job.status = AgentImportJobStatus.FAILED job.error = str(error) job.logs.append(f"Server extraction failed: {error}") job.updated_at = _current_timestamp() job.completed_at = job.updated_at job.metadata = {**job.metadata, "upload_processing": False} _persist_agent_import_job(job) def _active_server_import_job(project_id: str) -> OperationJob | None: active_statuses = {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING} candidates = [ job for job in _operations.jobs.values() if job.kind == "SERVER_IMPORT" and job.payload.get("project_id") == project_id and job.status in active_statuses ] if not candidates: return None return sorted(candidates, key=lambda item: item.created_at, reverse=True)[0] def _update_import_job_progress( job_id: str, message: str, *, status: OperationJobStatus | None = None, result: dict | None = None, error: str | None = None, **extra, ) -> OperationJob | None: job = _operations.jobs.get(job_id) if job is None: return None payload = dict(job.payload) logs = list(payload.get("logs") if isinstance(payload.get("logs"), list) else []) if message and (not logs or logs[-1] != message): logs.append(message) payload.update(extra) payload["message"] = message payload["logs"] = logs[-50:] if status == OperationJobStatus.RUNNING and not payload.get("started_at"): payload["started_at"] = _current_timestamp() job.payload = payload if status is not None: job.status = status if result is not None: job.result = result job.error = error from datetime import datetime, timezone job.updated_at = datetime.now(timezone.utc) _persist_job(job) return job def _emit_import_progress(progress: ImportProgressCallback | None, message: str, **extra) -> None: if progress is not None: progress(message, extra) def _persist_agent_import_job(job: AgentImportJob) -> AgentImportJob: _agent_import_jobs[job.job_id] = job _storage.write_document("agent_import_jobs", job.job_id, job.model_dump(mode="json")) return job def _persist_agent_browse_request(request: AgentBrowseRequest) -> AgentBrowseRequest: _agent_browse_requests[request.request_id] = request _storage.write_document("agent_browse_requests", request.request_id, request.model_dump(mode="json")) return request def _persist_agent_status(status: AgentStatus) -> AgentStatus: status = _agent_status_with_liveness(status) _agent_statuses[status.agent_id] = status _storage.write_document("agent_statuses", status.agent_id, status.model_dump(mode="json")) return status def _touch_agent_activity(agent_id: str) -> None: if not agent_id: return status = _agent_statuses.get(agent_id) or AgentStatus(agent_id=agent_id) status.last_seen_at = _current_timestamp() status.status = "online" _persist_agent_status(status) def _browse_smb_folders( *, path: str, username: str | None, password: str | None, domain: str | None, ) -> ServerBrowseResponse: try: import smbclient except ImportError as error: raise RuntimeError("SMB client dependency is not installed on the API server.") from error server, _share, _relative = _parse_unc_path(path) if username: qualified_user = f"{domain}\\{username}" if domain else username smbclient.register_session(server, username=qualified_user, password=password or "") else: smbclient.register_session(server) entries: list[AgentFolderEntry] = [] base_path = path.rstrip("\\") for item in smbclient.scandir(path): try: if not item.is_dir(): continue except OSError: continue child_path = f"{base_path}\\{item.name}" entries.append(AgentFolderEntry(name=item.name, path=child_path, is_directory=True)) if len(entries) >= 200: break parent = _unc_parent_path(path) return ServerBrowseResponse(path=path, parent_path=parent, entries=sorted(entries, key=lambda item: item.name.lower())) def _copy_smb_tree_to_local( *, source: str, target: Path, username: str | None, password: str | None, domain: str | None, progress: ImportProgressCallback | None = None, ) -> None: try: import smbclient except ImportError as error: raise RuntimeError("SMB client dependency is not installed on the API server.") from error server, _share, _relative = _parse_unc_path(source) if username: qualified_user = f"{domain}\\{username}" if domain else username smbclient.register_session(server, username=qualified_user, password=password or "") else: smbclient.register_session(server) stats = {"bytes_copied": 0, "files_copied": 0, "last_emit": 0.0} _copy_smb_directory(smbclient, source.rstrip("\\"), target, progress=progress, stats=stats) def _copy_smb_directory( smbclient_module, source: str, target: Path, *, progress: ImportProgressCallback | None, stats: dict[str, float | int], ) -> None: target.mkdir(parents=True, exist_ok=True) for item in smbclient_module.scandir(source): destination = target / item.name child_source = f"{source}\\{item.name}" try: if item.is_dir(): _emit_import_progress( progress, f"Чтение папки: {child_source}", stage="copying", bytes_copied=int(stats["bytes_copied"]), files_copied=int(stats["files_copied"]), ) _copy_smb_directory(smbclient_module, child_source, destination, progress=progress, stats=stats) continue except OSError: continue with smbclient_module.open_file(child_source, mode="rb") as remote_file: with destination.open("wb") as local_file: while True: chunk = remote_file.read(1024 * 1024) if not chunk: break local_file.write(chunk) stats["bytes_copied"] = int(stats["bytes_copied"]) + len(chunk) now = time.monotonic() if now - float(stats["last_emit"]) >= 2: stats["last_emit"] = now _emit_import_progress( progress, f"Копирование SMB: {_format_bytes(int(stats['bytes_copied']))}, файлов {int(stats['files_copied'])}", stage="copying", bytes_copied=int(stats["bytes_copied"]), files_copied=int(stats["files_copied"]), ) stats["files_copied"] = int(stats["files_copied"]) + 1 _emit_import_progress( progress, f"Скопирован файл: {item.name}", stage="copying", bytes_copied=int(stats["bytes_copied"]), files_copied=int(stats["files_copied"]), ) def _format_bytes(value: int) -> str: units = ["B", "KB", "MB", "GB", "TB"] amount = float(value) for unit in units: if amount < 1024 or unit == units[-1]: return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B" amount /= 1024 return f"{value} B" def _parse_unc_path(path: str) -> tuple[str, str, str]: parts = [part for part in path.strip("\\").split("\\") if part] if len(parts) < 2: raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.") server, share = parts[0], parts[1] relative = "\\".join(parts[2:]) return server, share, relative def _unc_parent_path(path: str) -> str | None: parts = [part for part in path.strip("\\").split("\\") if part] if len(parts) <= 2: return None return "\\\\" + "\\".join(parts[:-1]) def _safe_storage_name(value: str) -> str: return "".join(character if character.isalnum() or character in {"-", "_", "."} else "_" for character in value) def _extract_zip_safely( archive: zipfile.ZipFile, destination: Path, progress_callback: Callable[[], None] | None = None, ) -> None: destination_root = destination.resolve() for member in archive.infolist(): target = (destination_root / member.filename).resolve() if destination_root != target and destination_root not in target.parents: raise HTTPException(status_code=400, detail=f"Unsafe zip member path: {member.filename}") archive.extract(member, destination_root) if progress_callback is not None: progress_callback() def _append_and_persist_versions(snapshot: SirSnapshot) -> list[SemanticObjectVersion]: versions = _versions.append_snapshot_versions( snapshot, task_id=snapshot.metadata.task_id, session_id=snapshot.metadata.session_id, ) for version in versions: _storage.write_document("object_versions", version.version_id, version.model_dump(mode="json")) return versions def _version_summary(version: SemanticObjectVersion) -> ObjectVersionSummary: return ObjectVersionSummary( version_id=version.version_id, lineage_id=version.lineage_id, semantic_id=version.semantic_id, object_hash=version.object_hash, parent_version_id=version.parent_version_id, task_id=version.task_id, session_id=version.session_id, ) def _find_snapshot_node(snapshot: SirSnapshot, lineage_id: str): return next((node for node in snapshot.nodes if node.lineage_id == lineage_id), None) def _source_location(source_ref) -> SourceLocationResponse: if source_ref is None: return SourceLocationResponse() return SourceLocationResponse( source_path=source_ref.source_path, line_start=source_ref.line_start, line_end=source_ref.line_end, column_start=source_ref.column_start, column_end=source_ref.column_end, ) def _symbol_response(node) -> SymbolResponse: return SymbolResponse(node=_named_node(node), source=_source_location(node.source_ref)) def _named_node(node) -> NamedNode: return NamedNode( lineage_id=node.lineage_id, kind=node.kind.value, name=node.name, qualified_name=node.qualified_name, )