from __future__ import annotations
import json
import base64
import hashlib
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 neo4j import AsyncGraphDatabase
from pydantic import BaseModel, Field
from api_server.html5 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_editor,
render_html5_flowchart,
render_html5_index,
render_html5_metadata_apply_result,
render_html5_metadata_preview_result,
render_html5_object_context,
render_html5_project_setup,
render_html5_project_rows,
render_html5_project_report,
render_html5_review,
render_html5_symbol_detail,
render_html5_import_check,
render_html5_import_job,
render_html5_operation_rows,
render_html5_operation_summary,
render_html5_operations,
render_html5_settings_panel,
render_html5_setup_summary,
render_html5_source,
render_html5_status,
render_html5_symbols,
)
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")
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() -> Response:
return Response(
render_html5_operations(_html5_operation_jobs()),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/jobs")
async def html5_operation_jobs() -> Response:
return Response(
render_html5_operation_rows(_html5_operation_jobs()),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/summary")
async def html5_operation_summary() -> Response:
return Response(
render_html5_operation_summary(_html5_operation_jobs()),
media_type="text/html; charset=utf-8",
)
@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:
def stream_status():
while True:
try:
snapshot = _project_snapshot_or_404(project_id)
fragment = render_html5_status(project_id, snapshot)
except HTTPException as error:
fragment = f'project: {project_id}error: {error.detail}'
yield f"event: status\ndata: {fragment}\n\n"
if once:
break
time.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/{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),
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) -> 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")
object_context = render_html5_object_context(
project_id,
schema,
impact,
access,
ui,
runtime,
knowledge,
privacy,
integrations,
flowchart,
)
flowchart_context = render_html5_flowchart(project_id, flowchart, focus=object_name, 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)
return Response(
object_context + flowchart_context + source_context + symbol_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.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 _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() -> list[OperationJob]:
return sorted(_operations.jobs.values(), key=lambda job: job.updated_at, reverse=True)[:50]
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,
)