from __future__ import annotations # Deployment rule: adapter-1c-mcp is deployed and integration-tested on # docker.cin.su. Do not substitute test-docker unless the task explicitly # requests the staging environment. import argparse import base64 import copy from collections import Counter import hmac import concurrent.futures import csv import difflib import hashlib import io import json import math import multiprocessing import os import queue import re import signal import sqlite3 import sys import threading import time import traceback import urllib.parse import urllib.error import urllib.request import uuid import xml.etree.ElementTree as ET import zlib from contextlib import contextmanager from datetime import date, datetime, timezone from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Iterable CONNECTOR_ROOT = Path(__file__).resolve().parent if str(CONNECTOR_ROOT) not in sys.path: sys.path.insert(0, str(CONNECTOR_ROOT)) import repository_control from write.context import AdapterWriteContext from write.handlers.scheduled_job import execute as execute_scheduled_job_handler from write.registry import registered_handlers, select_write_handler SCHEMA = "onec_adapter_rest.v1" PARSER_ROOT = Path(__file__).resolve().parents[1] if len(Path(__file__).resolve().parents) >= 2 else Path(__file__).resolve().parent if PARSER_ROOT.exists() and str(PARSER_ROOT) not in sys.path: sys.path.insert(0, str(PARSER_ROOT)) from parser.scheduled_job import ( SCHEDULED_JOB_INTEGER_RANGES, SCHEDULED_JOB_WRITABLE_LIST_FIELDS, SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS, config_schedule_datetime, scheduled_job_schedule_layout, scheduled_job_schedule_rebuild_tree, scheduled_job_schedule_write_edits, scheduled_job_sql_schedule, ) from parser.common_command import common_command_group_guid, index_common_command_groups STORAGE_TABLES = {"Config", "ConfigSave", "ConfigCAS", "ConfigCASSave", "Params"} SENSITIVE_RESULT_FIELD_RE = re.compile( r"(?:парол|password|телефон|phone|email|e-mail|почт|паспорт|инн|снилс|банк(?:овск)?(?:ий)?счет|card|карта)", re.IGNORECASE, ) ADAPTER_JOBS: dict[str, dict[str, Any]] = {} ADAPTER_JOB_LOCK = threading.Lock() ADAPTER_JOB_TTL_SECONDS = 1800 ADAPTER_STATE_SCHEMA_VERSION = 2 ADAPTER_JOB_STORE_LOADED = False ADAPTER_INSTANCE_ID = uuid.uuid4().hex # Not exposed through health, jobs, or audit records. It authenticates only # process-local handoff between the authorization, preparation and writer # phases of one request. ADAPTER_WRITE_AUTHORIZATION_SECRET = os.urandom(32) ADAPTER_JOB_EVENT_SINK: Any = None ADAPTER_JOB_CONTEXT = threading.local() CONFIGURATION_ACTIVATION_STATE_LOCK = threading.RLock() BASE_ROOT_METADATA_CACHE: dict[tuple[str, str], dict[str, Any]] = {} BASE_ROOT_METADATA_CACHE_LOCK = threading.Lock() BASE_ROOT_METADATA_CACHE_TTL_SECONDS = 300 DATA_SCHEMA_CACHE: dict[str, dict[str, Any]] = {} DATA_SCHEMA_CACHE_LOCK = threading.Lock() DATA_SCHEMA_CACHE_TTL_SECONDS = 300 CHART_VALUE_TYPE_CACHE: dict[tuple[str, str], dict[str, Any]] = {} CHART_VALUE_TYPE_CACHE_LOCK = threading.Lock() CHART_VALUE_TYPE_CACHE_TTL_SECONDS = 1800 MOXEL_TEMPLATE_ARTIFACT_KIND = "template_part_moxel_v11" SCD_TEMPLATE_ARTIFACT_KIND = "dcs_template_inspect_v1" TEMPLATE_CONTENT_DEFAULT_MAX_BYTES = 262144 TEMPLATE_CONTENT_MAX_BYTES = 1048576 FULL_METHOD_SECTIONS = {"card", "semantic", "forms", "templates", "commands", "modules", "parts_summary"} FULL_METHOD_SECTION_ORDER = ["card", "semantic", "modules", "templates", "forms", "commands"] FULL_METHOD_ALL_KEY = "all" FULL_METHOD_DEFAULT_SECTIONS = ["card", "semantic", "modules", "templates", "forms", "commands"] ONEC_TEMPLATE_PLATFORM_TYPES = [ { "id": "tabular_document", "name": "Табличный документ", "xml_type": "SpreadsheetDocument", "decoder": "moxel", "status": "structure_and_safe_export_supported", "aliases": ["ТабличныйДокумент", "SpreadsheetDocument"], }, { "id": "text_document", "name": "Текстовый документ", "xml_type": "TextDocument", "decoder": "text", "status": "safe_export_supported", "aliases": ["ТекстовыйДокумент", "TextDocument"], }, { "id": "binary_data", "name": "Двоичные данные", "xml_type": "BinaryData", "decoder": "binary", "status": "safe_export_supported", "aliases": ["ДвоичныеДанные", "BinaryData"], }, { "id": "active_document", "name": "Active document", "xml_type": "ActiveDocument", "decoder": "active_document", "status": "not_implemented", "aliases": ["ActiveDocument", "Active document"], }, { "id": "html_document", "name": "HTML документ", "xml_type": "HTMLDocument", "decoder": "html", "status": "safe_export_supported", "aliases": ["HTMLДокумент", "HTMLDocument", "HtmlDocument"], }, { "id": "geographical_schema", "name": "Географическая схема", "xml_type": "GeographicalSchema", "decoder": "geographical_schema", "status": "not_implemented", "aliases": ["ГеографическаяСхема", "GeographicalSchema"], }, { "id": "graphical_schema", "name": "Графическая схема", "xml_type": "GraphicalSchema", "decoder": "graphical_schema", "status": "not_implemented", "aliases": ["ГрафическаяСхема", "GraphicalSchema"], }, { "id": "data_composition_schema", "name": "Схема компоновки данных", "xml_type": "DataCompositionSchema", "decoder": "data_composition_schema", "status": "read_and_scalar_patch_supported", "aliases": ["СхемаКомпоновкиДанных", "DataCompositionSchema"], }, { "id": "data_composition_appearance_template", "name": "Макет оформления компоновки данных", "xml_type": "DataCompositionAppearanceTemplate", "decoder": "data_composition_appearance", "status": "not_implemented", "aliases": ["МакетОформленияКомпоновкиДанных", "DataCompositionAppearanceTemplate"], }, { "id": "external_component", "name": "Внешняя компонента", "xml_type": "ExternalComponent", "decoder": "external_component", "status": "not_implemented", "aliases": ["ВнешняяКомпонента", "ExternalComponent"], }, ] KIND_ALIASES = { "конфигурация": "Configuration", "configuration": "Configuration", "документ": "Document", "documents": "Document", "document": "Document", "справочник": "Catalog", "catalogs": "Catalog", "catalog": "Catalog", "перечисление": "Enum", "enums": "Enum", "enum": "Enum", "регистрсведений": "InformationRegister", "регистр сведений": "InformationRegister", "informationregister": "InformationRegister", "informationregisters": "InformationRegister", "регистрнакопления": "AccumulationRegister", "регистр накопления": "AccumulationRegister", "accumulationregister": "AccumulationRegister", "accumulationregisters": "AccumulationRegister", "регистрбухгалтерии": "AccountingRegister", "регистр бухгалтерии": "AccountingRegister", "accountingregister": "AccountingRegister", "accountingregisters": "AccountingRegister", "отчет": "Report", "reports": "Report", "report": "Report", "обработка": "DataProcessor", "обработки": "DataProcessor", "dataprocessor": "DataProcessor", "dataprocessors": "DataProcessor", "общиймодуль": "CommonModule", "общий модуль": "CommonModule", "commonmodule": "CommonModule", "commonmodules": "CommonModule", "общаяформа": "CommonForm", "общая форма": "CommonForm", "общиеформы": "CommonForm", "общие формы": "CommonForm", "commonform": "CommonForm", "commonforms": "CommonForm", "форма": "Form", "forms": "Form", "form": "Form", "макет": "Template", "templates": "Template", "template": "Template", "команда": "Command", "commands": "Command", "command": "Command", "константа": "Constant", "constants": "Constant", "constant": "Constant", "планвидовхарактеристик": "ChartOfCharacteristicTypes", "план видов характеристик": "ChartOfCharacteristicTypes", "chartofcharacteristictypes": "ChartOfCharacteristicTypes", "плансчетов": "ChartOfAccounts", "план счетов": "ChartOfAccounts", "chartofaccounts": "ChartOfAccounts", "планвидоврасчета": "ChartOfCalculationTypes", "план видов расчета": "ChartOfCalculationTypes", "chartofcalculationtypes": "ChartOfCalculationTypes", "планобмена": "ExchangePlan", "план обмена": "ExchangePlan", "exchangeplan": "ExchangePlan", "журналдокументов": "DocumentJournal", "журнал документов": "DocumentJournal", "documentjournal": "DocumentJournal", "нумератордокументов": "DocumentNumerator", "нумератор документов": "DocumentNumerator", "documentnumerator": "DocumentNumerator", "documentnumerators": "DocumentNumerator", "регламентноезадание": "ScheduledJob", "регламентное задание": "ScheduledJob", "scheduledjob": "ScheduledJob", "сервисинтеграции": "IntegrationService", "сервис интеграции": "IntegrationService", "integrationservice": "IntegrationService", "integrationservices": "IntegrationService", "регистррасчета": "CalculationRegister", "регистр расчета": "CalculationRegister", "calculationregister": "CalculationRegister", "последовательность": "Sequence", "sequence": "Sequence", "критерийотбора": "SelectionCriterion", "критерий отбора": "SelectionCriterion", "selectioncriterion": "SelectionCriterion", "filtercriterion": "SelectionCriterion", "filtercriteria": "SelectionCriterion", "подписканасобытие": "EventSubscription", "подписка на событие": "EventSubscription", "eventsubscription": "EventSubscription", "роль": "Role", "role": "Role", "определяемыйтип": "DefinedType", "определяемый тип": "DefinedType", "definedtype": "DefinedType", "общиймакет": "CommonTemplate", "общий макет": "CommonTemplate", "commontemplate": "CommonTemplate", "общаякартинка": "CommonPicture", "общая картинка": "CommonPicture", "commonpicture": "CommonPicture", "группакоманд": "CommandGroup", "группа команд": "CommandGroup", "commandgroup": "CommandGroup", "стиль": "Style", "style": "Style", "элементстиля": "StyleItem", "элемент стиля": "StyleItem", "styleitem": "StyleItem", "интерфейс": "Interface", "interface": "Interface", } PUBLIC_KIND = { "Configuration": "configuration", "Catalog": "catalog", "Document": "document", "Enum": "enum", "InformationRegister": "register", "AccumulationRegister": "register", "AccountingRegister": "register", "Report": "report", "DataProcessor": "processing", "CommonModule": "common_module", "CommonForm": "common_form", "BusinessProcess": "business_process", "Task": "task", "ChartOfAccounts": "chart", "ChartOfCalculationTypes": "chart", "ChartOfCharacteristicTypes": "chart", "Constant": "constant", "ExchangePlan": "exchange_plan", "DocumentJournal": "document_journal", "DocumentNumerator": "document_numerator", "ScheduledJob": "scheduled_job", "CalculationRegister": "register", "Sequence": "sequence", "SelectionCriterion": "selection_criterion", "EventSubscription": "event_subscription", "Role": "role", "DefinedType": "defined_type", "SessionParameter": "session_parameter", "FunctionalOption": "functional_option", "FunctionalOptionsParameter": "functional_options_parameter", "SettingsStorage": "settings_storage", "CommonAttribute": "common_attribute", "CommonCommand": "common_command", "Subsystem": "subsystem", "Language": "language", "WebService": "web_service", "HTTPService": "http_service", "WSReference": "ws_reference", "XDTOPackage": "xdto_package", "ExternalDataSource": "external_data_source", "IntegrationService": "integration_service", "CommonTemplate": "common_template", "CommonPicture": "common_picture", "CommandGroup": "command_group", "Style": "style", "StyleItem": "style_item", "Interface": "interface", } DBNAMES_ROLE_KIND = { "Document": "Document", "Reference": "Catalog", "Enum": "Enum", "Report": "Report", "DataProcessor": "DataProcessor", "InfoRg": "InformationRegister", "AccumRg": "AccumulationRegister", "AccRg": "AccountingRegister", "BPr": "BusinessProcess", "Task": "Task", "Const": "Constant", "Chrc": "ChartOfCharacteristicTypes", "CKinds": "ChartOfCalculationTypes", "Acc": "ChartOfAccounts", "Node": "ExchangePlan", "DocumentJournal": "DocumentJournal", "ScheduledJobs": "ScheduledJob", "CalcRg": "CalculationRegister", "Sequence": "Sequence", } # The base configuration root descriptor contains collections that do not have # their own DBNames data-table route. These platform collection identifiers are # read from Config/root and let discovery cover code/configuration-only objects # without inventing SQL storage identifiers for callers. ROOT_COLLECTION_KIND = { "09736b02-9cac-4e3f-b4f7-d3e9576ab948": "Role", "0c89c792-16c3-11d5-b96b-0050bae0a95d": "CommonTemplate", "0fe48980-252d-11d6-a3c7-0050bae0a776": "CommonModule", "0fffc09c-8f4c-47cc-b41c-8d5c5a221d79": "HTTPService", "11bdaf85-d5ad-4d91-bb24-aa0eee139052": "ScheduledJob", "15794563-ccec-41f6-a83c-ec5f7b9a5bc1": "CommonAttribute", "24c43748-c938-45d0-8d14-01424a72b11e": "SessionParameter", "30d554db-541e-4f62-8970-a1c6dcfeb2bc": "FunctionalOptionsParameter", "37f2fa9a-b276-11d4-9435-004095e12fc7": "Subsystem", "3e5404af-6ef8-4c73-ad11-91bd2dfac4c8": "Style", "3e7bfcc0-067d-11d6-a3c7-0050bae0a776": "SelectionCriterion", "46b4cd97-fd13-4eaa-aba2-3bddd7699218": "SettingsStorage", "4e828da6-0f44-4b5b-b1c0-a2b3cfe7bdcc": "EventSubscription", "58848766-36ea-4076-8800-e91eb49590d7": "StyleItem", "7dcd43d9-aca5-4926-b549-1842e6a4e8cf": "CommonPicture", "857c4a91-e5f4-4fac-86ec-787626f1c108": "ExchangePlan", "8657032e-7740-4e1d-a3ba-5dd6e8afb78f": "WebService", "9cd510ce-abfc-11d4-9434-004095e12fc7": "Language", "a7641777-7813-45c6-96ef-9d51587a6ac6": "Interface", "af547940-3268-434f-a3e7-e47d6d2638c3": "FunctionalOption", "c045099e-13b9-4fb6-9d50-fca00202971e": "DefinedType", "cc9df798-7c94-4616-97d2-7aa0b7bc515e": "XDTOPackage", "d26096fb-7a5d-4df9-af63-47d04771fa9b": "WSReference", "5274d9fc-9c3a-4a71-8f5e-a0db8ab23de5": "ExternalDataSource", "bf3420b0-f6f9-41a0-b83a-fe9d4ab0b65d": "IntegrationService", } # The application-object block in the same root descriptor is ordered by the # platform format. Each collection starts with its declared count followed by # public metadata object GUIDs. ROOT_APPLICATION_COLLECTION_KIND = { 0: "Constant", 1: "Document", 2: "CommonForm", 3: "InformationRegister", 4: "CommandGroup", 5: "CommonCommand", 6: "DocumentNumerator", 7: "DocumentJournal", 8: "Report", 9: "ChartOfCharacteristicTypes", 10: "AccumulationRegister", 11: "CalculationRegister", 12: "DataProcessor", 13: "Catalog", 14: "Enum", } ROOT_APPLICATION_CLASS_KIND = { "0195e80c-b157-11d4-9435-004095e12fc7": "Constant", "061d872a-5787-460e-95ac-ed74ea3a3e84": "Document", "07ee8426-87f1-11d5-b99c-0050bae0a95d": "CommonForm", "13134201-f60b-11d5-a3c7-0050bae0a776": "InformationRegister", "1c57eabe-7349-44b3-b1de-ebfeab67b47d": "CommandGroup", "2f1a5187-fb0e-4b05-9489-dc5dd6412348": "CommonCommand", "36a8e346-9aaa-4af9-bdbd-83be3c177977": "DocumentNumerator", "4612bd75-71b7-4a5c-8cc5-2b0b65f9fa0d": "DocumentJournal", "631b75a0-29e2-11d6-a3c7-0050bae0a776": "Report", "82a1b659-b220-4d94-a9bd-14d757b95a48": "ChartOfCharacteristicTypes", "b64d9a40-1642-11d6-a3c7-0050bae0a776": "AccumulationRegister", "bc587f20-35d9-11d6-a3c7-0050bae0a776": "CalculationRegister", "bf845118-327b-4682-b5c6-285d2a0eb296": "DataProcessor", "cf4abea6-37b2-11d4-940f-008048da11f9": "Catalog", "f6a80749-5ad7-400b-8519-39dc5dff2542": "Enum", } ROOT_DISCOVERY_KIND_SET = set(ROOT_COLLECTION_KIND.values()) | {"Configuration", "CommonForm", "CommonCommand", "CommandGroup", "DocumentNumerator", "Report", "DataProcessor"} GENERATED_TYPE_PREFIX = { "Catalog": "Catalog", "Document": "Document", "Enum": "Enum", "InformationRegister": "InformationRegister", "AccumulationRegister": "AccumulationRegister", "AccountingRegister": "AccountingRegister", "BusinessProcess": "BusinessProcess", "Task": "Task", "Constant": "Constant", "ChartOfCharacteristicTypes": "ChartOfCharacteristicTypes", "ChartOfAccounts": "ChartOfAccounts", "ChartOfCalculationTypes": "ChartOfCalculationTypes", "ExchangePlan": "ExchangePlan", "DocumentJournal": "DocumentJournal", "ScheduledJob": "ScheduledJob", "DefinedType": "DefinedType", } GENERATED_TYPE_CATEGORIES = { "Catalog": ["Object", "Ref", "Selection", "List", "Manager"], "Document": ["Object", "Ref", "Selection", "List", "Manager"], "Enum": ["Ref", "Manager", "List"], "BusinessProcess": ["Object", "Ref", "Selection", "List", "RoutePointRef", "RoutePoint", "Manager"], "Task": ["Object", "Ref", "Selection", "List", "Manager"], "InformationRegister": ["Record", "Manager", "Selection", "List", "RecordSet", "RecordKey", "RecordManager"], "AccumulationRegister": ["Record", "Manager", "Selection", "List", "RecordSet", "RecordKey", "RecordManager"], "AccountingRegister": ["Record", "ExtDimensions", "RecordSet", "RecordKey", "Selection", "List", "Manager"], "Constant": ["Manager", "ValueManager"], "ChartOfCharacteristicTypes": ["Object", "Ref", "Selection", "List", "Manager"], "ChartOfAccounts": ["Object", "Ref", "Selection", "List", "Manager"], "ChartOfCalculationTypes": ["Object", "Ref", "Selection", "List", "Manager"], "ExchangePlan": ["Object", "Ref", "Selection", "List", "Manager"], "DocumentJournal": ["Selection", "List", "Manager"], "ScheduledJob": ["Manager"], "DefinedType": ["DefinedType"], } BUILTIN_TYPE_GUIDS = { "e199ca70-93cf-46ce-a54b-6edc88c3a296": { "name": "ХранилищеЗначения", "presentation": "ХранилищеЗначения", "bsl_type": "ValueStorage", }, "220455ea-6c85-4513-996f-bbe79ed07774": { "name": "ФиксированноеСоответствие", "presentation": "ФиксированноеСоответствие", "bsl_type": "FixedMap", }, "3ee983d7-ace7-40f9-bb7e-2e916fcddd56": { "name": "ФиксированнаяСтруктура", "presentation": "ФиксированнаяСтруктура", "bsl_type": "FixedStructure", }, "4500381b-db30-4a10-9db4-990038032acf": { "name": "ФиксированныйМассив", "presentation": "ФиксированныйМассив", "bsl_type": "FixedArray", }, "fc01b5df-97fe-449b-83d4-218a090e681e": { "name": "УникальныйИдентификатор", "presentation": "УникальныйИдентификатор", "bsl_type": "UUID", }, } RU_KIND = { "Configuration": "Конфигурация", "Catalog": "Справочник", "Document": "Документ", "Enum": "Перечисление", "InformationRegister": "РегистрСведений", "AccumulationRegister": "РегистрНакопления", "AccountingRegister": "РегистрБухгалтерии", "Report": "Отчет", "DataProcessor": "Обработка", "CommonModule": "ОбщийМодуль", "CommonForm": "ОбщаяФорма", "Form": "Форма", "Template": "Макет", "Command": "Команда", "BusinessProcess": "БизнесПроцесс", "Task": "Задача", "Constant": "Константа", "ChartOfCharacteristicTypes": "ПланВидовХарактеристик", "ChartOfAccounts": "ПланСчетов", "ChartOfCalculationTypes": "ПланВидовРасчета", "ExchangePlan": "ПланОбмена", "DocumentJournal": "ЖурналДокументов", "DocumentNumerator": "НумераторДокументов", "ScheduledJob": "РегламентноеЗадание", "DefinedType": "ОпределяемыйТип", "CalculationRegister": "РегистрРасчета", "Sequence": "Последовательность", "SelectionCriterion": "КритерийОтбора", "EventSubscription": "ПодпискаНаСобытие", "Role": "Роль", "SessionParameter": "ПараметрСеанса", "FunctionalOption": "ФункциональнаяОпция", "FunctionalOptionsParameter": "ПараметрФункциональныхОпций", "SettingsStorage": "ХранилищеНастроек", "CommonAttribute": "ОбщийРеквизит", "CommonCommand": "ОбщаяКоманда", "Subsystem": "Подсистема", "Language": "Язык", "WebService": "WebСервис", "HTTPService": "HTTPСервис", "WSReference": "WSСсылка", "XDTOPackage": "ПакетXDTO", "ExternalDataSource": "ВнешнийИсточникДанных", "IntegrationService": "СервисИнтеграции", "CommonTemplate": "ОбщийМакет", "CommonPicture": "ОбщаяКартинка", "CommandGroup": "ГруппаКоманд", "Style": "Стиль", "StyleItem": "ЭлементСтиля", "Interface": "Интерфейс", } ONEC_CODE_ROOT_KIND = { "Конфигурация": "Configuration", "Справочники": "Catalog", "Документы": "Document", "Перечисления": "Enum", "РегистрыСведений": "InformationRegister", "РегистрыНакопления": "AccumulationRegister", "РегистрыБухгалтерии": "AccountingRegister", "Отчеты": "Report", "Обработки": "DataProcessor", "ОбщиеМодули": "CommonModule", "ОбщиеФормы": "CommonForm", "БизнесПроцессы": "BusinessProcess", "Задачи": "Task", "Константы": "Constant", "ПланыВидовХарактеристик": "ChartOfCharacteristicTypes", "ПланыСчетов": "ChartOfAccounts", "ПланыВидовРасчета": "ChartOfCalculationTypes", "ПланыОбмена": "ExchangePlan", "ЖурналыДокументов": "DocumentJournal", "НумераторыДокументов": "DocumentNumerator", "РегламентныеЗадания": "ScheduledJob", "ОпределяемыеТипы": "DefinedType", "РегистрыРасчета": "CalculationRegister", "Последовательности": "Sequence", "КритерииОтбора": "SelectionCriterion", "ПодпискиНаСобытия": "EventSubscription", "Роли": "Role", "ПараметрыСеанса": "SessionParameter", "ФункциональныеОпции": "FunctionalOption", "ПараметрыФункциональныхОпций": "FunctionalOptionsParameter", "ХранилищаНастроек": "SettingsStorage", "ОбщиеРеквизиты": "CommonAttribute", "ОбщиеКоманды": "CommonCommand", "Подсистемы": "Subsystem", "Языки": "Language", "WebСервисы": "WebService", "HTTPСервисы": "HTTPService", "WSСсылки": "WSReference", "ПакетыXDTO": "XDTOPackage", "ВнешниеИсточникиДанных": "ExternalDataSource", "СервисыИнтеграции": "IntegrationService", "ОбщиеМакеты": "CommonTemplate", "ОбщиеКартинки": "CommonPicture", "ГруппыКоманд": "CommandGroup", "Стили": "Style", "ЭлементыСтиля": "StyleItem", "Интерфейсы": "Interface", } ONEC_CODE_ROOT_BY_KIND = {kind: root for root, kind in ONEC_CODE_ROOT_KIND.items()} ONEC_TYPE_QUALIFIER_KIND = { "СправочникСсылка": "Catalog", "СправочникОбъект": "Catalog", "СправочникСписок": "Catalog", "СправочникВыборка": "Catalog", "СправочникМенеджер": "Catalog", "ДокументСсылка": "Document", "ДокументОбъект": "Document", "ДокументСписок": "Document", "ДокументВыборка": "Document", "ДокументМенеджер": "Document", "ПеречислениеСсылка": "Enum", "ПеречислениеСписок": "Enum", "ПеречислениеМенеджер": "Enum", "БизнесПроцессСсылка": "BusinessProcess", "БизнесПроцессОбъект": "BusinessProcess", "БизнесПроцессСписок": "BusinessProcess", "БизнесПроцессВыборка": "BusinessProcess", "БизнесПроцессМенеджер": "BusinessProcess", "ЗадачаСсылка": "Task", "ЗадачаОбъект": "Task", "ЗадачаСписок": "Task", "ЗадачаВыборка": "Task", "ЗадачаМенеджер": "Task", "ПланВидовХарактеристикСсылка": "ChartOfCharacteristicTypes", "ПланВидовХарактеристикОбъект": "ChartOfCharacteristicTypes", "ПланВидовХарактеристикСписок": "ChartOfCharacteristicTypes", "ПланВидовХарактеристикВыборка": "ChartOfCharacteristicTypes", "ПланВидовХарактеристикМенеджер": "ChartOfCharacteristicTypes", "ПланСчетовСсылка": "ChartOfAccounts", "ПланСчетовОбъект": "ChartOfAccounts", "ПланСчетовСписок": "ChartOfAccounts", "ПланСчетовВыборка": "ChartOfAccounts", "ПланСчетовМенеджер": "ChartOfAccounts", "ПланВидовРасчетаСсылка": "ChartOfCalculationTypes", "ПланВидовРасчетаОбъект": "ChartOfCalculationTypes", "ПланВидовРасчетаСписок": "ChartOfCalculationTypes", "ПланВидовРасчетаВыборка": "ChartOfCalculationTypes", "ПланВидовРасчетаМенеджер": "ChartOfCalculationTypes", "ПланОбменаСсылка": "ExchangePlan", "ПланОбменаОбъект": "ExchangePlan", "ПланОбменаСписок": "ExchangePlan", "ПланОбменаВыборка": "ExchangePlan", "ПланОбменаМенеджер": "ExchangePlan", "РегистрСведенийМенеджер": "InformationRegister", "РегистрСведенийМенеджерЗаписи": "InformationRegister", "РегистрСведенийНаборЗаписей": "InformationRegister", "РегистрСведенийКлючЗаписи": "InformationRegister", "РегистрСведенийСписок": "InformationRegister", "РегистрСведенийВыборка": "InformationRegister", "РегистрНакопленияМенеджер": "AccumulationRegister", "РегистрНакопленияНаборЗаписей": "AccumulationRegister", "РегистрНакопленияКлючЗаписи": "AccumulationRegister", "РегистрНакопленияСписок": "AccumulationRegister", "РегистрНакопленияВыборка": "AccumulationRegister", "РегистрБухгалтерииМенеджер": "AccountingRegister", "РегистрБухгалтерииНаборЗаписей": "AccountingRegister", "РегистрБухгалтерииКлючЗаписи": "AccountingRegister", "РегистрБухгалтерииСписок": "AccountingRegister", "РегистрБухгалтерииВыборка": "AccountingRegister", "РегистрРасчетаМенеджер": "CalculationRegister", "РегистрРасчетаНаборЗаписей": "CalculationRegister", "РегистрРасчетаКлючЗаписи": "CalculationRegister", "РегистрРасчетаСписок": "CalculationRegister", "РегистрРасчетаВыборка": "CalculationRegister", "КонстантаМенеджер": "Constant", "КонстантаМенеджерЗначения": "Constant", "ОтчетОбъект": "Report", "ОтчетМенеджер": "Report", "ОбработкаОбъект": "DataProcessor", "ОбработкаМенеджер": "DataProcessor", "ЖурналДокументовСписок": "DocumentJournal", "ЖурналДокументовМенеджер": "DocumentJournal", "ПоследовательностьМенеджер": "Sequence", } REF_TYPE_PRESENTATION_PREFIX = { "Catalog": "СправочникСсылка", "Document": "ДокументСсылка", "Enum": "ПеречислениеСсылка", "BusinessProcess": "БизнесПроцессСсылка", "Task": "ЗадачаСсылка", "ChartOfCharacteristicTypes": "ПланВидовХарактеристикСсылка", "ChartOfAccounts": "ПланСчетовСсылка", "ChartOfCalculationTypes": "ПланВидовРасчетаСсылка", "ExchangePlan": "ПланОбменаСсылка", } OBJECT_TYPE_PRESENTATION_PREFIX = { "Catalog": "СправочникОбъект", "Document": "ДокументОбъект", "BusinessProcess": "БизнесПроцессОбъект", "Task": "ЗадачаОбъект", "ChartOfCharacteristicTypes": "ПланВидовХарактеристикОбъект", "ChartOfAccounts": "ПланСчетовОбъект", "ChartOfCalculationTypes": "ПланВидовРасчетаОбъект", "ExchangePlan": "ПланОбменаОбъект", } LIST_TYPE_PRESENTATION_PREFIX = { "Catalog": "СправочникСписок", "Document": "ДокументСписок", "Enum": "ПеречислениеСписок", "BusinessProcess": "БизнесПроцессСписок", "Task": "ЗадачаСписок", "ChartOfCharacteristicTypes": "ПланВидовХарактеристикСписок", "ChartOfAccounts": "ПланСчетовСписок", "ChartOfCalculationTypes": "ПланВидовРасчетаСписок", "ExchangePlan": "ПланОбменаСписок", } METHODS = [ {"name": "health", "transport": "GET /health", "description": "Состояние REST-адаптера и live SQL-доступа."}, {"name": "help.methods", "transport": "GET /methods or POST /rpc", "description": "Список методов адаптера."}, {"name": "repository.status", "transport": "POST /rpc", "description": "Repository configuration and optional read-only availability probe for direct or configured TCP bridge access."}, {"name": "repository.layers.audit", "transport": "POST /rpc", "description": "Read-only audit of the base configuration and every discovered extension: repository connection state, support state, and the safe next write action."}, {"name": "repository.layer.connection.plan", "transport": "POST /rpc", "description": "Read-only plan for configuring repository policy for base or one extension by name or GUID. It never changes adapter configuration or a 1C database."}, {"name": "repository.layer.connection.set", "transport": "POST /rpc", "description": "Persist the explicit repository connection state for base or one extension in adapter configuration only. Accepts extension name or GUID, requires confirm_repository_connection_change=true, and never writes a 1C SQL database."}, {"name": "metadata.support.decode", "transport": "POST /rpc", "description": "Decode ParentConfigurations support rules from live SQL. The base source is resolved through Config/root and an extension source through its complete ConfigCAS manifest. Repository capture is evaluated separately."}, {"name": "repository.sql_state.snapshot", "transport": "POST /rpc", "description": "Read-only hashes of selected live SQL configuration-state payloads for controlled before/after repository-state experiments. Requires diagnostic=true and never infers a native lock."}, {"name": "repository.sql_state.diff", "transport": "POST /rpc", "description": "Compare two repository.sql_state.snapshot responses and report changed records and parts. Requires diagnostic=true and never infers a native lock."}, {"name": "repository.lock.plan", "transport": "POST /rpc", "description": "Resolve public 1C object references to repository development-object lock scope without changing repository state."}, {"name": "repository.lock.request", "transport": "POST /rpc", "description": "Create a persisted manual lock request for the exact resolved object scope; it does not modify SQL or claim a repository lock."}, {"name": "repository.lock.request.status", "transport": "POST /rpc", "description": "Read the adapter-side status of a manual repository lock request."}, {"name": "repository.lock.request.cancel", "transport": "POST /rpc", "description": "Cancel a pending manual repository lock request with explicit confirmation."}, {"name": "repository.lock", "transport": "POST /rpc", "description": "Lock a planned set of objects through the configured 1C Designer repository endpoint."}, {"name": "repository.lock.confirm", "transport": "POST /rpc", "description": "Record an explicit user confirmation for the exact planned object set when repository lock_mode=manual; this never claims automatic verification."}, {"name": "repository.lock.verify", "transport": "POST /rpc", "description": "Verify an adapter-owned repository lock session."}, {"name": "repository.lock.close", "transport": "POST /rpc", "description": "Close a manual confirmation after the user confirms that the objects were released in Configurator."}, {"name": "repository.unlock", "transport": "POST /rpc", "description": "Release only objects acquired by the specified adapter lock session."}, {"name": "repository.commit.plan", "transport": "POST /rpc", "description": "Validate an adapter-owned lock session, object set, and required repository version comment before commit."}, {"name": "repository.commit", "transport": "POST /rpc", "description": "Commit objects through 1C Designer using configured direct or TCP bridge repository access and explicit approval."}, {"name": "adapter.job.start", "transport": "POST /rpc", "description": "Start a long adapter-owned job. Use adapter.job.get/cancel to observe or cancel it."}, {"name": "adapter.job.get", "transport": "POST /rpc", "description": "Read status, progress, result, and partial_result for an adapter-owned job."}, {"name": "adapter.job.cancel", "transport": "POST /rpc", "description": "Request cancellation of an adapter-owned job."}, {"name": "metadata.kinds", "transport": "GET /metadata/kinds or POST /rpc", "description": "Live 1C metadata kinds. Requires base_id."}, {"name": "metadata.capabilities", "transport": "POST /rpc", "description": "Public adapter capabilities by 1C metadata kind."}, {"name": "metadata.adapter.audit", "transport": "POST /rpc", "description": "Public audit of recognized 1C metadata kinds, public kind counts, code carrier matrix, missing supported kinds, unmapped DBNames roles, child object support, and not-yet-decoded areas."}, {"name": "metadata.objects.list", "transport": "GET /metadata/objects or POST /rpc", "description": "1C base/effective metadata object list. Does not accept extension filters; use extension.objects.find for extension-scoped objects such as test2. Uses local metadata cache for the normal fast list; pass refresh_cache=true, include_missing=true, only_missing=true, or exact_counts=true for live verification. Requires limit >= 1 and offset >= 0. Missing/unreadable payloads are hidden by default; only_missing=true lists only them. SQL/storage traces are hidden unless include_storage=true."}, {"name": "metadata.object.get", "transport": "GET /metadata/object or POST /rpc", "description": "1C metadata object card. mode must be card or semantic; default card returns compact identity/matches/counts without semantic sections. include_semantic and include_storage must be JSON booleans true/false, string values are invalid. Pass mode=semantic or include_semantic=true only when decoded semantic sections are needed; for реквизиты/табличные части prefer metadata.object.attributes, for full profile prefer metadata.object.full/decode. SQL/storage traces are hidden unless include_storage=true."}, {"name": "metadata.object.properties", "transport": "POST /rpc", "description": "Unified SQL-only semantic property reader for every 1C metadata object. Uses a kind-specific SQL decoder when available and falls back to the generic live semantic profile for all other kinds. Accepts the standard public object selectors. XML exports are never read at runtime."}, {"name": "metadata.object.property.write", "transport": "POST /rpc", "description": "Name-first saved-state writer for safe scalar identity properties of objects and existing attributes, tabular sections, dimensions, and resources. Supports synonym and comment, never renames or changes collection structure, never writes active Config/ConfigCAS, and requires explicit prepare/apply/rollback gates."}, {"name": "metadata.object.member.add", "transport": "POST /rpc", "description": "Name-first saved-state structural writer that adds one object requisite or tabular-section column by cloning an existing Attribute template in the same collection. Generates a container-scoped GUID internally, preserves payload formatting, and supports plan/apply/verify/rollback only in ConfigSave/ConfigCASSave."}, {"name": "metadata.object.decode", "transport": "POST /rpc", "description": "1C-facing decoded object profile: identity and semantic sections. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw storage offsets are exposed only with include_storage=true."}, {"name": "metadata.object.parts", "transport": "POST /rpc", "description": "1C-facing object part roles: metadata/form/module/template/help. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Physical Config part keys and raw offsets are hidden unless include_storage=true."}, {"name": "metadata.object.components", "transport": "POST /rpc", "description": "Read-only confirmed component graph for one metadata object. Returns only modules, forms, templates, and SCD nodes confirmed by the existing live-SQL decoders, with public child selectors and explicit unresolved areas. It never infers absent children from an object kind."}, {"name": "metadata.object.modules", "transport": "POST /rpc", "description": "1C-facing BSL module list for a metadata object, including owned form modules. Exact extension objects are resolved from public kind/name or ref through a live-validated route cache. include_storage must be a JSON boolean true/false, string values are invalid. Physical module ids are hidden unless include_storage=true."}, {"name": "metadata.object.related", "transport": "POST /rpc", "description": "1C-facing related metadata objects such as forms and templates. Physical record paths are hidden unless include_storage=true."}, {"name": "report.inspect", "transport": "POST /rpc", "description": "Read-only aggregate of one report's confirmed modules, forms, templates and Data Composition Schemas. Does not infer an SCD from a same-named form or stream."}, {"name": "scd.inspect", "transport": "POST /rpc", "description": "Read-only inspection entry point for a report Data Composition Schema. Resolves a base report from Config or an extension report from ConfigCAS by public names, and returns partial evidence instead of inventing undecoded parameters, datasets, resources, or variants."}, {"name": "appearance.inspect", "transport": "POST /rpc", "description": "Read-only inspection of a base CommonTemplate whose payload has AppearanceTemplate XML root. That root selects a reader only, not the 1C TemplateType. Returns XML-backed appearance rules; no write route is exposed."}, {"name": "scd.prepare", "transport": "POST /rpc", "description": "Prepare the complete SQL saved-state file set required for one report SCD. For base reports it includes both report files and the separately stored Template payload group, while keeping SQL file identifiers internal."}, {"name": "scd.prepare.rollback", "transport": "POST /rpc", "description": "Remove exactly the rows inserted by one SCD preparation receipt after hash precondition checks. This is intended for controlled test cleanup and never touches active Config/ConfigCAS."}, {"name": "scd.compare", "transport": "POST /rpc", "description": "Read-only semantic comparison of active and prepared saved-state SCD XML: Config ↔ ConfigSave for base reports and ConfigCAS ↔ ConfigCASSave for extensions. Compares named parameters, datasets, fields, expressions, totals, and variants rather than storage hashes."}, {"name": "scd.agent_report", "transport": "POST /rpc", "description": "Compact SQL-evidenced SCD report for coding agents: verified sources/fields, static query signals, unresolved items, and safe next calls. Pass field to include dcs.field.lineage for one output field. Never executes the report or applies changes."}, {"name": "dcs.field.lineage", "transport": "POST /rpc", "description": "Trace one SCD field to its query expression and direct 1C metadata sources using SQL-decoded SCD XML. Static evidence only; never executes a 1C query."}, {"name": "scd.patch", "transport": "POST /rpc", "description": "Build a reviewed, byte-preserving saved-state SQL proposal for one SCD dataset query or calculated/resource expression. Resolves report/schema by public names; uses ConfigSave for a base report and ConfigCASSave for an extension; writes no SQL unless explicit apply gates are supplied."}, {"name": "metadata.object.forms", "transport": "POST /rpc", "description": "1C-facing forms for a metadata object with form part roles. include_storage must be a JSON boolean true/false, string values are invalid. Physical Config part keys are hidden unless include_storage=true."}, {"name": "metadata.object.form.details", "transport": "POST /rpc", "description": "Decode object forms into public form properties: elements, attributes, commands, events, links, and decoded form parameters. Optional element/element_path/element_id focuses the returned elements list on one form element. include_parameters controls decoded parameter lists; max_parameters limits parameters per form node. For a heavy form pass execution_mode=job and poll adapter.job.get."}, {"name": "form.field.lineage", "transport": "POST /rpc", "description": "Object-scoped static lineage for a form field: resolves one owner form then returns matching elements, attributes, data paths, expressions and handlers. match_mode=exact is strict; semantic finds data controls whose logical name contains the requested caption while excluding command controls. analysis_profile=fast returns current data-path proof without expensive calculation/call-chain analysis; full keeps all static evidence. For a heavy form pass execution_mode=job and poll adapter.job.get. Never performs a global code search."}, {"name": "metadata.object.templates", "transport": "POST /rpc", "description": "1C-facing templates/makets for a metadata object with decoded content roles and public properties. evidence_mode controls undecoded payload evidence: none, summary, full, raw. include_storage must be a JSON boolean true/false."}, {"name": "metadata.object.template.details", "transport": "POST /rpc", "description": "Detailed public template/maket information: maket name, format, features, safe preview status, and undecoded evidence. evidence_mode=full/raw returns broader payload evidence; raw offsets require include_storage=true."}, {"name": "templates.read", "transport": "POST /rpc", "description": "Read one or more object templates by owner selector/template name or direct route and return public template structure. MOXCEL templates include decoded dimensions, named-area ranges, text/parameter cells, column widths, cell identifiers, coverage, and capability diagnostics. Pass include_content=true for a bounded read-only export (decoded container as base64 plus extracted HTML/text blocks); max_content_bytes is capped at 1 MiB per item. Use view=summary|structure|full and sections/max_* to keep responses compact."}, {"name": "templates.analyze", "transport": "POST /rpc", "description": "Analyze object templates for named areas, parameters, widths, overlaps, cell coverage, likely merge candidates, and MOXCEL decoding capability diagnostics. Use view=summary|structure|full and sections/max_* to keep responses compact."}, {"name": "templates.map", "transport": "POST /rpc", "description": "Compact agent-facing template map. Returns summary structure and analysis for MXL/MOXCEL templates without the full decoded payload lists unless sections/max_* request them."}, {"name": "templates.areas.find", "transport": "POST /rpc", "description": "Find a template by extension/object query or direct route and return decoded named areas with coordinates from current template payloads. Use area_query/area_name/area_occurrence for focused report/print-form maket area lookup; include_coverage=false returns a compact coordinate list."}, {"name": "metadata.object.commands", "transport": "POST /rpc", "description": "1C-facing commands for a metadata object. CommandGroup selectors resolve the reverse CommonCommand.Group relation by public names; callers do not pass GUIDs. Physical record paths are hidden unless include_storage=true."}, {"name": "metadata.definition.find", "transport": "POST /rpc", "description": "Find where a 1C name is defined: top-level metadata objects, object attributes, tabular-section fields, form attributes/elements/events/commands, templates, SCD fields, commands, BSL routines, and extension definitions from DBNames-Ext/ConfigCAS. Accepts public refs such as Обработка. or Document., plus ref, kind/name/guid, or object_type/object_name/object_guid selectors. Returns public 1C locations, origin as configuration/extension when known, read selectors, and related_selectors for next adapter calls. A single metadata object match is promoted to top-level object. areas=metadata/extensions can work without an object selector; object/form/module/scd areas require an object selector. Use areas=[\"scd\"] for a field-scoped SQL SCD trace; schema or template narrows it only when that SCD name is proven by decoded metadata. When an object selector or form is passed and areas is omitted, search is scoped to the selected object/form to avoid a full configuration scan. Default is live verification; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to rebuild the index after configuration/extension updates. For potentially slow BSL/form/template scans use execution_mode=job and poll adapter.job.get; verified partial matches are retained if its time budget expires. No SQL/storage details unless include_storage=true."}, {"name": "metadata.route.resolve", "transport": "POST /rpc", "description": "Resolve live ConfigCAS/DBNames routes for extension metadata objects or child objects by extension, query, kind, or GUID."}, {"name": "metadata.resolve_overrides", "transport": "POST /rpc", "description": "Build the discovered routine override chain for a target object/routine. Default configuration_view=effective_working means the logical Designer snapshot (base, saved changes, and extension layers); runtime_applied is executable now and compare returns both. The chain records extension action evidence (insert_before/insert_after/replace/replace_with_control); it never presents raw storage tables as the programming API."}, {"name": "metadata.object.special.details", "transport": "POST /rpc", "description": "Backward-compatible kind-specific SQL details for Configuration, Constant, CommonAttribute, SessionParameter, FunctionalOption, FunctionalOptionsParameter, DocumentNumerator, IntegrationService, CommandGroup, Role, ScheduledJob, EventSubscription, WebService, HTTPService, and DocumentJournal. Role details include object rights, RLS conditions, and restriction templates from its SQL .0 payload. Prefer metadata.object.properties. For DocumentJournal pass include_column_types=true to resolve column types."}, {"name": "metadata.form.decode", "transport": "POST /rpc", "description": "Decode one form into events, elements, commands, attributes, module summary, and decoded form parameters. view=structure adds a compact static projection; logical parent/child links remain explicitly unresolved until their SQL codec is proven. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw offsets require include_storage=true. For a heavy form pass execution_mode=job and poll adapter.job.get."}, {"name": "metadata.form.owner_index.build", "transport": "POST /rpc", "description": "Build/refresh the SQL-backed form owner index for CommonForm and object-owned forms from extension routes or direct form SQL payloads. XML remains analysis/learning only."}, {"name": "metadata.form.write_target.resolve", "transport": "POST /rpc", "description": "Resolve an agent-facing saved-state form write target into table, file_name, profile section, path, current value, candidates, and writable properties."}, {"name": "metadata.form.write_target.verify", "transport": "POST /rpc", "description": "Read-only agent check for a form write target. Verifies whether a saved-state form target is currently writable or whether the adapter would need to prepare ConfigSave/ConfigCASSave first."}, {"name": "metadata.write_target.resolve", "transport": "POST /rpc", "description": "Resolve one fully-qualified logical write path into an immutable, read-only extension/object/form context. It never prepares or writes saved state and never guesses a physical module container."}, {"name": "metadata.saved_state.prepare", "transport": "POST /rpc", "description": "Prepare an empty saved-state working copy by public 1C object name/ref and semantic layer. Default mode is plan; SQL insert requires allow_sql_saved_state_prepare=true and blocks on target collisions. include_storage=true exposes storage diagnostics."}, {"name": "metadata.saved_state.ensure", "transport": "POST /rpc", "description": "Idempotently ensure a public object, module, or owned form has a saved-state working copy. It selects ConfigSave or ConfigCASSave from extension provenance, copies only missing active parts, and never overwrites existing saved work. Default mode is plan; SQL insert still requires allow_sql_saved_state_prepare=true."}, {"name": "metadata.saved_state.ensure.rollback", "transport": "POST /rpc", "description": "Rollback exactly the rows inserted by one metadata.saved_state.ensure receipt after hash precondition checks. It never changes active Config or ConfigCAS and requires allow_sql_saved_state_rollback=true."}, {"name": "metadata.saved_state.status", "transport": "POST /rpc", "description": "Read-only name-first overview of a base_saved_state or extension_saved_state layer. Public mode reports semantic aggregate state; include_storage=true exposes SQL rows/files, hashes, and diff selectors."}, {"name": "metadata.saved_state.diff", "transport": "POST /rpc", "description": "Agent-facing read-only comparison of a saved-state module with its active source. Prefer a 1C object ref or kind/name plus module_ordinal; generated module_ref and table/file_name remain accepted for follow-up tooling. Reports changed/unchanged, needs_prepare, hashes, and compact payload diff."}, {"name": "metadata.saved_state.changes.list", "transport": "POST /rpc", "description": "Read-only name-first list of pending saved-state changes. Filter by semantic layer; public rows resolve 1C object/form/module context and hide SQL coordinates; include_storage=true exposes per-file diff/write diagnostics."}, {"name": "configuration.activation.status", "transport": "POST /rpc or POST /configuration/activation/status", "description": "Read-only live status of the saved_state → active boundary for base and extension layers. Compares ConfigSave/ConfigCASSave with Config/ConfigCAS through the existing saved-state status reader, never uses the code index as authority, and never starts 1C Designer."}, {"name": "configuration.activation.plan", "transport": "POST /rpc or POST /configuration/activation/plan", "description": "Build a read-only activation handoff plan from live activation status. Returns the exact review and verification calls plus a manual 1C Designer action when activation is required; execution remains unavailable until a separate confirmed Designer bridge is implemented."}, {"name": "configuration.activation.request", "transport": "POST /rpc or POST /configuration/activation/request", "description": "Create an expiring adapter-local activation request bound to an exact live-SQL fingerprint of pending saved-state files. The request does not start Designer or alter the infobase."}, {"name": "configuration.activation.request.status", "transport": "POST /rpc or POST /configuration/activation/request/status", "description": "Read one adapter-local activation request and its expiry/debug acceptance state. No 1C operation is performed."}, {"name": "configuration.activation.request.cancel", "transport": "POST /rpc or POST /configuration/activation/request/cancel", "description": "Cancel one exact adapter-local activation request with explicit confirm_cancel=true. This never starts Designer and does not modify 1C."}, {"name": "configuration.activation.audit", "transport": "POST /rpc or POST /configuration/activation/audit", "description": "List bounded adapter-local activation request summaries and lifecycle events for one base_id. Fingerprinted storage evidence is not exposed."}, {"name": "configuration.activation.capabilities", "transport": "POST /rpc or POST /configuration/activation/capabilities", "description": "Report safe activation workflow and Designer runner readiness without returning credentials, executable paths, URLs, or infobase selectors. Base /UpdateDBCfg remains debug-only; extension execution remains manual until a verified platform command is implemented."}, {"name": "configuration.activation.bridge.probe", "transport": "POST /rpc or POST /configuration/activation/bridge/probe", "description": "Perform an explicit non-mutating local or HTTP runner readiness probe. It validates Designer availability and infobase selector presence without starting Designer, reading configured passwords, or exposing paths/URLs/selectors."}, {"name": "configuration.activation.execute", "transport": "POST /rpc or POST /configuration/activation/execute", "description": "Validate an activation request against a fresh live-SQL fingerprint. Only mode=debug is currently accepted; it records a dry-run acceptance after explicit confirmation and never starts Designer or changes the active configuration."}, {"name": "configuration.activation.verify", "transport": "POST /rpc or POST /configuration/activation/verify", "description": "Verify the post-activation state for one exact activation request through a fresh live-SQL comparison. Marks the request verified_up_to_date only when saved and active layers align; otherwise reports not_activated, changed_since_request, or blocked. Alignment is not presented as proof that Designer performed activation."}, {"name": "metadata.saved_state.forms.search", "transport": "POST /rpc", "description": "Fast name-first saved-state form search by public owner ref, form, element, command, attribute, or text. Public rows expose 1C names and semantic selectors; include_storage=true opts into SQL files, GUIDs, brace paths, markers, and write diagnostics."}, {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast name-first saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts public ref or kind/name selectors and resolves SQL routes internally. Public results expose 1C owner/form/module names, previews, and name-first metadata.write.plan targets; include_storage=true additionally exposes module_ref handles and physical write guards."}, {"name": "metadata.form.write_matrix.build", "transport": "POST /rpc", "description": "Build a source-aware matrix of decoded saved-state form scalar properties and safe write-smoke candidates."}, {"name": "metadata.form.write_matrix.smoke", "transport": "POST /rpc", "description": "Run apply_and_rollback smoke writes for safe entries from metadata.form.write_matrix.build and report verified write routes."}, {"name": "metadata.form.element.write", "transport": "POST /rpc", "description": "Saved-state form element write planner. Resolves a decoded form element and builds a reviewable changes.propose payload for ConfigSave/ConfigCASSave. Requires allow_saved_state_write=true and does not write SQL."}, {"name": "metadata.form.element.write_apply", "transport": "POST /rpc", "description": "Orchestrate saved-state form element write: plan only, apply, or apply_and_rollback smoke run with semantic verification. Requires explicit write/apply gates."}, {"name": "metadata.form.target.move", "transport": "POST /rpc", "description": "Saved-state form structural move planner. Currently supports preserve-format sibling slot swap with apply/apply_and_rollback gates."}, {"name": "metadata.form.command_button.write", "transport": "POST /rpc", "description": "Plan/apply a form command workflow: optional BSL handler routine upsert plus form command and visible command button append. Recognizes CommonForm/top-level common forms and object-owned forms; prepares saved-state when needed. XML is analysis/learning input only, not the live adapter write transport."}, {"name": "metadata.form.command_button.verify", "transport": "POST /rpc", "description": "Read-only verification for a saved-state form command workflow: command, visible button, embedded handler routine, command-handler link, and button-command link. Accepts the same public form selectors as metadata.form.command_button.write."}, {"name": "metadata.module.write_apply", "transport": "POST /rpc", "description": "Orchestrate saved-state BSL module stream writes from module_ref/table/file_name: plan, apply, or apply_and_rollback. Requires explicit saved-state write/apply gates."}, {"name": "metadata.write.plan", "transport": "POST /rpc", "description": "Read-only name-first metadata write planner. For forms pass target.kind=form with extension/ref/form and element, command, or attribute plus edits; for modules reuse the public write_plan_target from metadata.saved_state.modules.search. The adapter resolves saved-state SQL handles internally, reports layer/provenance requirements, and never applies changes."}, {"name": "metadata.write.preflight", "transport": "POST /rpc", "description": "Read-only preflight for high-level writes. Combines metadata.write.plan with live saved-state verification and reports ready, needs_prepare, needs_resolution, or blocked before any write."}, {"name": "metadata.write.capabilities", "transport": "POST /rpc", "description": "Agent-facing matrix of what the adapter can read, plan, and write to the saved-state layer. SQL/storage details are hidden unless include_storage=true."}, {"name": "metadata.write", "transport": "POST /rpc", "description": "High-level working-configuration write orchestrator. Routes forms, modules, and scheduled-job schedules by public 1C names, builds reviewable proposals, and applies only with explicit write gates. Storage preparation and SQL routing are adapter-internal; module writes support text, old/new, and routine_name/routine_text edits with revision guards."}, {"name": "metadata.write.history", "transport": "POST /rpc", "description": "List recent adapter write operations or fetch one operation_id, including status, routed method, target summary, backup ids, and full result for a specific operation."}, {"name": "metadata.write.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state write by operation_id or backup_id using write history evidence. Requires allow_sql_saved_state_rollback=true."}, {"name": "code.write", "transport": "POST /rpc", "description": "Plan-first BSL working-configuration write facade. Works with 1C names and code text; without execution_mode it only builds a logical change plan. Apply/apply_and_verify requires explicit write gates. The adapter prepares and verifies its SQL working copy internally. Supports full module text, routine_name/routine_text, and unique old/new fragment replacement."}, {"name": "metadata.write_learning.capture_before", "transport": "POST /rpc", "description": "Capture a saved-state form baseline for write-rule learning. Stores decoded writable targets and storage sha1 without payload hex."}, {"name": "metadata.write_learning.capture_after", "transport": "POST /rpc", "description": "Capture a saved-state form after a manual Designer edit for write-rule learning."}, {"name": "metadata.write_learning.diff", "transport": "POST /rpc", "description": "Compare before/after write-learning captures and return changed writable form properties."}, {"name": "metadata.write_learning.infer_rule", "transport": "POST /rpc", "description": "Infer a metadata.write payload from a write-learning diff."}, {"name": "metadata.object.attributes", "transport": "POST /metadata/object/attributes or POST /rpc", "description": "High-level 1C object attributes and tabular sections. Pass member= to read one member (or a tabular-section column) and receive member_type_evidence: only declared reference targets are marked confirmed; no business relation is inferred. resolve_generated_types=true explicitly resolves generated/defined reference types; it is off by default because it can be expensive for a large object. only must be a string: all, attributes, tabular_sections, dimensions, resources, or register_fields. Default is live verification and then cache update; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to force refresh. Use only=attributes or only=tabular_sections for a smaller public response. include_storage, use_cache, and resolve_generated_types must be JSON booleans true/false, string values are invalid. SQL/storage traces are hidden unless include_storage=true."}, {"name": "metadata.relationship.verify", "transport": "POST /rpc", "description": "Verify a proposed metadata-object relationship through the declared type of one exact attribute or tabular-section column. Requires base_id, source object selector, and member; target_ref is optional. Returns confirmed only for an explicit declared target type and never infers a business relation from similarly named fields, BSL text, or data values."}, {"name": "metadata.relationship.find", "transport": "POST /rpc", "description": "Find direct attributes, dimensions, resources, or tabular-section columns explicitly declared as references between a source object and target_ref. direction=outgoing (default), incoming, or either; use execution_mode=job for a large object. This is metadata-type evidence only; it does not infer joins from BSL or data."}, {"name": "metadata.object.full", "transport": "POST /metadata/object/full or POST /rpc", "description": "Start a long job for a full high-level 1C object profile: card, semantic sections, decoded forms, templates, commands, module handles, and optional parts evidence. evidence_mode=full/raw automatically includes parts_summary; raw offsets require include_storage=true. Poll adapter.job.get/mcp.job.get."}, {"name": "metadata.snapshot", "transport": "POST /metadata/snapshot or POST /rpc", "description": "1C-facing live metadata summary for a concrete base_id."}, {"name": "metadata.cache.status", "transport": "POST /rpc", "description": "Internal metadata identity cache status for one explicit base_id. No default database is used."}, {"name": "metadata.cache.lookup", "transport": "POST /rpc", "description": "Internal metadata identity cache lookup by explicit base_id and 1C object name/guid."}, {"name": "metadata.cache.rebuild", "transport": "POST /rpc", "description": "Start a long job that rebuilds the internal metadata cache for one explicit base_id. Returns job_id; poll adapter.job.get and cancel with adapter.job.cancel."}, {"name": "metadata.cache.invalidate", "transport": "POST /rpc", "description": "Invalidate internal metadata identity cache for one explicit base_id. Supports dry_run JSON boolean."}, {"name": "infobase.users.search", "transport": "POST /rpc", "description": "Default user lookup for 1C infobase users shown in Configurator. Reads safe fields from dbo.v8users. This is authoritative for platform identity, authentication flags, platform administrator flag, and RolesID; exact role names require the 1C runtime API and are never inferred from BSP profiles."}, {"name": "infobase.user.get", "transport": "POST /rpc", "description": "Get one 1C infobase/Configurator user by exact name or platform user id. Returns safe dbo.v8users fields and explicit role-resolution limits. BSP users, groups, and profiles are a separate layer."}, {"name": "infobase.user.password.status", "transport": "POST /rpc", "description": "Read whether one exact infobase/Configurator user has an empty or non-empty password without exposing hashes or dbo.v8users.Data. Also reports whether standard authentication is enabled."}, {"name": "infobase.user.password.capabilities", "transport": "POST /rpc", "description": "Report whether the 1C runtime bridge for Configurator-user password operations is configured, including explicit unauthenticated test-mode status."}, {"name": "infobase.user.password.set", "transport": "POST /rpc", "description": "Set a new password for one exact infobase/Configurator user through a guarded SQL update of dbo.v8users.Data. Computes the case-sensitive and uppercase SHA-1/Base64 pair in memory, verifies transactional readback, and never persists or echoes the clear-text password."}, {"name": "infobase.user.password.clear", "transport": "POST /rpc", "description": "Clear the password of one exact infobase/Configurator user through a guarded SQL update of dbo.v8users.Data. Decodes the per-row container, replaces only the current password hash pair with empty-password hashes, verifies readback in one transaction, and requires exact user_id confirmation plus allow_password_clear=true."}, {"name": "access.snapshot.extract", "transport": "POST /rpc or POST /access/snapshot/extract", "description": "Discover or extract a normalized BSP access snapshot from live SQL: BSP catalog users, access groups, profiles, technical roles, memberships, role permissions, and data restrictions. This is not the Configurator user list and is not authoritative for platform authentication or direct platform role assignments."}, {"name": "access.graph.build", "transport": "POST /rpc or POST /access/graph", "description": "Build a normalized 1C access graph from an access snapshot: users, access groups, profiles, roles, data restrictions, and effective permissions with source chains."}, {"name": "access.user.explain", "transport": "POST /rpc or POST /access/user/explain", "description": "Explain BSP access for one BSP catalog user, including profile/group/role source chains and optional object/action filtering. For an ordinary request about users, start with infobase.users.search; BSP results do not replace Configurator role assignments."}, {"name": "access.users.search", "transport": "POST /rpc or POST /access/users/search", "description": "Search BSP catalog users by name, login, id/ref tail, or fuzzy fragment and return candidates for access.user.explain. Use only when the caller explicitly asks about BSP users, access groups, profiles, or RLS; ordinary 'users' means infobase/Configurator users."}, {"name": "access.keys.query", "transport": "POST /rpc or POST /access/keys/query", "description": "Page through BSP access key registers by group, user_set, object, access_set, or all. In object mode pass object_ref for a metadata object name and record_ref for a concrete application-data record; kind remains the query area. Legacy raw BSP object/object_id filters remain supported."}, {"name": "access.object_keys.resolve", "transport": "POST /rpc or POST /access/object-keys/resolve", "description": "Page through BSP object access keys. Pass object_ref for a public 1C metadata name and record_ref for a concrete application-data record; the adapter resolves internal object_sql_number/object_id values and readable presentations."}, {"name": "access.object.explain", "transport": "POST /rpc or POST /access/object/explain", "description": "Explain who can see a BSP-protected data record. Pass object_ref for its metadata object and record_ref for the concrete record; legacy raw BSP object/object_id/access_key filters remain supported."}, {"name": "access.object.roles", "transport": "POST /rpc or POST /access/object/roles", "description": "Find BSP roles that grant permissions for one metadata object and summarize read/insert/update/delete rights."}, {"name": "access.object.subjects", "transport": "POST /rpc or POST /access/object/subjects", "description": "Find roles, profiles, access groups, and users that receive permissions for one metadata object."}, {"name": "access.rls.discover", "transport": "POST /rpc or POST /access/rls/discover", "description": "Discover metadata candidates for BSP/RLS/data restriction extraction by names such as Огранич, Доступ, RLS, and Ключ."}, {"name": "access.role.profiles", "transport": "POST /rpc or POST /access/role/profiles", "description": "Find BSP access profiles that include a role, and access groups that use those profiles."}, {"name": "access.role.users", "transport": "POST /rpc or POST /access/role/users", "description": "Find users that receive a BSP role through access profiles and access groups, with fuzzy role matching."}, {"name": "access.role.audit_export", "transport": "POST /rpc or POST /access/role/audit-export", "description": "Export a flat audit report for role -> profile -> access group -> user, as JSON rows and optionally CSV text."}, {"name": "access.role.audit_analyze", "transport": "POST /rpc or POST /access/role/audit-analyze", "description": "Analyze role access audit rows and return risk findings for broad groups, external users, fuzzy matches, multiple paths, and high user counts."}, {"name": "semantic.cache.search", "transport": "POST /rpc", "description": "Search prepared semantic documents from decoded artifact cache. Results are candidate-only by default; pass validate_candidates=true to SHA-check top matches against current source bytes before using them."}, {"name": "semantic.cache.status", "transport": "POST /rpc", "description": "Report semantic document cache readiness by vector status, kind, and embedding model, including pending counts for embedding workers."}, {"name": "semantic.cache.validate", "transport": "POST /rpc", "description": "Validate one semantic cache candidate by document_id against current source bytes. Fresh matches can be used through read_selector; changed payloads are marked non-embedded and returned as stale."}, {"name": "semantic.cache.validate_batch", "transport": "POST /rpc", "description": "Validate semantic cache candidates in batches by document_ids or kind/vector_status filters. Each candidate is checked against current source bytes before use."}, {"name": "semantic.cache.refresh", "transport": "POST /rpc", "description": "Re-read and re-decode one semantic cache document from current source bytes, update semantic/artifact caches, and queue a fresh embedding when content changed."}, {"name": "semantic.cache.rebuild", "transport": "POST /rpc", "description": "Warm semantic/artifact caches from fresh extension route cache entries. Currently supports Template/MOXCEL routes and queues refreshed documents for embeddings."}, {"name": "semantic.cache.pending", "transport": "POST /rpc", "description": "List semantic cache documents that need embeddings. Returns text previews and content_sha1 preconditions for safe external embedding workers."}, {"name": "semantic.cache.embedding.upsert", "transport": "POST /rpc", "description": "Store an embedding for one semantic cache document only when document_id and content_sha1 still match the current source-derived document."}, {"name": "metadata.module_owner_cache.backfill", "transport": "POST /rpc", "description": "Incrementally build the local module-to-1C-object name map. By default enriches already indexed code modules only; deep=true also decodes objects without code-index rows. Processes one bounded page, returns a resumable next_cursor, and never writes platform SQL."}, {"name": "metadata.module_owner_cache.prune", "transport": "POST /rpc", "description": "Targeted local module-owner cache cleanup. Prefer a 1C owner ref or kind/name; owner GUID is resolved internally. Generated module_ref selectors remain accepted. Supports dry_run JSON boolean and never writes platform SQL."}, {"name": "extensions.list", "transport": "GET /extensions or POST /rpc", "description": "Публичный список расширений конфигурации: имя, порядок, дата обновления, активность и GUID. require base_id, limit >= 1 and offset >= 0 when provided. Технические поля скрыты, если явно не передан include_storage=true."}, {"name": "extension.cache.status", "transport": "POST /rpc", "description": "Report extension route cache freshness grouped by extension/kind, including stale counts and oldest validation timestamps."}, {"name": "extension.cache.rebuild", "transport": "POST /rpc", "description": "Warm the validated extension route cache from live extension manifests and descriptor payloads. Use extension/kind/max_items to scope the rebuild; no vector or semantic result is treated as authoritative."}, {"name": "extension.cache.validate", "transport": "POST /rpc", "description": "Validate cached extension routes against current live manifests and mark stale entries. Use before programming sessions or scheduled refreshes to keep source cache honest."}, {"name": "extension.objects.find", "transport": "POST /rpc", "description": "Fast search for extension metadata objects by extension name/GUID, object kind, GUID, or name fragment. Defaults to state=working: saved ConfigCASSave forms are returned over active/cache/manifest objects and marked saved_only or saved_override. Use state=active for activated metadata only, state=save for saved-state only, state=both to compare. Cached route candidates are live-validated before use; pass refresh_cache=true to skip cached candidates and rebuild from live sources. Pass full_scan=true only when a slower ConfigCAS payload scan is required. Returns routes and safe read selectors."}, {"name": "metadata.code_index.build", "transport": "POST /rpc", "description": "Build or warm a SQL-derived BSL module cache from live storage. SQL remains authoritative; cached rows store payload/text hashes and optional local vector chunks."}, {"name": "metadata.code_index.status", "transport": "POST /rpc", "description": "Report BSL code index and vector chunk cache counts. Status is informational; individual answers still require SQL verification."}, {"name": "metadata.code_index.search", "transport": "POST /rpc", "description": "Hybrid-safe BSL lexical search over the local code index. strict=true is the default: cached candidates are verified against live SQL, stale rows are refreshed and discarded, saved-state overlays shadow active rows, and a dirty index falls back to live SQL."}, {"name": "metadata.code_index.verify", "transport": "POST /rpc", "description": "Verify one cached module_ref against current live SQL payload/text hashes and report cache_hit_verified or cache_hit_stale."}, {"name": "metadata.code_index.refresh_changed", "transport": "POST /rpc", "description": "Verify cached search candidates and rebuild changed modules from live SQL. Intended for small operational refreshes, not full rebuilds."}, {"name": "metadata.code_index.sync_pending", "transport": "POST /rpc", "description": "Process the local code-index dirty/outbox queue from live SQL. Exact saved-state files are rebuilt incrementally; missing saved overlays are removed so active code becomes visible again."}, {"name": "metadata.code_index.poll_changes", "transport": "POST /rpc", "description": "Poll live 1C storage for code-index changes made outside the adapter. Saved-state tables are checked by default; include_active=true also scans active tables. New/deleted files and changed hashes are synchronized incrementally."}, {"name": "metadata.code_vector.pending", "transport": "POST /rpc", "description": "List current BSL chunks that do not yet have an embedding for the requested model. Returns chunk_id/text_sha1 preconditions for an external local or OpenAI-compatible embedding worker."}, {"name": "metadata.code_vector.embedding.upsert", "transport": "POST /rpc", "description": "Store one externally computed BSL chunk embedding only when chunk_id and text_sha1 still match the current local code index. Credentials and model endpoints are never stored."}, {"name": "metadata.code_vector.search", "transport": "POST /rpc", "description": "Hybrid lexical/vector search over cached BSL chunks using local hashing embeddings or supplied query_embedding. strict=true is the default: overfetched candidates are live-verified, stale rows are refreshed and discarded, saved-state overlays shadow active rows, and dirty coverage includes a live fallback. Vector cache is never authoritative."}, {"name": "schema.tables.list", "transport": "POST /rpc", "description": "Low-level diagnostic table list for developers. Requires diagnostic=true. Parameters: limit JSON integer, timeout_seconds JSON integer, like JSON string, include_columns JSON boolean."}, {"name": "storage.files.list", "transport": "POST /rpc", "description": "Low-level diagnostic list of storage payload records. Requires diagnostic=true."}, {"name": "storage.file.get", "transport": "POST /rpc", "description": "Low-level diagnostic read of one storage payload record. Requires diagnostic=true. Parameters: table, file_name, include_payload JSON boolean, timeout_seconds JSON integer."}, {"name": "storage.saved_state.apply_proposal", "transport": "POST /rpc", "description": "Apply a reviewed encoded proposal to ConfigSave/ConfigCASSave with backup, sha1 precondition, transaction, and readback verification. Requires allow_sql_saved_state_apply=true."}, {"name": "storage.saved_state.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state apply by backup_id or backup_path. Requires allow_sql_saved_state_rollback=true."}, {"name": "storage.saved_state.backups.list", "transport": "POST /rpc", "description": "List local saved-state apply backups with optional base_id/table/file_name filters."}, {"name": "storage.saved_state.backups.prune", "transport": "POST /rpc", "description": "Prune old local saved-state backup artifacts by base/table/file filters. Defaults to dry_run=true, preserves keep_latest backups, and requires confirm_delete=true for deletion. Never writes the 1C database."}, {"name": "metadata.dbnames.summary", "transport": "POST /rpc", "description": "Low-level diagnostic DBNames summary for developers. Requires diagnostic=true."}, {"name": "code.search", "transport": "POST /rpc", "description": "Search decoded BSL in configuration_view=effective_working by default: the logical Designer snapshot, with saved development changes and extension layers preferred. Use runtime_applied only for code executable now, or compare for both. Object scope accepts ref, kind/name/guid, or object_type/object_name/object_guid. Every item has read_selector.method is code.read; reuse that selector directly. Results expose logical owners and read selectors, not Config/ConfigSave internals."}, {"name": "code.read", "transport": "POST /rpc", "description": "Read module or routine text in configuration_view=effective_working by default, by logical owner selector or a prior code.search read_selector. Pass module_ref from code.search read_selector unchanged when it is present. runtime_applied reads code executable now; compare returns both. The response identifies the view so saved Designer changes are never mistaken for already activated runtime code."}, {"name": "code.symbol.resolve", "transport": "POST /rpc", "description": "Conservatively resolve a BSL expression inside a concrete module/routine context. Full 1C paths and context-proven members are metadata; routine parameters, local variables, and short object names remain code symbols."}, {"name": "templates.bindings", "transport": "POST /rpc", "description": "Extract template dependencies, parameters/fields bindings and owner chain for a report/processing/form object."}, {"name": "template.field.lineage", "transport": "POST /rpc", "description": "Trace one static print-template parameter through a BSL area assignment to its local formula or source row fields. SQL-decoded evidence only; helper calls and dynamic area names remain partial."}, {"name": "diagnostics.call_chain", "transport": "POST /rpc", "description": "Build diagnostic call chain for a code entrypoint: entry method owner/module, possible overrides, and detected static usage links in the same object scope."}, {"name": "payload.diff", "transport": "POST /rpc", "description": "Low-level diagnostic comparison of two 1C payloads from live storage or inline bytes/text. Returns byte sha1/size changes, text unified diff, tree scalar changes, string changes, and compact undecoded evidence. Requires diagnostic=true."}, {"name": "codec.decode", "transport": "POST /rpc", "description": "Low-level diagnostic decode of a 1C payload record into text/tree. Requires diagnostic=true."}, {"name": "codec.encode", "transport": "POST /rpc", "description": "Low-level diagnostic encode of text/tree into a 1C payload envelope. Requires diagnostic=true."}, {"name": "modules.search", "transport": "POST /modules/search or POST /rpc", "description": "Live BSL text search. Defaults to state=working: saved tables ConfigCASSave/ConfigSave are preferred over active ConfigCAS/Config for programming-time code analysis; use state=active/save/both when comparing. Pass ref, kind/name/guid, object_type/object_name/object_guid, or module_ordinal to search inside one object's modules quickly. Supports `extension` for module owner scoping by extension, `routine_name` for narrowing to one procedure/function before text match, and owner_scan_limit for resolve_owners scans. Every public match includes read_selector.method=modules.read and either an object selector or opaque module_ref. Global search is partial by scan_limit unless increased (scope='all' checks ConfigCAS/ConfigCASSave + Config/ConfigSave). Counts and diagnostics.owner_resolution explain incomplete owner recovery. Physical module ids are hidden unless include_storage=true."}, {"name": "modules.read", "transport": "GET /modules/read or POST /rpc", "description": "Read a BSL module by object selector (ref, kind/name/guid, object_type/object_name/object_guid) and module_ordinal, or by module_ref from modules.search read_selector. Supports mode=summary, preview=true, max_chars, offset, routine_name, include_text=false. Set table=ConfigSave/ConfigCASSave to read saved data directly. Selected BSL fragment is returned in text; preview=true also returns preview as a compatibility alias. Internal module_id is accepted only for tooling; source/payload are hidden unless include_storage=true."}, {"name": "query.validate", "transport": "POST /query/validate or POST /rpc", "description": "Проверка read-only SQL-запроса."}, {"name": "query.run", "transport": "POST /query/run or POST /rpc", "description": "Low-level diagnostic execution of a validated read-only SQL query. Requires diagnostic=true."}, {"name": "data.schema", "transport": "POST /rpc", "description": "Resolve a 1C object by public name and return its logical application-data schema and type metadata."}, {"name": "data.list", "transport": "POST /rpc", "description": "Read application data by public 1C object selector with logical fields, exact filters, ordering, and pagination."}, {"name": "data.get", "transport": "POST /rpc", "description": "Read one application data record by public 1C object selector and 32-character reference id."}, {"name": "data.count", "transport": "POST /rpc", "description": "Count application data records by public 1C object selector and exact logical filters."}, {"name": "data.query", "transport": "POST /rpc", "description": "Universal logical application-data query facade over data.list/data.count."}, {"name": "additional_attributes.find", "transport": "POST /rpc", "description": "Read-only discovery of additional requisites/properties. Searches a ChartOfCharacteristicTypes through public selectors and returns an explicit not_found/empty_source diagnostic instead of a silent empty list."}, {"name": "additional_attributes.storage.resolve", "transport": "POST /rpc", "description": "Read-only resolution of the confirmed storage route for an additional requisite. It returns a join only when the property and Object/Property/Value fields are evidenced by live metadata; it never guesses _Fld columns."}, {"name": "data.present", "transport": "POST /rpc", "description": "Resolve a 1C application-data reference to a public presentation without exposing SQL identifiers."}, {"name": "data.movements", "transport": "POST /rpc", "description": "Read register movements for a recorder reference using a public register selector."}, {"name": "data.virtual", "transport": "POST /rpc", "description": "Read 1C-style register virtual views: slices for information registers and balances/turnovers for accumulation registers."}, {"name": "changes.propose", "transport": "POST /changes/propose or POST /rpc", "description": "Builds a reviewable encoded payload proposal from live source + path edits. Does not write to SQL."}, ] DATA_OBJECT_SELECTOR_METHODS = frozenset( { "data.schema", "data.list", "data.get", "data.count", "data.query", "data.present", "data.movements", "data.virtual", } ) METHOD_INPUT_SCHEMAS = { "repository.lock.confirm": { "type": "object", "required": ["base_id", "request_id", "user_confirmed_locked"], "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "request_id": {"type": "string", "description": "Exact id returned by repository.lock.request."}, "user_confirmed_locked": {"type": "boolean", "const": True, "description": "Explicit statement that the user has captured the exact request scope in Configurator."}, "confirmed_repository_user": {"type": "string", "description": "Optional only when repository_user is not saved in the resolved layer setting; otherwise, if supplied, it must match."}, }, "example": {"base_id": "neft", "request_id": "rreq-…", "user_confirmed_locked": True}, "notes": [ "Do not repeat objects, operation, or layer_id: they are taken from the persisted request.", "Use the next_call object returned by repository.lock.request whenever available.", "The successful response returns write_context with lock_session_id; copy it into write preflight and write operations.", ], }, } METHOD_INPUT_SCHEMAS.update( { method: { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "object_ref": { "type": "string", "description": "Preferred public 1C object ref, for example Справочники.Номенклатура or РегистрСведений.КурсыВалют.", }, "ref": { "type": "string", "description": "Public object ref; data.get/data.present also accept a legacy 32-character record ref when object_ref identifies the object.", }, "kind": {"type": "string", "description": "1C metadata kind in English or Russian."}, "name": {"type": "string", "description": "Exact 1C metadata object name."}, "guid": {"type": "string", "description": "Metadata object GUID; callers normally should prefer a public name."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "object_guid": {"type": "string", "description": "MCP alias of guid."}, "record_ref": {"type": "string", "description": "32-character 1C application-data reference id."}, "recorder_ref": {"type": "string", "description": "32-character recorder reference for data.movements."}, }, } for method in DATA_OBJECT_SELECTOR_METHODS } ) # Application-data writes deliberately use a separate schema from the read # facade. A caller never supplies a physical table or SQL expression: the # public object name is resolved through DBNames and the field names are # checked against the live logical schema. METHOD_INPUT_SCHEMAS["metadata.relationship.verify"] = { "type": "object", "required": ["base_id", "member"], "additionalProperties": True, "properties": { "base_id": {"type": "string"}, "ref": {"type": "string", "description": "Source public 1C object ref, for example Document.СписаниеЗапасов."}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "member": {"type": "string", "description": "Exact source attribute or tabular-section column name."}, "target_ref": {"type": "string", "description": "Optional proposed target ref. A mismatch returns not_confirmed."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, }, } METHOD_INPUT_SCHEMAS["metadata.relationship.find"] = { "type": "object", "required": ["base_id", "target_ref"], "additionalProperties": True, "properties": { "base_id": {"type": "string"}, "ref": {"type": "string", "description": "Source public 1C object ref."}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "target_ref": {"type": "string", "description": "Public 1C object ref to look for in declared reference types."}, "direction": {"type": "string", "enum": ["outgoing", "incoming", "either"]}, "execution_mode": {"type": "string", "enum": ["job"]}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, }, } METHOD_INPUT_SCHEMAS["metadata.support.decode"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": { "type": "string", "description": "Optional public 1C object ref whose supplier support rule should be returned.", }, "kind": {"type": "string", "description": "Optional metadata kind in English or Russian."}, "name": {"type": "string", "description": "Optional exact 1C object name."}, "guid": {"type": "string", "description": "Optional metadata object GUID."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "object_guid": {"type": "string", "description": "MCP alias of guid."}, "member_ref": { "type": "string", "description": "Optional full public child ref, for example Catalog.Номенклатура.Attribute.Артикул.", }, "child_ref": {"type": "string", "description": "Alias of member_ref."}, "member_kind": { "type": "string", "description": "Optional child category: Attribute, TabularSection, Dimension, or Resource; Russian aliases are accepted.", }, "member_name": {"type": "string", "description": "Optional exact child metadata name."}, "canonical_path": {"type": "string", "description": "Alias of a full public object/member path."}, "layer_id": { "type": "string", "description": "base or extension:; defaults to base.", }, }, } METHOD_INPUT_SCHEMAS["metadata.object.property.write"] = { "type": "object", "required": ["base_id", "property", "value"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Preferred public 1C object ref."}, "kind": {"type": "string", "description": "Optional metadata kind."}, "name": {"type": "string", "description": "Optional exact 1C object name."}, "guid": {"type": "string", "description": "Optional metadata GUID; names are preferred."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "object_guid": {"type": "string", "description": "MCP alias of guid."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "layer": { "type": "string", "enum": ["base_saved_state", "extension_saved_state"], "description": "Semantic saved-state layer; inferred from extension when omitted.", }, "property": { "type": "string", "enum": ["synonym", "comment"], "description": "Safe scalar identity property. Object name/rename is intentionally unsupported.", }, "value": {"type": "string", "description": "Requested scalar value; an empty string is allowed."}, "language": {"type": "string", "description": "Existing synonym locale to edit; defaults to ru."}, "expected_old": {"type": "string", "description": "Optional semantic compare-and-set guard."}, "expected_sha1": {"type": "string", "description": "Optional saved-state payload compare-and-set guard."}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "allow_saved_state_write": {"type": "boolean"}, "auto_prepare_saved_state": {"type": "boolean"}, "allow_sql_saved_state_prepare": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, "include_storage": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = { "type": "object", "required": ["base_id", "template_member_ref", "new_member_name"], "additionalProperties": True, "properties": { "base_id": {"type": "string"}, "ref": {"type": "string", "description": "Optional public parent object ref; inferred from template_member_ref."}, "kind": {"type": "string"}, "name": {"type": "string"}, "object_type": {"type": "string"}, "object_name": {"type": "string"}, "template_member_ref": { "type": "string", "description": "Full public ref of an existing Attribute to clone.", }, "new_member_name": {"type": "string", "description": "New 1C Attribute name."}, "new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."}, "new_member_comment": {"type": "string", "description": "Optional comment; defaults to an empty string and is never inherited from the template."}, "extension": {"type": "string"}, "layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]}, "expected_sha1": {"type": "string"}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "allow_saved_state_write": {"type": "boolean"}, "auto_prepare_saved_state": {"type": "boolean"}, "allow_sql_saved_state_prepare": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, "include_storage": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.write"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "target": { "type": "object", "additionalProperties": True, "description": "Name-first target. Use kind/area=object|form|module|schedule plus a public ref, kind/name, or canonical_path.", "properties": { "kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]}, "area": {"type": "string", "enum": ["object", "form", "module", "schedule"]}, "ref": {"type": "string", "description": "Public 1C object/member ref."}, "canonical_path": {"type": "string", "description": "Public 1C code or metadata path."}, "extension": {"type": "string", "description": "Public extension name for an extension-owned form or module."}, "form": {"type": "string", "description": "Exact 1C form name."}, "element": {"type": "string", "description": "Exact 1C form element name."}, "command": {"type": "string", "description": "Exact 1C form command name."}, "attribute": {"type": "string", "description": "Exact 1C form attribute name."}, "module": {"type": "string", "description": "Public module role/name returned by saved-state module search."}, "qualified_name": {"type": "string", "description": "Public qualified form or module name returned by search."}, "stream_ordinal": {"type": "integer", "minimum": 1, "description": "Public 1-based stream ordinal returned by saved-state module search."}, "property": {"type": "string"}, "operation": { "type": "string", "description": "Operation such as add_attribute, property_change, replace, or upsert_routine.", }, "template_member_ref": {"type": "string"}, "new_member_name": {"type": "string"}, }, }, "target_kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]}, "ref": {"type": "string", "description": "Optional public 1C object ref."}, "kind": {"type": "string", "description": "Optional metadata kind in English or Russian."}, "name": {"type": "string", "description": "Optional exact 1C object name."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "extension": {"type": "string"}, "property": {"type": "string"}, "value": {}, "edits": { "type": "array", "items": { "type": "object", "required": ["property", "value"], "properties": { "property": {"type": "string"}, "value": {}, }, }, }, "schedule": {"type": "object", "additionalProperties": True}, "template_member_ref": {"type": "string"}, "new_member_name": {"type": "string"}, "new_member_synonym": {"type": "string"}, "new_member_comment": {"type": "string"}, "execution_mode": { "type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"], }, "mode": { "type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"], }, "allow_saved_state_write": {"type": "boolean"}, "allow_sql_saved_state_prepare": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, }, "examples": [ { "base_id": "upo_test", "target": { "area": "object", "ref": "Catalog.Номенклатура", "property": "synonym", }, "value": "Номенклатура", "mode": "plan", }, { "base_id": "upo_test", "target": { "area": "object", "operation": "add_attribute", "template_member_ref": "Catalog.Номенклатура.Attribute.Артикул", "new_member_name": "КодПоставщика", }, "mode": "plan", }, { "base_id": "upo_test", "target": { "kind": "form", "extension": "test2", "ref": "Catalog.test2", "form": "t_Форма", "command": "ЗаменаДомена", }, "edits": [{"property": "Заголовок", "value": "Замена домена"}], "mode": "plan", }, { "base_id": "upo_test", "target": { "kind": "schedule", "ref": "ScheduledJob.ОбменДанными", }, "schedule": {"begin_time": "09:00:00"}, "mode": "plan", }, ], } METHOD_INPUT_SCHEMAS["metadata.write.plan"] = { "type": "object", "required": ["base_id", "target"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "target": { "type": "object", "required": ["kind"], "additionalProperties": True, "description": "Prefer a public name-first selector; storage handles are optional follow-up values resolved internally from names.", "properties": { "kind": {"type": "string", "enum": ["form", "module", "metadata"]}, "ref": {"type": "string", "description": "Preferred public owner ref, for example Catalog.Номенклатура."}, "extension": {"type": "string", "description": "Public extension name."}, "form": {"type": "string", "description": "Exact 1C form name."}, "element": {"type": "string", "description": "Exact form element name."}, "command": {"type": "string", "description": "Exact form command name."}, "attribute": {"type": "string", "description": "Exact form attribute name."}, "module": {"type": "string", "description": "Public module role/name returned by metadata.saved_state.modules.search."}, "qualified_name": {"type": "string", "description": "Public qualified name returned by saved-state search."}, "stream_ordinal": {"type": "integer", "minimum": 1}, "canonical_path": {"type": "string", "description": "Full public 1C metadata/code path when one is already known."}, }, }, "edits": { "type": "array", "description": "Form property edits. Planning resolves the target but does not apply them.", "items": { "type": "object", "required": ["property", "value"], "properties": { "property": {"type": "string", "description": "Public property presentation or canonical property name."}, "value": {}, }, }, }, "intent": {"type": "object", "additionalProperties": True, "description": "Module or metadata operation intent."}, "resolve_origin": {"type": "boolean", "description": "Resolve a canonical path origin when no saved-state selector is available."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, }, "examples": [ { "base_id": "upo_test", "target": { "kind": "form", "extension": "test2", "ref": "Catalog.test2", "form": "t_Форма", "command": "ЗаменаДомена", }, "edits": [{"property": "Заголовок", "value": "Замена домена"}], "resolve_origin": False, }, { "base_id": "upo_test", "target": { "kind": "module", "ref": "Catalog.Номенклатура", "module": "Модуль менеджера", "stream_ordinal": 1, }, "intent": { "operation": "replace_with_control", "control_fragment": "Процедура Обновить()", "new": "Процедура Обновить()\nКонецПроцедуры", }, "resolve_origin": False, }, ], } METHOD_INPUT_SCHEMAS["metadata.write.preflight"] = copy.deepcopy(METHOD_INPUT_SCHEMAS["metadata.write.plan"]) METHOD_INPUT_SCHEMAS["metadata.write_target.resolve"] = { "type": "object", "required": ["base_id", "path"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "path": {"type": "string", "description": "Full public path, for example Расширение..Отчеты..Формы.
.МодульФормы."}, "write_target": {"type": "object", "additionalProperties": True, "description": "Optional existing explicit target; its path and expected extension must agree with path."}, "old": {"type": "string", "description": "Optional exact old code fragment. When supplied, resolve verifies it in the confirmed active form module before any prepare/write step."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, }, } METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": { "type": "string", "description": "Optional public 1C object ref. Omit the object selector only for an intentional bounded global scan.", }, "kind": {"type": "string", "description": "Optional metadata kind in English or Russian."}, "name": {"type": "string", "description": "Optional exact 1C object name."}, "guid": {"type": "string", "description": "Optional metadata object GUID."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "object_guid": {"type": "string", "description": "MCP alias of guid."}, "extension_guid": {"type": "string", "description": "Optional extension layer GUID."}, "table": {"type": "string", "description": "Optional explicit storage state; normally resolved automatically."}, "max_items": {"type": "integer", "minimum": 1, "maximum": 20000}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000}, "include_vectors": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.form.owner_index.build"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": { "type": "string", "description": "Optional public 1C owner ref, for example Обработки.ОбменДанными or ОбщиеФормы.Параметры.", }, "kind": {"type": "string", "description": "Optional owner metadata kind in English or Russian."}, "name": {"type": "string", "description": "Optional exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "form": {"type": "string", "description": "Optional exact form name inside the selected owner."}, "form_name": {"type": "string", "description": "Alias of form."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "table": {"type": "string", "description": "Optional direct diagnostic storage route table."}, "file_name": {"type": "string", "description": "Optional direct diagnostic storage file."}, }, } METHOD_INPUT_SCHEMAS["metadata.form.command_button.verify"] = { "type": "object", "required": ["base_id", "command_name"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, "kind": {"type": "string", "description": "Optional owner metadata kind."}, "name": {"type": "string", "description": "Optional exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "form": {"type": "string", "description": "Exact nested form name."}, "form_name": {"type": "string", "description": "Alias of form."}, "command_name": {"type": "string", "description": "Exact form command name."}, "button_name": {"type": "string", "description": "Expected button name; defaults to command_name."}, "handler_name": {"type": "string", "description": "Expected handler routine; defaults to command_name."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "include_storage": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.form.command_button.write"] = { "type": "object", "required": ["base_id", "command_name"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, "kind": {"type": "string", "description": "Optional owner metadata kind."}, "name": {"type": "string", "description": "Optional exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "form": {"type": "string", "description": "Exact nested form name."}, "form_name": {"type": "string", "description": "Alias of form."}, "form_guid": {"type": "string", "description": "Optional form GUID, distinct from the owner GUID."}, "command_name": {"type": "string", "description": "Exact command name to upsert."}, "button_name": {"type": "string", "description": "Button name; defaults to command_name."}, "handler_name": {"type": "string", "description": "Handler routine name; defaults to command_name."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "allow_saved_state_write": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, }, } _FORM_WRITE_TARGET_SELECTOR_PROPERTIES = { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, "kind": {"type": "string", "description": "Optional owner metadata kind."}, "name": {"type": "string", "description": "Optional exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "form": {"type": "string", "description": "Exact nested form name."}, "form_name": {"type": "string", "description": "Alias of form."}, "form_guid": {"type": "string", "description": "Optional form GUID, distinct from the owner GUID."}, "element": {"type": "string", "description": "Exact form element name."}, "element_name": {"type": "string", "description": "Alias of element."}, "command": {"type": "string", "description": "Exact form command name."}, "attribute": {"type": "string", "description": "Exact form attribute name."}, "property": {"type": "string", "description": "Scalar property to resolve."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "table": {"type": "string", "description": "Saved-state table."}, "file_name": {"type": "string", "description": "Optional concrete saved-state file."}, "include_storage": {"type": "boolean"}, } for _form_write_target_method in ("metadata.form.write_target.resolve", "metadata.form.write_target.verify"): METHOD_INPUT_SCHEMAS[_form_write_target_method] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": dict(_FORM_WRITE_TARGET_SELECTOR_PROPERTIES), } for _form_element_write_method in ("metadata.form.element.write", "metadata.form.element.write_apply"): METHOD_INPUT_SCHEMAS[_form_element_write_method] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, "edits": {"type": "array", "description": "Non-empty scalar property edit list."}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "allow_saved_state_write": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.form.target.move"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, "from_element": {"type": "string", "description": "Exact source sibling element name."}, "to_element": {"type": "string", "description": "Exact destination sibling element name."}, "with_element": {"type": "string", "description": "Alias of to_element for a sibling swap."}, "after_element": {"type": "string", "description": "Existing sibling used as the destination slot."}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "allow_saved_state_write": {"type": "boolean"}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, }, } for _form_write_matrix_method in ("metadata.form.write_matrix.build", "metadata.form.write_matrix.smoke"): METHOD_INPUT_SCHEMAS[_form_write_matrix_method] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, "max_candidates": {"type": "integer", "minimum": 1, "maximum": 5000}, "allow_sql_saved_state_apply": {"type": "boolean"}, "allow_sql_saved_state_rollback": {"type": "boolean"}, }, } for _write_learning_capture_method in ("metadata.write_learning.capture_before", "metadata.write_learning.capture_after"): METHOD_INPUT_SCHEMAS[_write_learning_capture_method] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, "learning_id": {"type": "string", "description": "Local write-learning case identifier."}, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.forms.search"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, "query": {"type": "string", "description": "Optional text search inside the decoded form."}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "tables": {"type": "array", "items": {"type": "string"}}, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.status"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["base_saved_state", "extension_saved_state"], "description": "Preferred semantic saved-state layer selector.", }, "table": { "type": "string", "enum": ["ConfigSave", "ConfigCASSave"], "description": "Optional low-level storage selector; prefer layer.", }, "prefix": {"type": "string", "description": "Optional diagnostic storage file prefix."}, "limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "include_files": {"type": "boolean"}, "include_unchanged": {"type": "boolean"}, "include_storage": { "type": "boolean", "description": "Expose SQL database/table/file coordinates, hashes, and concrete diff selectors.", }, }, } METHOD_INPUT_SCHEMAS["configuration.activation.status"] = { "type": "object", "required": ["base_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["all", "base_saved_state", "extension_saved_state"], "description": "Saved-state layer scope. Defaults to all.", }, "limit": { "type": "integer", "minimum": 1, "maximum": 5000, "description": "Maximum saved-state rows checked per layer. Defaults to 5000.", }, "timeout_seconds": {"type": "integer", "minimum": 1}, "include_files": { "type": "boolean", "description": "Include changed file diagnostics only when include_storage=true.", }, "include_storage": { "type": "boolean", "description": "Expose storage tables, file names, hashes, and diff selectors.", }, }, } METHOD_INPUT_SCHEMAS["configuration.activation.plan"] = copy.deepcopy( METHOD_INPUT_SCHEMAS["configuration.activation.status"] ) METHOD_INPUT_SCHEMAS["configuration.activation.request"] = copy.deepcopy( METHOD_INPUT_SCHEMAS["configuration.activation.status"] ) METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"].pop("include_files", None) METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"].pop("include_storage", None) METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"]["ttl_seconds"] = { "type": "integer", "minimum": 60, "maximum": 86400, "description": "Adapter-local request lifetime. Defaults to 1800 seconds.", } METHOD_INPUT_SCHEMAS["configuration.activation.request.status"] = { "type": "object", "required": ["request_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Optional expected base identifier."}, "request_id": {"type": "string", "description": "Activation request id."}, }, } METHOD_INPUT_SCHEMAS["configuration.activation.request.cancel"] = { "type": "object", "required": ["request_id", "confirm_cancel"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Optional expected base identifier."}, "request_id": {"type": "string", "description": "Activation request id."}, "confirm_cancel": { "type": "boolean", "const": True, "description": "Explicit confirmation that this exact request should be cancelled.", }, }, } METHOD_INPUT_SCHEMAS["configuration.activation.audit"] = { "type": "object", "required": ["base_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, "status": {"type": "string", "description": "Optional exact request status filter."}, }, } METHOD_INPUT_SCHEMAS["configuration.activation.capabilities"] = { "type": "object", "required": ["base_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["all", "base_saved_state", "extension_saved_state"], }, }, } METHOD_INPUT_SCHEMAS["configuration.activation.bridge.probe"] = { "type": "object", "required": ["base_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["all", "base_saved_state", "extension_saved_state"], }, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60}, }, } METHOD_INPUT_SCHEMAS["configuration.activation.execute"] = { "type": "object", "required": ["base_id", "request_id", "confirm_activation"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "request_id": {"type": "string", "description": "Activation request id."}, "mode": { "type": "string", "enum": ["debug"], "description": "Only debug is currently supported.", }, "confirm_activation": { "type": "boolean", "const": True, "description": "Explicit confirmation of the exact fingerprinted request.", }, "bridge_debug": { "type": "boolean", "description": "Also require an end-to-end local/HTTP runner debug receipt. Designer is not started.", }, "timeout_seconds": {"type": "integer", "minimum": 1}, }, } METHOD_INPUT_SCHEMAS["configuration.activation.verify"] = { "type": "object", "required": ["base_id", "request_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "request_id": {"type": "string", "description": "Activation request id."}, "timeout_seconds": {"type": "integer", "minimum": 1}, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["base_saved_state", "extension_saved_state"], "description": "Preferred semantic target saved-state layer.", }, "ref": {"type": "string", "description": "Preferred public 1C object reference."}, "kind": {"type": "string", "description": "Optional metadata kind."}, "name": {"type": "string", "description": "Optional exact 1C object name."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "form": {"type": "string", "description": "Optional extension form name. Requires public owner ref or kind+name; the adapter resolves its payload without a global scan."}, "module_ref": {"type": "string", "description": "Opaque module reference returned by the adapter; do not substitute a public module name."}, "file_name": {"type": "string", "description": "Optional low-level follow-up selector returned by the adapter."}, "file_names": {"type": "array", "items": {"type": "string"}, "description": "Optional explicit atomic storage group returned by the adapter."}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 240, "description": "Per-call SQL timeout; the response reports completed stages."}, "target_table": { "type": "string", "enum": ["ConfigSave", "ConfigCASSave"], "description": "Optional low-level storage selector; prefer layer.", }, "mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify"]}, "allow_sql_saved_state_prepare": {"type": "boolean"}, "merge_missing": {"type": "boolean", "description": "Copy only active files absent from the saved-state target; never overwrite existing saved files."}, "include_storage": { "type": "boolean", "description": "Expose SQL tables, database, files, hashes, row details, and low-level apply diagnostics.", }, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.ensure"] = { **METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"], "properties": { **METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"]["properties"], "mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify"]}, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.ensure.rollback"] = { "type": "object", "required": ["base_id", "prepare_receipt_id", "allow_sql_saved_state_rollback"], "additionalProperties": False, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "prepare_receipt_id": {"type": "string", "description": "Opaque receipt id returned by metadata.saved_state.ensure apply."}, "allow_sql_saved_state_rollback": {"type": "boolean", "const": True}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 240}, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.changes.list"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "layer": { "type": "string", "enum": ["base_saved_state", "extension_saved_state"], "description": "Preferred semantic saved-state layer filter.", }, "table": { "type": "string", "enum": ["ConfigSave", "ConfigCASSave"], "description": "Optional low-level saved-state layer filter.", }, "include_unchanged": {"type": "boolean"}, "include_context": { "type": "boolean", "description": "Request resolved object/form/module context explicitly; public mode enables it automatically.", }, "group_by_context": {"type": "boolean"}, "context_limit": {"type": "integer", "minimum": 0, "maximum": 500}, "limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "include_storage": { "type": "boolean", "description": "Expose SQL tables/files, hashes, concrete diff selectors, module refs, and low-level actions.", }, }, } METHOD_INPUT_SCHEMAS["metadata.saved_state.modules.search"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Optional public 1C module-owner ref."}, "kind": {"type": "string", "description": "Optional owner metadata kind."}, "name": {"type": "string", "description": "Optional exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "owner_guid": {"type": "string", "description": "Low-level exact owner GUID alias."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "layer": { "type": "string", "enum": ["base_saved_state", "extension_saved_state"], "description": "Optional semantic saved-state layer; prefer this to SQL table names.", }, "query": {"type": "string", "description": "Optional BSL text query."}, "file_name": {"type": "string", "description": "Optional concrete saved-state file."}, "stream_index": {"type": "integer", "minimum": 0}, "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, "tables": {"type": "array", "items": {"type": "string"}}, "include_storage": { "type": "boolean", "description": "Expose SQL tables, file names, GUID identities, module_ref handles, hashes, and low-level write targets.", }, }, } METHOD_INPUT_SCHEMAS["code.write"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Preferred public 1C module-owner ref."}, "kind": {"type": "string", "description": "Owner metadata kind."}, "name": {"type": "string", "description": "Exact owner object name."}, "guid": {"type": "string", "description": "Optional owner metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of owner kind."}, "object_name": {"type": "string", "description": "MCP alias of owner name."}, "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, "form": {"type": "string", "description": "Optional nested form name."}, "form_name": {"type": "string", "description": "Alias of form."}, "routine_name": {"type": "string", "description": "Exact BSL routine name."}, "routine_text": {"type": "string", "description": "Replacement BSL routine text."}, "module_text": {"type": "string", "description": "Replacement full module text."}, "old": {"type": "string", "description": "Exact old fragment."}, "new": {"type": "string", "description": "Replacement fragment."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, "include_storage": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["templates.areas.find"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Preferred public 1C template ref, for example Макеты.ПечатнаяФорма."}, "kind": {"type": "string", "description": "Template metadata kind."}, "name": {"type": "string", "description": "Exact template object name."}, "guid": {"type": "string", "description": "Optional template GUID."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of template name."}, "object_guid": {"type": "string", "description": "MCP alias of template GUID."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, "area_name": {"type": "string", "description": "Exact named area."}, "area_query": {"type": "string", "description": "Named-area substring query."}, "area_occurrence": {"type": "integer", "minimum": 1}, "route_ref": {"type": "string", "description": "Optional low-level : route."}, "include_coverage": {"type": "boolean"}, }, } METHOD_INPUT_SCHEMAS["metadata.cache.lookup"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { "base_id": {"type": "string", "description": "Adapter base identifier."}, "ref": {"type": "string", "description": "Preferred public 1C object ref."}, "kind": {"type": "string", "description": "Metadata kind."}, "name": {"type": "string", "description": "Exact object name."}, "guid": {"type": "string", "description": "Optional metadata GUID."}, "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "object_guid": {"type": "string", "description": "MCP alias of GUID."}, }, } _ACCESS_OBJECT_FILTER_PROPERTIES = { "base_id": {"type": "string", "description": "Adapter base identifier."}, "object_ref": { "type": "string", "description": "Public 1C metadata object ref, for example Справочники.Номенклатура. Resolved internally to the BSP object_sql_number.", }, "record_ref": { "type": "string", "description": "Concrete 1C application-data record reference (32 hex characters or UUID form). Resolved internally to the BSP object_id.", }, "object": { "type": "string", "description": "Legacy low-level exact BSP object field. Prefer object_ref plus record_ref.", }, "object_id": { "type": "string", "description": "Legacy low-level exact BSP record id. Prefer record_ref.", }, "object_sql_number": { "type": "integer", "description": "Legacy physical DBNames number. Prefer object_ref.", }, "access_key": {"type": "string", "description": "Optional exact BSP access-key id."}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000}, "offset": {"type": "integer", "minimum": 0, "maximum": 10000000}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120}, "max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000}, } METHOD_INPUT_SCHEMAS["access.keys.query"] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": { **_ACCESS_OBJECT_FILTER_PROPERTIES, "kind": { "type": "string", "enum": ["group", "user_set", "object", "set", "all"], "description": "Access-key query area, not a 1C metadata kind. Use object_ref to select a metadata object.", }, "area": { "type": "string", "enum": ["group", "user_set", "object", "set", "all"], "description": "Alias of kind.", }, "resolve_records": {"type": "boolean"}, }, } for _access_object_method in ("access.object_keys.resolve", "access.object.explain"): METHOD_INPUT_SCHEMAS[_access_object_method] = { "type": "object", "required": ["base_id"], "additionalProperties": True, "properties": dict(_ACCESS_OBJECT_FILTER_PROPERTIES), } METHOD_INPUT_SCHEMAS["storage.saved_state.backups.prune"] = { "type": "object", "required": ["base_id"], "additionalProperties": False, "properties": { "base_id": {"type": "string"}, "table": {"type": "string", "enum": ["ConfigSave", "ConfigCASSave"]}, "file_name": {"type": "string"}, "older_than_days": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 30}, "keep_latest": {"type": "integer", "minimum": 0, "maximum": 10000, "default": 20}, "limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 500}, "dry_run": {"type": "boolean", "default": True}, "confirm_delete": {"type": "boolean", "default": False}, "diagnostic": { "type": "boolean", "description": "Required when the method is forwarded through the generic MCP diagnostic policy.", }, }, "notes": [ "Defaults to dry_run=true and never writes the 1C database.", "Actual local backup deletion requires dry_run=false and confirm_delete=true.", ], } RELATED_SECTION_RULES = { "Document": [ {"path": "4", "category": "Template"}, {"path": "6", "category": "Command"}, {"path": "7", "category": "Form"}, ], "Catalog": [ {"path": "3", "category": "Template"}, {"path": "4", "category": "Command"}, {"path": "7", "category": "Form"}, ], "Report": [ {"path": "3", "category": "Template"}, {"path": "5", "category": "Form"}, {"path": "7", "category": "Command"}, ], "DataProcessor": [ {"path": "4", "category": "Template"}, {"path": "5", "category": "Command"}, {"path": "6", "category": "Form"}, ], "Enum": [ {"path": "3", "category": "Form"}, {"path": "4", "category": "Template"}, ], "InformationRegister": [ {"path": "5", "category": "Form"}, {"path": "6", "category": "Template"}, {"path": "8", "category": "Command"}, ], "AccumulationRegister": [ {"path": "4", "category": "Command"}, {"path": "8", "category": "Form"}, ], "BusinessProcess": [{"path": "4", "category": "Form"}], "Task": [ {"path": "4", "category": "Form"}, {"path": "8", "category": "Command"}, ], "ChartOfCharacteristicTypes": [ {"path": "4", "category": "Template"}, {"path": "7", "category": "Form"}, ], "ChartOfAccounts": [{"path": "6", "category": "Form"}], "ChartOfCalculationTypes": [{"path": "7", "category": "Form"}], "CalculationRegister": [ {"path": "4", "category": "Recalculation"}, {"path": "5", "category": "Template"}, {"path": "7", "category": "Form"}, {"path": "8", "category": "Command"}, ], "DocumentJournal": [ {"path": "3", "category": "Template"}, {"path": "5", "category": "Command"}, {"path": "6", "category": "Form"}, ], "ExchangePlan": [ {"path": "4", "category": "Template"}, {"path": "6", "category": "Form"}, {"path": "7", "category": "Command"}, ], "SelectionCriterion": [{"path": "3", "category": "Form"}], "SettingsStorage": [{"path": "4", "category": "Form"}], } KIND_CAPABILITIES = { "Configuration": ["list", "get", "properties", "content", "modules", "special_details"], "Catalog": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "commands", "modules"], "Document": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "commands", "modules"], "Enum": ["list", "get", "enum_values", "values", "choice_settings", "forms", "templates", "properties", "special_details"], "InformationRegister": ["list", "get", "dimensions", "resources", "attributes", "forms", "templates", "commands", "modules"], "AccumulationRegister": ["list", "get", "dimensions", "resources", "attributes", "forms", "commands", "modules"], "AccountingRegister": ["list", "get", "dimensions", "resources", "attributes", "modules"], "BusinessProcess": ["list", "get", "attributes", "forms", "modules"], "Task": ["list", "get", "attributes", "addressing_attributes", "forms", "commands", "modules"], "Constant": ["list", "get", "value_type", "properties", "special_details"], "ChartOfCharacteristicTypes": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "modules", "properties", "special_details"], "ChartOfAccounts": ["list", "get", "attributes", "tabular_sections", "accounting_flags", "forms", "modules"], "ChartOfCalculationTypes": ["list", "get", "attributes", "tabular_sections", "forms", "modules", "properties", "special_details"], "ExchangePlan": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "commands", "modules"], "DocumentJournal": ["list", "get", "columns", "forms", "templates", "commands", "properties", "special_details"], "DocumentNumerator": ["list", "get", "number_type", "number_length", "periodicity", "unique_check", "properties", "special_details"], "ScheduledJob": ["list", "get", "schedule", "method", "properties", "special_details"], "Report": ["list", "get", "attributes", "forms", "templates", "commands", "modules"], "DataProcessor": ["list", "get", "attributes", "forms", "templates", "commands", "modules"], "CommonModule": ["list", "get", "modules"], "CommonForm": ["get", "form_details", "modules"], "Form": ["get", "form_details"], "Template": ["get", "template_details"], "Command": ["get"], "CalculationRegister": ["list", "get", "dimensions", "resources", "attributes", "recalculations", "forms", "templates", "commands", "modules", "properties", "special_details"], "Sequence": ["list", "get", "dimensions"], "SelectionCriterion": ["list", "get", "type", "content", "forms", "properties", "special_details"], "EventSubscription": ["list", "get", "source", "handler", "properties", "special_details"], "Role": ["list", "get", "rights", "restrictions", "properties", "special_details"], "DefinedType": ["list", "get", "type", "types", "properties", "special_details"], "SessionParameter": ["list", "get", "type", "properties", "special_details"], "FunctionalOption": ["list", "get", "type", "privileged_get", "properties", "special_details"], "FunctionalOptionsParameter": ["list", "get", "use", "properties", "special_details"], "SettingsStorage": ["list", "get", "forms", "modules", "properties", "special_details"], "CommonAttribute": ["list", "get", "type", "data_separation", "properties", "special_details"], "CommonCommand": ["list", "get", "modules", "properties", "special_details"], "Subsystem": ["list", "get", "content", "command_interface", "properties", "special_details"], "Language": ["list", "get", "properties", "special_details"], "WebService": ["list", "get", "operations", "modules", "properties", "special_details"], "HTTPService": ["list", "get", "url_templates", "methods", "modules", "properties", "special_details"], "WSReference": ["list", "get", "operations", "schemas", "properties", "special_details"], "XDTOPackage": ["list", "get", "types", "properties", "special_details"], "ExternalDataSource": ["list", "get", "tables", "fields", "key_fields", "types", "cubes", "functions", "properties", "special_details"], "IntegrationService": ["list", "get", "channels", "modules", "properties", "special_details"], "CommonTemplate": ["list", "get", "templates", "template_details", "template_read", "template_analyze", "template_map"], "CommonPicture": ["list", "get", "binary_preview", "properties", "special_details"], "CommandGroup": ["list", "get", "commands", "properties", "special_details"], "Style": ["list", "get", "style_items", "properties", "special_details"], "StyleItem": ["list", "get", "value_type", "properties", "special_details"], "Interface": ["list", "get"], } CHILD_METADATA_KINDS = {"Form", "Template", "Command"} TOP_LEVEL_METADATA_KINDS = set(KIND_CAPABILITIES) - CHILD_METADATA_KINDS def normalize(value: Any) -> str: return re.sub(r"[\s._-]+", "", str(value or "")).casefold() for _kind, _kind_ru in RU_KIND.items(): KIND_ALIASES[normalize(_kind)] = _kind KIND_ALIASES[normalize(_kind_ru)] = _kind for _code_root, _kind in ONEC_CODE_ROOT_KIND.items(): KIND_ALIASES[normalize(_code_root)] = _kind for _type_qualifier, _kind in ONEC_TYPE_QUALIFIER_KIND.items(): KIND_ALIASES[normalize(_type_qualifier)] = _kind def normalize_exact(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "").strip()).casefold() def text_quality_score(value: Any) -> int: text = str(value or "") if not text: return 0 cyrillic = sum(1 for char in text if "\u0400" <= char <= "\u04ff") latin = sum(1 for char in text if char.isascii() and char.isalpha()) digits = sum(1 for char in text if char.isdigit()) printable = sum(1 for char in text if char.isprintable() or char in "\r\n\t") cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") replacement = text.count("\ufffd") controls = sum(1 for char in text if ord(char) < 32 and char not in "\r\n\t") mojibake = sum(1 for char in text if char in "ÐÑÂÃÄÅÆÇÈÉÊËÌÍÎÏÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ") cyrillic_mojibake_pairs = len(re.findall(r"[РСГ][\u0400-\u04ff]", text)) return printable + cyrillic * 5 + latin + digits - cjk * 10 - replacement * 20 - controls * 8 - mojibake * 2 - cyrillic_mojibake_pairs * 12 def text_variants(value: Any) -> list[str]: text = str(value or "") if not text: return [] variants: list[str] = [] def add(candidate: str | None) -> None: if candidate and candidate not in variants: variants.append(candidate) add(text) transforms = ( ("latin1", "cp1251"), ("latin1", "utf-8"), ("cp1251", "utf-8"), ) for source, target in transforms: try: add(text.encode(source).decode(target)) except (UnicodeEncodeError, UnicodeDecodeError): continue return variants def best_text_variant(value: Any) -> str: variants = text_variants(value) if not variants: return str(value or "") return max(variants, key=text_quality_score) BSL_DECL_RE = re.compile(r"(?im)^\s*(?:Асинх\s+)?(?:Процедура|Функция)\s+[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*\s*\(") def cp1251_reverse_chars() -> dict[str, int]: result: dict[str, int] = {} for index in range(256): try: result[bytes([index]).decode("cp1251")] = index except UnicodeDecodeError: continue return result CP1251_REVERSE_CHARS = cp1251_reverse_chars() def bsl_text_signal_score(text: Any) -> int: value = str(text or "") if not value: return 0 score = 0 score += len(BSL_DECL_RE.findall(value)) * 20 score += len(re.findall(r"(?im)^\s*&На(?:Клиенте|Сервере|СервереБезКонтекста)\b", value)) * 8 score += len(re.findall(r"(?im)^\s*#(?:Если|Область|КонецОбласти)\b", value)) * 4 score += value.count("КонецПроцедуры") * 6 score += value.count("КонецФункции") * 6 score += sum(1 for char in value if "А" <= char <= "я" or char in "Ёё") score -= value.count("\ufffd") * 30 score -= len(re.findall(r"[РС][\u0400-\u04ff]", value)) * 3 return score def repair_bsl_mojibake_text(text: Any) -> str: source = str(text or "") if not source: return "" if is_bsl_like_text(source): return source candidates = [source, source.replace("п»ї", "\ufeff").replace("П»ї", "\ufeff")] cleaned = source.replace("п»ї", "").replace("П»ї", "").lstrip("\ufeff") try: repaired_bytes = bytes(CP1251_REVERSE_CHARS[char] for char in cleaned) repaired = repaired_bytes.decode("utf-8", errors="ignore") except Exception: repaired = "" if repaired and is_bsl_like_text(repaired): return ("\ufeff" + repaired) if source.startswith(("п»ї", "П»ї", "\ufeff")) else repaired for encoding in ("cp1251", "latin1"): try: repaired = cleaned.encode(encoding).decode("utf-8") except Exception: continue if is_bsl_like_text(repaired): return ("\ufeff" + repaired) if source.startswith(("п»ї", "П»ї", "\ufeff")) else repaired candidates.append(repaired) if source.startswith(("п»ї", "П»ї", "\ufeff")): candidates.append("\ufeff" + repaired) variants: list[str] = [] seen: set[str] = set() for candidate in candidates: if candidate in seen: continue seen.add(candidate) variants.append(candidate) return max(variants, key=lambda item: (bsl_text_signal_score(item), text_quality_score(item))) def is_bsl_like_text(text: Any) -> bool: value = str(text or "") return bool( BSL_DECL_RE.search(value) or "КонецПроцедуры" in value or "КонецФункции" in value or re.search(r"(?im)^\s*&На(?:Клиенте|Сервере|СервереБезКонтекста)\b", value) or re.search(r"(?im)^\s*#(?:Если|Область|КонецОбласти)\b", value) ) def normalized_variants(value: Any) -> set[str]: return {normalize(variant) for variant in text_variants(value) if normalize(variant)} def normalized_exact_variants(value: Any) -> set[str]: return {normalize_exact(variant) for variant in text_variants(value) if normalize_exact(variant)} def normalized_contains_any(needle: Any, haystack: Any) -> bool: needles = normalized_variants(needle) haystacks = normalized_variants(haystack) return any(needle_value in haystack_value for needle_value in needles for haystack_value in haystacks) def truthy(value: Any) -> bool: return str(value or "").strip().casefold() in {"1", "true", "yes", "on", "да"} def strict_bool_argument( payload: dict[str, Any], name: str, *, method: str, default: bool = False, ) -> tuple[bool | None, dict[str, Any] | None]: if name not in payload: return default, None value = payload.get(name) if isinstance(value, bool): return value, None return None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": name, "diagnostics": {"message": f"{name} must be a JSON boolean true/false, not a string or number."}, } ATTRIBUTE_ONLY_ALIASES = { "all": "all", "attributes": "attributes", "requisites": "attributes", "attrs": "attributes", "tabular_sections": "tabular_sections", "tabularsections": "tabular_sections", "table_parts": "tabular_sections", "tabs": "tabular_sections", "dimensions": "dimensions", "измерения": "dimensions", "resources": "resources", "ресурсы": "resources", "register_fields": "register_fields", "поля_регистра": "register_fields", } def invalid_argument(method: str, argument: str, message: str, *, allowed_values: list[str] | None = None) -> dict[str, Any]: result: dict[str, Any] = { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": argument, "diagnostics": {"message": message}, } if allowed_values is not None: result["allowed_values"] = allowed_values return result RUNTIME_XML_SOURCE_ARGUMENTS = { "xml_path", "xml_root", "meta_xml_path", "form_xml_path", "configuration_xml", "configuration_xml_path", "config_dump_info", "config_dump_info_path", } def runtime_xml_source_argument(value: Any, path: str = "payload") -> str | None: if isinstance(value, dict): for key, item in value.items(): current_path = f"{path}.{key}" if str(key).strip().casefold() in RUNTIME_XML_SOURCE_ARGUMENTS: return current_path nested = runtime_xml_source_argument(item, current_path) if nested: return nested elif isinstance(value, list): for index, item in enumerate(value): nested = runtime_xml_source_argument(item, f"{path}[{index}]") if nested: return nested return None def validate_sql_only_runtime_payload(method: str, payload: dict[str, Any]) -> dict[str, Any] | None: argument_path = runtime_xml_source_argument(payload) if not argument_path: return None return invalid_argument( method, argument_path, "The running adapter is SQL-only. XML exports and XML paths are accepted only by offline decoder-analysis scripts.", ) def child_not_found(method: str, child_kind_ru: str, child_name: Any, object_card: dict[str, Any], *, base_id: str) -> dict[str, Any]: not_found_phrase = "не найдена" if child_kind_ru.lower().endswith("а") else "не найден" result = { "schema": "onec_adapter_request_error.v1", "method": method, "status": "not_found", "error": "not_found", "base_id": base_id, "object": object_card, "diagnostics": { "message": f"{child_kind_ru} `{child_name}` {not_found_phrase} у объекта `{object_card.get('name') or object_card.get('guid')}`.", }, } return result def optional_string_filter(payload: dict[str, Any], keys: list[str], *, method: str) -> tuple[Any, dict[str, Any] | None]: for key in keys: if key not in payload or payload.get(key) is None: continue value = payload.get(key) if not isinstance(value, str): return None, invalid_argument(method, key, f"{key} must be a JSON string.") if value != "": return value, None return None, None def validate_optional_string_arguments(payload: dict[str, Any], method: str, names: list[str]) -> dict[str, Any] | None: for name in names: if name in payload and payload.get(name) is not None and not isinstance(payload.get(name), str): return invalid_argument(method, name, f"{name} must be a JSON string.") return None def validate_optional_non_empty_string_arguments(payload: dict[str, Any], method: str, names: list[str]) -> dict[str, Any] | None: for name in names: if name not in payload: continue value = payload.get(name) if value is None or value == "": return invalid_argument(method, name, f"{name} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument(method, name, f"{name} must be a JSON string.") return None def child_identity_match_by(identity: dict[str, Any], filter_value: Any) -> str | None: wanted = normalize(filter_value or "") if not wanted: return None wanted_exact_variants = normalized_exact_variants(filter_value) wanted_variants = normalized_variants(filter_value) name_variants = normalized_variants(identity.get("name") or "") name_exact_variants = normalized_exact_variants(identity.get("name") or "") synonyms = identity.get("synonyms") or {} synonym_values = list(synonyms.values()) if isinstance(synonyms, dict) else [] if wanted_exact_variants & name_exact_variants: return "name_exact" if any(wanted_exact_variants & normalized_exact_variants(value or "") for value in synonym_values): return "synonym_exact" if any(wanted_value in name_value for wanted_value in wanted_variants for name_value in name_variants): return "name_contains" if any(normalized_contains_any(filter_value, value or "") for value in synonym_values): return "synonym_contains" return None def filter_related_children_by_identity(items: list[dict[str, Any]], category: str, filter_value: Any) -> list[tuple[dict[str, Any], str | None]]: wanted = normalize(filter_value or "") wanted_guid = str(filter_value or "").strip().lower() if is_guid_text(filter_value) else "" candidates: list[tuple[dict[str, Any], str | None]] = [] for item in items: if item.get("category") != category or item.get("status") != "ok": continue if not wanted: candidates.append((item, None)) continue if wanted_guid and str(item.get("guid") or "").strip().lower() == wanted_guid: candidates.append((item, "guid_exact")) continue match_by = child_identity_match_by(item.get("identity") or {}, filter_value) if match_by: candidates.append((item, match_by)) if wanted_guid and any(match_by == "guid_exact" for _, match_by in candidates): candidates = [(item, match_by) for item, match_by in candidates if match_by == "guid_exact"] elif wanted and any(match_by in {"name_exact", "synonym_exact"} for _, match_by in candidates): candidates = [(item, match_by) for item, match_by in candidates if match_by in {"name_exact", "synonym_exact"}] return candidates def limit_object_commands_result( object_commands: list[dict[str, Any]], form_commands: list[dict[str, Any]], max_commands: int, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: max_commands = max(1, int(max_commands or 1)) command_rows = [("object", item) for item in object_commands] + [("form", item) for item in form_commands] limited_rows = command_rows[:max_commands] limited_object_commands = [item for scope, item in limited_rows if scope == "object"] limited_form_commands = [item for scope, item in limited_rows if scope == "form"] limited_commands = [item for _, item in limited_rows] total_commands = len(command_rows) return ( limited_object_commands, limited_form_commands, limited_commands, { "commands_total": total_commands, "commands_truncated": total_commands > len(limited_commands), "max_commands": max_commands, }, ) def filter_public_rows_by_name(rows: list[dict[str, Any]], filter_value: Any) -> list[tuple[dict[str, Any], str | None]]: wanted = normalize(filter_value or "") candidates: list[tuple[dict[str, Any], str | None]] = [] for row in rows: if not wanted: candidates.append((row, None)) continue identity = {"name": row.get("name"), "synonyms": {"ru": row.get("synonym")} if row.get("synonym") else {}} match_by = child_identity_match_by(identity, filter_value) if match_by: candidates.append((row, match_by)) if wanted and any(match_by in {"name_exact", "synonym_exact"} for _, match_by in candidates): candidates = [(row, match_by) for row, match_by in candidates if match_by in {"name_exact", "synonym_exact"}] return candidates def command_match_by(item: dict[str, Any], filter_value: Any) -> str | None: if not normalize(filter_value or ""): return None identity = { "name": item.get("name"), "synonyms": {"ru": item.get("synonym") or item.get("title")} if (item.get("synonym") or item.get("title")) else {}, } return child_identity_match_by(identity, filter_value) def strict_include_storage(payload: dict[str, Any], method: str) -> tuple[bool | None, dict[str, Any] | None]: return strict_bool_argument(payload, "include_storage", method=method, default=False) def require_diagnostic_mode(payload: dict[str, Any], method: str) -> dict[str, Any] | None: if payload.get("_internal") is True: return None diagnostic, diagnostic_error = strict_bool_argument(payload, "diagnostic", method=method, default=False) if diagnostic_error: return diagnostic_error if diagnostic is True: return None return invalid_argument( method, "diagnostic", "Низкоуровневый диагностический метод доступен только при diagnostic=true.", allowed_values=["true"], ) def jsonable(value: Any) -> Any: if isinstance(value, Decimal): return int(value) if value == value.to_integral_value() else float(value) if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, (bytes, bytearray)): return {"type": "binary", "bytes": len(value), "hex": bytes(value).hex()} return value def dbnames_ext_guid_from_idrref(data: bytes | bytearray | None) -> str | None: if not data or len(data) != 16: return None reordered = bytes(data[12:16] + data[10:12] + data[8:10] + data[0:2] + data[2:8]) return str(uuid.UUID(bytes=reordered)) def canonical_kind(value: str | None) -> str | None: if not value: return None stripped = value.strip() return KIND_ALIASES.get(normalize(stripped), stripped) def known_canonical_kind(value: str | None) -> str | None: """Resolve only known 1C metadata roots, including code and value-type roots.""" candidate = canonical_kind(value) return candidate if candidate in RU_KIND else None def parse_1c_object_path(value: Any, explicit_kind: str | None = None) -> dict[str, Any]: """Parse public refs and code-like 1C paths without exposing storage identifiers.""" raw = str(value or "").strip() parts = [part.strip() for part in raw.split(".") if part.strip()] result: dict[str, Any] = { "input": raw, "parts": parts, "kind": known_canonical_kind(explicit_kind), "name": "", "member_path": [], "recognized_root": False, } if not parts: return result first = parts[0] if first.casefold().startswith("cfg:"): first = first[4:].strip() parts[0] = first root_index = 0 if normalize(first) in {"metadata", "метаданные"}: root_index = 1 root = parts[root_index] if len(parts) > root_index else "" root_kind = known_canonical_kind(root) if root_kind: result["recognized_root"] = True result["input_root"] = root result["kind"] = result["kind"] or root_kind name_index = root_index + 1 if len(parts) > name_index: result["name"] = parts[name_index] result["member_path"] = parts[name_index + 1 :] elif result["kind"]: result["name"] = parts[0] result["member_path"] = parts[1:] else: result["name"] = raw kind = result.get("kind") name = str(result.get("name") or "") if kind and name: result["canonical_ref"] = f"{kind}.{name}" result["metadata_ref"] = f"{RU_KIND.get(kind, kind)}.{name}" result["code_ref"] = f"{ONEC_CODE_ROOT_BY_KIND.get(kind, RU_KIND.get(kind, kind))}.{name}" return result def parse_kind_request(kind: str | None) -> tuple[str | None, str | None]: raw = normalize(kind) public_values = set(PUBLIC_KIND.values()) internal_values = set(PUBLIC_KIND) | set(DBNAMES_ROLE_KIND.values()) | set(KIND_CAPABILITIES) wanted = canonical_kind(kind) if raw in public_values and (wanted not in internal_values): return None, raw if wanted in internal_values: return wanted, None return wanted, None def parse_object_query(kind: str | None, name: str) -> tuple[str | None, str]: query = str(name or "").strip() parsed = parse_1c_object_path(query, explicit_kind=kind) if parsed.get("kind") and parsed.get("name"): return str(parsed["kind"]), str(parsed["name"]) if "." not in query: return parse_kind_request(kind)[0], query left, right = query.split(".", 1) left = left[4:] if left.casefold().startswith("cfg:") else left return parse_kind_request(kind)[0] or parse_kind_request(left)[0], right def dbnames_record_storage_table(record: Any, default_table: str = "Config") -> str: source = str(getattr(record, "source", "") or "") if extension_guid_from_dbnames_source(source): return "ConfigCAS" return default_table def preferred_object_storage_table(row: dict[str, Any] | None, default_table: str = "Config") -> str: if not isinstance(row, dict): return default_table storage = row.get("storage") if isinstance(row.get("storage"), dict) else {} table = str(storage.get("table") or row.get("table") or default_table or "Config") return table if table in STORAGE_TABLES else "Config" def normalize_object_ref_payload(payload: dict[str, Any], method: str) -> dict[str, Any] | dict[str, Any]: """Fill kind/name/guid from public 1C refs such as Document.Поступление.""" if "ref" not in payload or payload.get("ref") in {None, ""}: return payload ref_value = payload.get("ref") if not isinstance(ref_value, str): return invalid_argument(method, "ref", "ref must be a JSON string.") ref = ref_value.strip() if not ref: return payload normalized = dict(payload) if is_guid_text(ref): if not normalized.get("guid"): normalized["guid"] = ref.lower() return normalized ref_kind, ref_name = parse_object_query(None, ref) if ref_kind and ref_name: if not normalized.get("kind"): normalized["kind"] = ref_kind if not normalized.get("name"): normalized["name"] = ref_name elif ref_name and not normalized.get("name"): normalized["name"] = ref_name return normalized def normalize_object_selector_aliases(payload: dict[str, Any], method: str) -> dict[str, Any]: """Fill kind/name/guid from MCP-friendly object_type/object_name/object_guid aliases.""" module_ref = str(payload.get("module_ref") or "").strip() if not module_ref: selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error normalized = normalize_object_ref_payload(payload, method) if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": return normalized result = dict(normalized) if not result.get("kind") and result.get("object_type") not in {None, ""}: result["kind"] = result.get("object_type") if not result.get("name") and result.get("object_name") not in {None, ""}: result["name"] = result.get("object_name") if not result.get("guid") and result.get("object_guid") not in {None, ""}: result["guid"] = result.get("object_guid") if result.get("kind"): result["kind"] = canonical_kind(str(result.get("kind") or "")) return result def normalize_data_object_selector_aliases(payload: dict[str, Any], method: str) -> dict[str, Any]: """Normalize data-method object names without consuming record references.""" object_ref = payload.get("object_ref") if object_ref is not None and not isinstance(object_ref, str): return invalid_argument(method, "object_ref", "object_ref must be a JSON string.") ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if ordinal_value not in {None, ""}: return invalid_argument( method, "ordinal", "Application-data methods require an object name, public ref, or GUID; ordinal selectors are not supported.", ) selector_payload = dict(payload) selector_payload.pop("record_ref", None) selector_payload.pop("recorder_ref", None) raw_ref = str(payload.get("ref") or "").replace("-", "").strip() if isinstance(object_ref, str) and object_ref.strip(): selector_payload["ref"] = object_ref.strip() elif re.fullmatch(r"[0-9a-fA-F]{32}", raw_ref): selector_payload.pop("ref", None) normalized_selector = normalize_object_selector_aliases(selector_payload, method) if isinstance(normalized_selector, dict) and normalized_selector.get("status") == "invalid_argument": return normalized_selector result = dict(payload) for key in ("kind", "name", "guid"): if normalized_selector.get(key) not in {None, ""}: result[key] = normalized_selector[key] return result def normalize_selector_payload_for_method(payload: dict[str, Any], method: str) -> dict[str, Any]: capabilities = OBJECT_SELECTOR_METHOD_CAPABILITIES.get(method) or {} ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if capabilities.get("accepts_ordinal") is False and ordinal_value not in {None, ""}: return invalid_argument( method, "ordinal", "This method requires an object name, public ref, or GUID; ordinal selectors are not supported.", ) if method in DATA_OBJECT_SELECTOR_METHODS: return normalize_data_object_selector_aliases(payload, method) return normalize_object_selector_aliases(payload, method) def has_object_selector(payload: dict[str, Any]) -> bool: ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") return bool( payload.get("ref") or payload.get("guid") or payload.get("object_guid") or payload.get("name") or payload.get("object_name") or ordinal_value not in {None, ""} ) def object_selector_ref(kind: Any, name: Any) -> str | None: kind_text = str(kind or "").strip() name_text = str(name or "").strip() if not kind_text or not name_text: return None return f"{canonical_kind(kind_text) or kind_text}.{name_text}" def enrich_selector_with_object_ref(selector: dict[str, Any], object_info: dict[str, Any]) -> dict[str, Any]: enriched = dict(selector) if not enriched.get("name") and object_info.get("name") not in {None, ""}: enriched["name"] = object_info.get("name") public_ref = object_selector_ref(enriched.get("kind") or object_info.get("kind"), enriched.get("name") or object_info.get("name")) if public_ref: enriched["ref"] = public_ref return enriched ADAPTER_CONTRACT_VERSION = "onec-selector-contract.v1" OBJECT_SELECTOR_GUIDANCE = " Object selector accepts ref, kind/name/guid, or object_type/object_name/object_guid." OBJECT_SELECTOR_GUIDANCE_TERMS = ("ref", "kind/name/guid", "object_type/object_name/object_guid") OBJECT_SELECTOR_REQUIRED_MESSAGE = "Pass an object selector: ref, kind/name/guid, object_type/object_name/object_guid, or ordinal." OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL = "Pass an object selector: ref, kind/name/guid, or object_type/object_name/object_guid." OBJECT_SELECTOR_GLOBAL_REQUIRED_MESSAGE = ( "Pass an object selector: ref, kind/name/guid, or object_type/object_name/object_guid; " "or use areas metadata/extensions for global lookup." ) OBJECT_SELECTOR_OR_MODULE_REQUIRED_MESSAGE = ( "Pass module_ref/module_id or object selector (ref, kind/name/guid, or object_type/object_name/object_guid)." ) MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE = ( "Pass an object selector (ref, kind/name/guid, object_type/object_name/object_guid, or ordinal) " "or use module_id as
:[#stream:] where
is one of Config, ConfigSave, ConfigCAS, ConfigCASSave." ) OBJECT_SELECTOR_ARGUMENTS = ["ref", "object_type", "object_name", "object_guid", "kind", "name", "guid"] OBJECT_SELECTOR_DEFAULT_CAPABILITIES = { "accepts_ref": True, "accepts_kind_name_guid": True, "accepts_object_aliases": True, "accepts_ordinal": True, "accepts_module_ref": False, "accepts_extension": False, "allows_global_areas": False, "requires_object_selector": False, } OBJECT_SELECTOR_METHOD_CAPABILITIES = { method: dict(OBJECT_SELECTOR_DEFAULT_CAPABILITIES) for method in ( "metadata.objects.list", "metadata.object.get", "metadata.object.properties", "metadata.object.property.write", "metadata.object.member.add", "metadata.object.decode", "metadata.object.parts", "metadata.object.components", "metadata.object.modules", "metadata.object.related", "metadata.object.forms", "metadata.object.form.details", "metadata.object.templates", "metadata.object.template.details", "templates.read", "templates.analyze", "templates.map", "metadata.object.commands", "metadata.object.special.details", "metadata.form.decode", "metadata.saved_state.prepare", "metadata.resolve_overrides", "templates.bindings", "diagnostics.call_chain", "access.object.roles", "access.object.subjects", ) } OBJECT_SELECTOR_METHOD_CAPABILITIES.update( { "metadata.object.property.write": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": True, }, "metadata.object.member.add": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, }, "metadata.objects.list": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": False, "extension_alternative_method": "extension.objects.find", }, "metadata.definition.find": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, "allows_global_areas": True, "requires_query": True, }, "metadata.route.resolve": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, "allows_global_search": True, }, "extension.objects.find": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, "allows_global_search": True, }, "metadata.object.attributes": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, }, "metadata.relationship.verify": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, "read_only": True, }, "metadata.relationship.find": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, "read_only": True, }, "metadata.object.full": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, }, "metadata.saved_state.prepare": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_module_ref": True, "accepts_extension": True, }, "metadata.saved_state.diff": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_module_ref": True, "accepts_extension": True, "requires_object_selector": False, "module_selector_arguments": ["module_ordinal", "module_index", "module_number"], "read_only": True, }, "metadata.module_owner_cache.prune": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_module_ref": True, "requires_object_selector": False, "selector_purpose": "cache_owner_scope", "local_cache_mutation": True, }, "metadata.module_owner_cache.backfill": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "requires_object_selector": False, "selector_purpose": "cache_owner_backfill_scope", "local_cache_mutation": True, }, "modules.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, "allows_global_search": True, }, "modules.read": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_module_ref": True, "requires_object_selector": False, }, "code.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "allows_global_search": True, "requires_query": True, }, "code.read": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_module_ref": True, "requires_object_selector": False, }, "code.symbol.resolve": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_module_ref": True, "requires_object_selector": False, }, "metadata.support.decode": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "requires_object_selector": False, "selector_purpose": "optional_object_support_filter", }, "metadata.code_index.build": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "allows_global_search": True, "requires_object_selector": False, "selector_purpose": "optional_index_scope", }, "metadata.form.owner_index.build": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name"], }, "metadata.form.command_button.verify": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name"], "child_selector_arguments": ["command_name", "button_name", "handler_name"], "read_only": True, }, "metadata.form.command_button.write": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["command_name", "button_name", "handler_name"], "read_only": False, "write_guards": [ "allow_saved_state_write", "allow_sql_saved_state_apply", "repository_apply_gate", "verified_saved_state_target", ], }, "metadata.form.write_target.resolve": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": True, }, "metadata.form.write_target.verify": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": True, }, "metadata.form.element.write": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": False, "write_guards": ["allow_saved_state_write", "proposal_only"], }, "metadata.form.element.write_apply": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": False, "write_guards": [ "allow_saved_state_write", "allow_sql_saved_state_apply", "repository_apply_gate", "verified_saved_state_target", ], }, "metadata.form.target.move": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["from_element", "to_element", "with_element", "after_element"], "read_only": False, "write_guards": [ "allow_saved_state_write", "allow_sql_saved_state_apply", "repository_apply_gate", "verified_saved_state_target", ], }, "metadata.form.write_matrix.build": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": True, }, "metadata.form.write_matrix.smoke": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": False, "write_guards": [ "allow_sql_saved_state_apply", "allow_sql_saved_state_rollback", "apply_and_rollback_only", ], }, "metadata.write_learning.capture_before": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": False, "write_guards": ["local_artifact_only", "no_sql_write"], }, "metadata.write_learning.capture_after": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": False, "write_guards": ["local_artifact_only", "no_sql_write"], }, "metadata.saved_state.forms.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "optional_form_owner_scope", "form_selector_arguments": ["form", "form_name", "form_guid"], "child_selector_arguments": ["element", "element_name", "command", "attribute"], "read_only": True, }, "metadata.saved_state.modules.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "allows_global_search": True, "requires_object_selector": False, "selector_purpose": "optional_module_owner_scope", "module_selector_arguments": ["file_name", "stream_index"], "read_only": True, }, "code.write": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "accepts_module_ref": True, "requires_object_selector": False, "selector_purpose": "module_owner_or_concrete_module", "form_selector_arguments": ["form", "form_name"], "child_selector_arguments": ["routine_name"], "read_only": False, "write_guards": [ "save_first", "allow_sql_saved_state_apply", "repository_apply_gate", "verified_saved_state_target", ], }, "templates.areas.find": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "accepts_extension": True, "requires_object_selector": False, "selector_purpose": "template_or_direct_route", "child_selector_arguments": ["area_name", "area_query", "area_occurrence"], "direct_route_arguments": ["route_ref", "table", "file_name"], "read_only": True, }, "metadata.cache.lookup": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "requires_object_selector": True, "selector_purpose": "local_identity_cache_lookup", "read_only": True, "authoritative_source": "live_sql_not_cache", }, **{ method: { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_ordinal": False, "requires_object_selector": True, "accepts_object_ref": True, "record_ref_argument": ( "recorder_ref" if method == "data.movements" else "record_ref" if method in {"data.get", "data.present"} else None ), } for method in DATA_OBJECT_SELECTOR_METHODS }, } ) OBJECT_SELECTOR_ALIAS_METHODS = frozenset(OBJECT_SELECTOR_METHOD_CAPABILITIES) DEFAULT_METHOD_INPUT_SCHEMA = { "type": "object", "additionalProperties": True, "description": "Method-specific JSON payload. See the method description and selector_capabilities.", } def object_selector_capabilities(method: str) -> dict[str, Any] | None: capabilities = OBJECT_SELECTOR_METHOD_CAPABILITIES.get(method) return dict(capabilities) if capabilities is not None else None def validate_object_selector_arguments(payload: dict[str, Any], method: str, *, include_view: bool = True) -> dict[str, Any] | None: arguments = [*OBJECT_SELECTOR_ARGUMENTS, *(["view"] if include_view else [])] return validate_optional_string_arguments(payload, method, arguments) def public_method_row(row: dict[str, Any]) -> dict[str, Any]: public = dict(row) name = str(public.get("name") or "") description = str(public.get("description") or "") if name in OBJECT_SELECTOR_ALIAS_METHODS and not all(term in description for term in OBJECT_SELECTOR_GUIDANCE_TERMS): public["description"] = f"{description}{OBJECT_SELECTOR_GUIDANCE}" selector_capabilities = object_selector_capabilities(name) if selector_capabilities is not None: public["selector_capabilities"] = selector_capabilities public["input_schema"] = copy.deepcopy(METHOD_INPUT_SCHEMAS.get(name) or DEFAULT_METHOD_INPUT_SCHEMA) specialized_routes = adapter_http_routes_for_method(name) if specialized_routes: public["http_routes"] = specialized_routes return public HTTP_GET_METHOD_ROUTES = { "/health": "health", "/methods": "help.methods", "/metadata/kinds": "metadata.kinds", "/metadata/objects": "metadata.objects.list", "/metadata/object": "metadata.object.get", "/extensions": "extensions.list", "/modules/read": "modules.read", } HTTP_POST_METHOD_ROUTES = { "/metadata/snapshot": "metadata.snapshot", "/metadata/object/decode": "metadata.object.decode", "/metadata/object/parts": "metadata.object.parts", "/metadata/object/components": "metadata.object.components", "/metadata/object/modules": "metadata.object.modules", "/metadata/object/related": "metadata.object.related", "/metadata/object/forms": "metadata.object.forms", "/metadata/object/form-details": "metadata.object.form.details", "/metadata/object/templates": "metadata.object.templates", "/metadata/object/template-details": "metadata.object.template.details", "/templates/read": "templates.read", "/templates/analyze": "templates.analyze", "/templates/map": "templates.map", "/metadata/object/commands": "metadata.object.commands", "/metadata/route/resolve": "metadata.route.resolve", "/metadata/object/special-details": "metadata.object.special.details", "/metadata/form/decode": "metadata.form.decode", "/metadata/form/write-target/resolve": "metadata.form.write_target.resolve", "/metadata/saved-state/forms/search": "metadata.saved_state.forms.search", "/configuration/activation/status": "configuration.activation.status", "/configuration/activation/plan": "configuration.activation.plan", "/configuration/activation/request": "configuration.activation.request", "/configuration/activation/request/status": "configuration.activation.request.status", "/configuration/activation/request/cancel": "configuration.activation.request.cancel", "/configuration/activation/audit": "configuration.activation.audit", "/configuration/activation/capabilities": "configuration.activation.capabilities", "/configuration/activation/bridge/probe": "configuration.activation.bridge.probe", "/configuration/activation/execute": "configuration.activation.execute", "/configuration/activation/verify": "configuration.activation.verify", "/metadata/form/element-write": "metadata.form.element.write", "/metadata/form/element-write-apply": "metadata.form.element.write_apply", "/metadata/write-plan": "metadata.write.plan", "/metadata/write-capabilities": "metadata.write.capabilities", "/metadata/write": "metadata.write", "/metadata/write-learning/capture-before": "metadata.write_learning.capture_before", "/metadata/write-learning/capture-after": "metadata.write_learning.capture_after", "/metadata/write-learning/diff": "metadata.write_learning.diff", "/metadata/write-learning/infer-rule": "metadata.write_learning.infer_rule", "/code/write": "code.write", "/metadata/object/attributes": "metadata.object.attributes", "/metadata/object/full": "metadata.object.full", "/metadata/resolve-overrides": "metadata.resolve_overrides", "/modules/search": "modules.search", "/code/search": "code.search", "/code/read": "code.read", "/code/symbol/resolve": "code.symbol.resolve", "/templates/bindings": "templates.bindings", "/diagnostics/call-chain": "diagnostics.call_chain", "/metadata/module-owner-cache/prune": "metadata.module_owner_cache.prune", "/access/snapshot/extract": "access.snapshot.extract", "/access/graph": "access.graph.build", "/access/user/explain": "access.user.explain", "/access/users/search": "access.users.search", "/access/keys/query": "access.keys.query", "/access/object-keys/resolve": "access.object_keys.resolve", "/access/object/explain": "access.object.explain", "/access/object/roles": "access.object.roles", "/access/object/subjects": "access.object.subjects", "/access/rls/discover": "access.rls.discover", "/access/role/profiles": "access.role.profiles", "/access/role/users": "access.role.users", "/access/role/audit-export": "access.role.audit_export", "/access/role/audit-analyze": "access.role.audit_analyze", "/extension/objects/find": "extension.objects.find", "/query/validate": "query.validate", "/query/run": "query.run", "/codec/decode": "codec.decode", "/codec/encode": "codec.encode", "/changes/propose": "changes.propose", "/storage/saved-state/apply-proposal": "storage.saved_state.apply_proposal", "/storage/saved-state/rollback": "storage.saved_state.rollback", "/storage/saved-state/backups": "storage.saved_state.backups.list", "/storage/saved-state/backups/prune": "storage.saved_state.backups.prune", } def adapter_http_routes_for_method(method: str) -> list[dict[str, str]]: routes = [ {"verb": "GET", "path": path} for path, route_method in HTTP_GET_METHOD_ROUTES.items() if route_method == method ] routes.extend( {"verb": "POST", "path": path} for path, route_method in HTTP_POST_METHOD_ROUTES.items() if route_method == method ) return routes METHOD_INPUT_SCHEMAS["form.field.lineage"] = { "type": "object", "required": ["base_id", "field"], "additionalProperties": True, "properties": { "base_id": {"type": "string"}, "ref": {"type": "string", "description": "Public object ref, e.g. Document.УОП_ПланированиеМеню."}, "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, "field": {"type": "string", "description": "Form element, attribute, command, or displayed field name."}, "form": {"type": "string"}, "form_title": {"type": "string"}, "analysis_profile": {"type": "string", "enum": ["full", "fast"], "default": "full"}, "match_mode": {"type": "string", "enum": ["contains", "exact", "semantic"], "default": "contains"}, "include_related": {"type": "boolean", "default": False}, "include_binding_inference": {"type": "boolean", "default": True}, "include_call_chain": {"type": "boolean", "default": True}, "include_calculation": {"type": "boolean", "default": True}, "call_chain_depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 2}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 30}, "execution_mode": {"type": "string", "enum": ["job"]}, }, "examples": [{ "base_id": "upo_test", "ref": "Document.УОП_ПланированиеМеню", "form": "ФормаДокумента", "field": "ВидыКоличествЦенаПродажи", "match_mode": "exact", "include_calculation": False, }], } def adapter_method_registry() -> dict[str, dict[str, Any]]: """Single runtime registry used by help, jobs, RPC validation, and MCP checks.""" return { str(row.get("name") or ""): public_method_row(row) for row in METHODS if str(row.get("name") or "") } def adapter_method_registry_diagnostics() -> dict[str, Any]: names = [str(row.get("name") or "") for row in METHODS] duplicates = sorted({name for name in names if name and names.count(name) > 1}) registered = set(adapter_method_registry()) route_methods = set(HTTP_GET_METHOD_ROUTES.values()) | set(HTTP_POST_METHOD_ROUTES.values()) return { "status": "ok" if not duplicates and set(METHOD_INPUT_SCHEMAS) <= registered and set(OBJECT_SELECTOR_METHOD_CAPABILITIES) <= registered and route_methods <= registered else "invalid", "counts": { "methods": len(registered), "methods_with_input_schema": sum(1 for row in adapter_method_registry().values() if isinstance(row.get("input_schema"), dict)), "explicit_input_schemas": len(METHOD_INPUT_SCHEMAS), "selector_contracts": len(OBJECT_SELECTOR_METHOD_CAPABILITIES), "specialized_http_routes": len(HTTP_GET_METHOD_ROUTES) + len(HTTP_POST_METHOD_ROUTES), }, "duplicates": duplicates, "input_schemas_without_method": sorted(set(METHOD_INPUT_SCHEMAS) - registered), "selector_contracts_without_method": sorted(set(OBJECT_SELECTOR_METHOD_CAPABILITIES) - registered), "http_routes_without_method": sorted(route_methods - registered), } def is_extension_path(path: str | None) -> bool: parts = [part.casefold() for part in str(path or "").replace("/", "\\").split("\\")] return "расширения" in parts or "extensions" in parts def is_extension_object(item: dict[str, Any], top: dict[str, Any]) -> bool: return bool(item.get("extension_routes")) or is_extension_path(top.get("relative_path") or top.get("path")) ADAPTER_HEALTH_CAPABILITIES = [ "live_sql", "read-only-query", "extensions", "saved-state-read", "saved-state-write", "active-saved-compare", "configuration-activation-status", "repository-coordination", "support-decode", "code-index", ] def adapter_health_features(*, configured: bool, base_id: str | None) -> dict[str, Any]: base_required = not bool(base_id) available = bool(configured and base_id) return { "saved_state_read": { "available": available, "requires_base_id": base_required, "tables": ["ConfigSave", "ConfigCASSave"], }, "saved_state_write": { "available": available, "requires_base_id": base_required, "agent_method": "code.write", "target": "saved_state", "activation_state": "not_activated", "active_configuration_write": False, }, "active_saved_compare": { "available": available, "requires_base_id": base_required, "methods": ["code.read", "metadata.saved_state.diff"], "module_text_sha1": True, }, "repository_coordination": { "available": bool(base_id), "requires_base_id": base_required, "native_lock_verification": False, "manual_confirmation_supported": True, }, "support_decode": { "available": available, "requires_base_id": base_required, "source": "live_sql", }, "configuration_activation": { "available": available, "status": "debug_acceptance_available", "methods": [ "configuration.activation.status", "configuration.activation.plan", "configuration.activation.request", "configuration.activation.request.status", "configuration.activation.request.cancel", "configuration.activation.audit", "configuration.activation.capabilities", "configuration.activation.bridge.probe", "configuration.activation.execute", "configuration.activation.verify", ], "debug_acceptance_available": True, "execution_available": False, "reason": "designer_activation_bridge_not_implemented", "saved_state_only": True, "read_only": True, "state_store": "local_sqlite", }, } ADAPTER_RUNTIME_LOCK = threading.Lock() ADAPTER_RUNTIME: dict[str, Any] = {"draining": False, "active_rpc": {}} def adapter_runtime_begin_request(request_id: str, method: str) -> None: with ADAPTER_RUNTIME_LOCK: ADAPTER_RUNTIME["active_rpc"][request_id] = method def adapter_runtime_end_request(request_id: str) -> None: with ADAPTER_RUNTIME_LOCK: ADAPTER_RUNTIME["active_rpc"].pop(request_id, None) def adapter_runtime_health() -> dict[str, Any]: with ADAPTER_RUNTIME_LOCK: methods = list(ADAPTER_RUNTIME["active_rpc"].values()) return { "state": "draining" if ADAPTER_RUNTIME["draining"] else "ready", "active_rpc_count": len(methods), "active_methods": dict(sorted(Counter(methods).items())), } class AdapterState: def health(self, *, base_id: str | None = None) -> dict[str, Any]: if not base_id: return { "schema": "onec_adapter_health.v1", "status": "ok", "base_id": None, "contract_version": ADAPTER_CONTRACT_VERSION, "runtime": adapter_runtime_health(), "capabilities": ADAPTER_HEALTH_CAPABILITIES, "features": adapter_health_features(configured=False, base_id=None), "diagnostics": {"message": "Pass base_id to check a concrete 1C database source."}, } resolved_base_id = str(base_id) config, config_error = sql_config_for_base(resolved_base_id) result: dict[str, Any] = { "schema": "onec_adapter_health.v1", "status": "ok" if config else "degraded", "base_id": resolved_base_id, "contract_version": ADAPTER_CONTRACT_VERSION, "runtime": adapter_runtime_health(), "live_sql": { "configured": bool(config), "server": config.get("server") if config else None, "database": config.get("database") if config else None, "user": config.get("user") if config else None, }, "capabilities": ADAPTER_HEALTH_CAPABILITIES, "features": adapter_health_features(configured=bool(config), base_id=resolved_base_id), } if config_error: result["diagnostics"] = config_error return result STATE: AdapterState def top_objects(index: dict[str, Any]): for guid, item in (index.get("objects") or {}).items(): for top in item.get("xml_top_objects") or []: yield str(guid), item, top def base_id_required(method: str) -> dict[str, Any]: return { "schema": "onec_adapter_request_error.v1", "method": method, "status": "error", "error": "base_id_required", "diagnostics": {"message": "Pass base_id explicitly. The adapter does not use a default database."}, } def live_source_unavailable(method: str, base_id: str, config_error: dict[str, Any] | None) -> dict[str, Any]: return { "schema": "onec_adapter_source_missing.v1", "method": method, "status": "source_missing", "base_id": base_id, "source": {"kind": "live_sql", "status": (config_error or {}).get("status", "not_configured")}, "diagnostics": config_error or {"message": "Live SQL connection is not configured for this base_id."}, } def compact_storage(item: dict[str, Any]) -> dict[str, Any]: return { "dbnames": item.get("dbnames") or [], "config_routes": item.get("config_routes") or [], "extension_routes": item.get("extension_routes") or [], "route_kind": item.get("route_kind") or [], "xml_occurrence_count": item.get("xml_occurrence_count"), } STORAGE_TRACE_KEYS = { "database", "depth", "file_name", "guids_sample", "id_path", "index", "marker", "marker_name", "module_id", "name_path", "object_storage_routes", "part_id", "path", "payload", "payload_bytes", "payload_role", "raw_bytes", "sha1", "source_file", "storage", "storage_routes", "strings_sample", "stream_index", "suffix", "table", "title_lang", "title_path", "type_code", } def strip_storage_traces(value: Any) -> Any: if isinstance(value, list): return [strip_storage_traces(item) for item in value] if not isinstance(value, dict): return value public: dict[str, Any] = {} for key, item in value.items(): if key in STORAGE_TRACE_KEYS: continue if key == "source" and isinstance(item, dict) and item.get("kind") == "live_sql": public[key] = {"kind": "live_metadata"} continue public[key] = strip_storage_traces(item) return public def public_error_result(result: dict[str, Any], *, include_storage: bool, method: str) -> dict[str, Any]: public = dict(result) public["method"] = method if include_storage: return public return strip_storage_traces(public) def public_metadata_row(row: dict[str, Any], *, include_storage: bool = False) -> dict[str, Any]: """Return a 1C-facing metadata row; physical storage is opt-in.""" public = dict(row) if public.get("name") not in {None, ""}: public["name"] = best_text_variant(public.get("name")) if public.get("synonym") not in {None, ""}: public["synonym"] = best_text_variant(public.get("synonym")) identity = public.get("identity") if isinstance(identity, dict): identity = dict(identity) if identity.get("name") not in {None, ""}: identity["name"] = best_text_variant(identity.get("name")) synonyms = identity.get("synonyms") if isinstance(synonyms, dict): identity["synonyms"] = { key: best_text_variant(value) for key, value in synonyms.items() } public["identity"] = identity public_ref = object_selector_ref(public.get("kind"), public.get("name")) if public_ref: public["ref"] = public_ref storage = public.pop("storage", None) if include_storage and storage is not None: public["storage"] = storage return public def metadata_payload_missing_diagnostics() -> dict[str, str]: return {"message": "Описание объекта метаданных не найдено в хранилище конфигурации."} def public_semantic_profile( semantic: dict[str, Any] | None, *, include_storage: bool = False, resolved_types: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any] | None: if not isinstance(semantic, dict): return None resolved_types = resolved_types or {} public = dict(semantic) if not include_storage: public.pop("object_storage_routes", None) public.pop("section_rules", None) public.pop("generic_sections", None) sections = [] for section in public.get("sections") or []: if not isinstance(section, dict): sections.append(section) continue public_section = dict(section) if not include_storage: for key in STORAGE_TRACE_KEYS: public_section.pop(key, None) records = [] for record in public_section.get("records") or []: if not isinstance(record, dict): records.append(record) continue public_record = dict(record) if not include_storage: for key in STORAGE_TRACE_KEYS: public_record.pop(key, None) if "type" in public_record: public_record["type"] = public_type_info(public_record.get("type"), resolved_types, include_storage=include_storage) columns = [] for column in public_record.get("columns") or []: if not isinstance(column, dict): columns.append(column) continue public_column = dict(column) if not include_storage: for key in STORAGE_TRACE_KEYS: public_column.pop(key, None) if "type" in public_column: public_column["type"] = public_type_info(public_column.get("type"), resolved_types, include_storage=include_storage) columns.append(public_column) if "columns" in public_record: public_record["columns"] = columns records.append(public_record) public_section["records"] = records sections.append(public_section) public["sections"] = sections return public if include_storage else strip_storage_traces(public) OBJECT_MODULE_OWNER_KINDS = { "Catalog", "Document", "Report", "DataProcessor", "BusinessProcess", "Task", "ChartOfCharacteristicTypes", "ChartOfAccounts", "ChartOfCalculationTypes", "ExchangePlan", } REGISTER_MODULE_OWNER_KINDS = { "InformationRegister", "AccumulationRegister", "AccountingRegister", "CalculationRegister", } CODE_CARRIER_MATRIX = { "object_modules": { "status": "supported", "read_status": "supported", "write_status": "supported_saved_state", "module_roles": ["object_module", "manager_module", "command_module"], "owner_kinds": sorted(OBJECT_MODULE_OWNER_KINDS), "read_methods": ["modules.search", "modules.read", "code.search", "code.read"], "write_methods": ["code.write", "metadata.write", "storage.saved_state.apply_proposal"], "methods": ["modules.search", "modules.read", "code.search", "code.read", "code.write"], "default_state": "working", "notes": ["Reads prefer saved-state tables for programming-time analysis.", "Writes target ConfigSave/ConfigCASSave only."], }, "register_modules": { "status": "supported", "read_status": "supported", "write_status": "supported_saved_state", "module_roles": ["record_set_module", "register_module", "command_module"], "owner_kinds": sorted(REGISTER_MODULE_OWNER_KINDS), "read_methods": ["modules.search", "modules.read", "code.search", "code.read"], "write_methods": ["code.write", "metadata.write", "storage.saved_state.apply_proposal"], "methods": ["modules.search", "modules.read", "code.search", "code.read", "code.write"], "default_state": "working", "notes": ["Record-set and command/register streams are named at the public 1C level."], }, "common_modules": { "status": "supported", "read_status": "supported", "write_status": "supported_saved_state", "module_roles": ["common_module"], "owner_kinds": ["CommonModule"], "read_methods": ["modules.search", "modules.read", "code.search", "code.read"], "write_methods": ["code.write"], "methods": ["modules.search", "modules.read", "code.search", "code.read", "code.write"], "default_state": "working", "notes": ["Common modules are discovered by public 1C name and their canonical .0 Config stream is exposed as BSL."], }, "form_modules": { "status": "supported", "read_status": "supported", "write_status": "supported_saved_state", "module_roles": ["form_module"], "owner_kinds": ["CommonForm", "Form"], "read_methods": ["metadata.object.forms", "metadata.form.decode", "modules.search", "modules.read", "code.search", "code.read"], "write_methods": ["code.write", "metadata.write", "storage.saved_state.apply_proposal"], "methods": ["metadata.object.forms", "metadata.form.decode", "modules.search", "modules.read", "code.search", "code.read", "code.write"], "default_state": "working", "notes": ["Saved-state form modules are returned as public BSL text, not raw form containers."], }, "scheduled_jobs": { "status": "supported_reference", "read_status": "supported_reference", "write_status": "read_only", "module_roles": [], "owner_kinds": ["ScheduledJob"], "read_methods": ["metadata.object.properties", "metadata.object.special.details", "modules.read", "metadata.resolve_overrides", "code.search", "code.read"], "write_methods": [], "methods": ["metadata.object.properties", "metadata.object.special.details", "modules.read", "metadata.resolve_overrides", "code.search", "code.read"], "default_state": "working", "notes": ["SQL metadata resolves the common-module owner and returns a ready modules.read selector for the scheduled handler procedure."], }, "application_session_external_connection_modules": { "status": "supported_read", "read_status": "supported", "write_status": "read_only", "module_roles": ["ordinary_application_module", "managed_application_module", "session_module", "external_connection_module"], "owner_kinds": ["Configuration"], "read_methods": ["metadata.object.modules", "modules.search", "modules.read", "code.search", "code.read"], "write_methods": [], "methods": ["metadata.object.modules", "modules.search", "modules.read", "code.search", "code.read"], "default_state": "working", "notes": ["The SQL file prefix comes from the Configuration identity GUID; stable Config suffixes are mapped to public module roles."], }, "web_http_service_modules": { "status": "supported_read", "read_status": "supported", "write_status": "read_only", "module_roles": ["web_service_module", "http_service_module", "integration_service_module"], "owner_kinds": ["WebService", "HTTPService", "IntegrationService"], "read_methods": ["metadata.object.modules", "modules.search", "modules.read", "code.search", "code.read"], "write_methods": [], "methods": ["metadata.object.modules", "modules.search", "modules.read", "code.search", "code.read"], "default_state": "working", "notes": ["The canonical full BSL stream is selected from the service Config part; short duplicate container fragments are ignored."], }, "event_subscriptions": { "status": "supported_reference", "read_status": "supported_reference", "write_status": "read_only", "module_roles": [], "owner_kinds": ["EventSubscription"], "read_methods": ["metadata.object.properties", "metadata.object.special.details", "modules.read", "code.read"], "write_methods": [], "methods": ["metadata.object.properties", "metadata.object.special.details", "modules.read", "code.read"], "default_state": "working", "notes": ["SQL metadata resolves every event source plus the common-module handler and returns a ready modules.read selector for the handler procedure."], }, } METADATA_WRITE_CAPABILITIES = { "bsl_modules": { "status": "supported_saved_state", "targets": ["object_module", "manager_module", "command_module", "record_set_module", "register_module", "form_module", "common_module"], "operations": ["replace_module", "replace_routine", "replace_unique_fragment"], "agent_method": "code.write", "write_layer": "save", "apply_method": "storage.saved_state.apply_proposal", "guards": ["expected_sha1", "expected_text_sha1", "backup", "readback_verification"], }, "report_scd": { "status": "supported_saved_state", "targets": ["report_data_composition_schema", "dataset_query", "calculated_expression", "resource_expression"], "operations": ["inspect", "prepare", "plan_scalar_patch", "apply_and_verify", "semantic_compare", "rollback_prepare"], "agent_method": "scd.patch", "write_layer": "save", "apply_method": "storage.saved_state.apply_proposal", "guards": ["expected_sha1", "prepare_receipt", "proposal_review", "semantic_readback", "semantic_compare"], "notes": ["The report extension route is ConfigCAS -> ConfigCASSave; a base report route is Config -> ConfigSave."], }, "form_element_properties": { "status": "partial_saved_state", "targets": ["form_element", "form_command", "form_attribute"], "operations": [ "plan_property_write", "apply_property_write", "move_sibling_target", "upsert_command_button", "upsert_handler_routine", "verify_command_button_links", ], "agent_method": "metadata.write", "write_layer": "save", "guards": ["expected_sha1", "backup", "semantic_readback", "cache_invalidation"], "gaps": ["Cross-parent structural moves, arbitrary element deletion, complex command bindings, and inherited form properties are not fully writable yet."], }, "object_metadata": { "status": "partial_saved_state", "targets": ["object_identity", "attributes", "tabular_sections", "commands"], "operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"], "agent_method": "metadata.write", "write_layer": "save", "guards": ["expected_old", "expected_sha1", "backup", "semantic_readback", "settings_shape_verification", "cache_invalidation"], "gaps": ["Attribute add currently requires a safe existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, Dimension/Resource/TabularSection creation, and other structural collection writes remain disabled."], }, "templates": { "status": "read_only", "targets": ["template", "moxel", "html", "binary"], "operations": [], "write_layer": None, "gaps": ["Template rendering/round-trip encoding is not complete enough for safe writes."], }, "scheduled_jobs": { "status": "partial_saved_state", "targets": ["schedule", "method_reference"], "operations": ["plan_schedule_write", "apply_schedule_write", "verify_schedule_write", "rollback_schedule_write"], "agent_method": "metadata.write", "write_layer": "save", "guards": ["expected_sha1", "backup", "readback_verification"], "gaps": ["The method reference remains read-only here; edit executable code through the resolved module selector."], }, "web_http_services": { "status": "read_only", "targets": ["web_service_module", "http_service_module", "integration_service_module"], "operations": [], "write_layer": None, "gaps": ["Live SQL module discovery and reading are supported; saved-state write routing remains disabled until service-specific round-trip validation is complete."], }, "roles_rights_subscriptions": { "status": "read_only", "targets": ["rights", "roles", "event_subscriptions"], "operations": ["metadata.object.properties", "metadata.object.special.details", "modules.read"], "write_layer": None, "gaps": ["Role rights and event subscriptions are fully decoded for reads; saved-state mutation remains intentionally disabled."], }, } # This is deliberately data exposed by metadata.write.capabilities rather than # a free-form convention for agents. A report edit frequently spans all of # these carriers; its BSL handler must exist before the SCD/form is connected # to it. Entries with a read-only status remain visible so an agent stops # before proposing an unsafe binary/container rewrite. METADATA_WRITE_WORKFLOW = [ { "priority": 1, "area": "bsl_modules", "purpose": "Create or change executable BSL routines, including report and form handlers.", "status": "supported_saved_state", "methods": ["modules.read", "code.read", "metadata.write.plan", "code.write"], }, { "priority": 2, "area": "report_scd", "purpose": "Change a report Data Composition Schema query or scalar expression after the required BSL parameters are known.", "status": "supported_saved_state", "methods": ["scd.inspect", "scd.prepare", "scd.patch", "scd.compare"], }, { "priority": 3, "area": "templates", "purpose": "Inspect report layouts/templates and stop if a change would require binary or spreadsheet round-trip encoding.", "status": "read_only", "methods": ["templates.read"], }, { "priority": 4, "area": "form_element_properties", "purpose": "Bind commands and make supported form property changes after their handlers exist.", "status": "partial_saved_state", "methods": ["metadata.write.plan", "metadata.write"], }, { "priority": 5, "area": "object_metadata", "purpose": "Apply supported object-level metadata changes only after dependent code/forms have a verified plan.", "status": "partial_saved_state", "methods": ["metadata.write.plan", "metadata.write"], }, { "priority": 6, "area": "scheduled_jobs", "purpose": "Change a schedule only after its common-module handler has been reviewed or changed in priority 1.", "status": "partial_saved_state", "methods": ["metadata.write.plan", "metadata.write"], }, ] def module_suffix_from_file_name(file_name: Any) -> str | None: match = re.search(r"\.(\d+)$", str(file_name or "")) return match.group(1) if match else None def module_suffix_from_module_id(module_id: Any) -> str | None: table, file_name, _stream_index = parse_module_id(str(module_id or "")) if not table or not file_name: return None return module_suffix_from_file_name(file_name) def public_module_role( *, owner_kind: str | None = None, suffix: str | None = None, ordinal: int | None = None, current_name: str | None = None, ) -> dict[str, Any]: kind = canonical_kind(str(owner_kind or "")) suffix = str(suffix or "").strip().lstrip(".") if kind == "Configuration": configuration_role = { "0": ("ordinary_application_module", "Модуль обычного приложения"), "5": ("external_connection_module", "Модуль внешнего соединения"), "6": ("managed_application_module", "Модуль управляемого приложения"), "7": ("session_module", "Модуль сеанса"), }.get(suffix) if configuration_role: return {"kind": configuration_role[0], "name": configuration_role[1], "suffix": suffix} service_role = { "WebService": ("web_service_module", "Модуль Web-сервиса"), "HTTPService": ("http_service_module", "Модуль HTTP-сервиса"), "IntegrationService": ("integration_service_module", "Модуль сервиса интеграции"), }.get(kind) if service_role: return {"kind": service_role[0], "name": service_role[1], **({"suffix": suffix} if suffix else {})} if kind == "SettingsStorage": return {"kind": "manager_module", "name": "Модуль менеджера", **({"suffix": suffix} if suffix else {})} # A stream suffix is storage evidence, not proof of a Configurator-tree # role. In particular, associating every ``.2`` stream with a Command # invented a command path for extension reports without decoding a Command # child object. Promote these roles only through metadata.object.commands # (or another owner-proven decoder). if suffix in {"2", "3"}: return { "kind": "bsl_module", "name": "Модуль BSL с неподтвержденной ролью", "role_status": "unconfirmed", "configurator_path_status": "unconfirmed", "suffix": suffix, } if suffix == "0": if kind in REGISTER_MODULE_OWNER_KINDS: return {"kind": "record_set_module", "name": "Модуль набора записей", "suffix": suffix} if kind == "Constant": return {"kind": "value_manager_module", "name": "Модуль менеджера значения", "suffix": suffix} if kind == "DocumentJournal": return {"kind": "manager_module", "name": "Модуль менеджера", "suffix": suffix} if kind == "CommonForm": return {"kind": "form_module", "name": "Модуль формы", "suffix": suffix} if kind == "CommonModule": return {"kind": "common_module", "name": current_name or "Общий модуль", "suffix": suffix} if kind in OBJECT_MODULE_OWNER_KINDS or not kind: return {"kind": "object_module", "name": "Модуль объекта", "suffix": suffix} if kind == "CommonModule": return {"kind": "common_module", "name": current_name or "Общий модуль"} if kind == "CommonForm": return {"kind": "form_module", "name": "Модуль формы"} if kind in REGISTER_MODULE_OWNER_KINDS and ordinal in {None, 1}: return {"kind": "record_set_module", "name": "Модуль набора записей"} if kind in REGISTER_MODULE_OWNER_KINDS: display_name = "Модуль регистра" if ordinal in {None, 1} else f"Модуль регистра {ordinal}" return {"kind": "register_module", "name": display_name} if kind == "Constant" and ordinal in {None, 1}: return {"kind": "value_manager_module", "name": "Модуль менеджера значения"} if kind == "DocumentJournal" and ordinal in {None, 1}: return {"kind": "manager_module", "name": "Модуль менеджера"} display_name = "Модуль объекта" if ordinal in {None, 1} else f"Модуль объекта {ordinal}" return {"kind": "object_module", "name": display_name} def public_module_row( module: dict[str, Any], *, include_storage: bool = False, ordinal: int | None = None, owner_kind: str | None = None, owner_name: str | None = None, ) -> dict[str, Any]: public = dict(module) kind = canonical_kind(str(owner_kind or "")) is_form_module = str(public.get("kind") or "") == "form_module" or isinstance(public.get("form"), dict) role = ( {"kind": "form_module", "name": "Модуль формы"} if is_form_module else public_module_role( owner_kind=kind, suffix=str(public.get("suffix") or module_suffix_from_module_id(public.get("module_id")) or ""), ordinal=ordinal, # A common module is addressed in 1C code by the metadata object name. # The raw row name is a Config/ConfigCAS stream coordinate and must # never become its public name. current_name=str(owner_name or "") if kind == "CommonModule" else None, ) ) original_name = public.get("name") public["name"] = role.get("name") public["kind"] = role.get("kind") for evidence_key in ("role_status", "configurator_path_status"): if role.get(evidence_key): public[evidence_key] = role[evidence_key] if ordinal is not None: public["module_ordinal"] = ordinal if include_storage and original_name and original_name != public.get("name"): public["storage_name"] = original_name if not include_storage: for key in ( "module_id", "source", "table", "file_name", "suffix", "stream_index", "bytes", "sha1", "encoding", "payload_role", "storage_name", ): public.pop(key, None) return public def public_code_qualified_name( *, owner: dict[str, Any] | None = None, form: dict[str, Any] | None = None, module: dict[str, Any] | None = None, ) -> str | None: owner_name = str((owner or {}).get("name") or "").strip() form_name = str((form or {}).get("name") or "").strip() module_name = str((module or {}).get("name") or "").strip() if ( str((module or {}).get("kind") or "").strip() == "common_module" and owner_name and normalize(module_name) == normalize(owner_name) ): return owner_name parts = [part for part in (owner_name, form_name, module_name) if part] return ".".join(parts) if parts else None def public_module_with_qualified_name( module: dict[str, Any], *, owner: dict[str, Any] | None = None, include_storage: bool = False, ordinal: int | None = None, owner_kind: str | None = None, ) -> dict[str, Any]: public = public_module_row( module, include_storage=include_storage, ordinal=ordinal, owner_kind=owner_kind or ((owner or {}).get("kind") if isinstance(owner, dict) else None), owner_name=(owner or {}).get("name") if isinstance(owner, dict) else None, ) form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} if form_payload: qualified_name = ".".join( part for part in [ (owner or {}).get("name"), "Форма", form_payload.get("name"), public.get("name"), ] if part ) public["form"] = { key: form_payload.get(key) for key in ("guid", "name", "synonym") if form_payload.get(key) not in {None, ""} } else: qualified_name = public_code_qualified_name(owner=owner, module=public) if qualified_name: public["qualified_name"] = qualified_name public["display_name"] = qualified_name return public def template_type_key(value: Any) -> str: return re.sub(r"[\s_\-]+", "", str(value or "").casefold()) def normalize_onec_template_platform_type(value: Any) -> dict[str, Any] | None: key = template_type_key(value) if not key: return None for item in ONEC_TEMPLATE_PLATFORM_TYPES: values = [item.get("id"), item.get("name"), item.get("xml_type"), *(item.get("aliases") or [])] if key in {template_type_key(candidate) for candidate in values}: return dict(item) return None def onec_template_type_by_id(type_id: str) -> dict[str, Any]: return normalize_onec_template_platform_type(type_id) or {"id": type_id, "name": type_id} def declared_template_platform_type_from_part(part: dict[str, Any]) -> dict[str, Any] | None: candidates: list[Any] = [ part.get("template_type_id"), part.get("template_type"), part.get("platform_type_id"), part.get("platform_type"), ] classification = part.get("classification") if isinstance(part.get("classification"), dict) else {} candidates.extend( [ classification.get("template_type_id"), classification.get("template_type"), classification.get("platform_type_id"), classification.get("platform_type"), ] ) candidates.extend(classification.get("strings_sample") or []) for candidate in candidates: template_type = normalize_onec_template_platform_type(candidate) if template_type: return template_type return None def template_descriptor_type_code_from_part(part: dict[str, Any]) -> str | None: """Return an unlabelled type code from the proven Template descriptor shape. The code is useful evidence, but its numeric-to-TemplateType mapping is intentionally not guessed from the payload carrier or a template name. """ if str(part.get("role") or "") != "metadata_payload": return None classification = part.get("classification") if isinstance(part.get("classification"), dict) else {} tree = classification.get("tree") if not isinstance(tree, dict): return None if config_tree_scalar_at_path(tree, (0,)) != "1" or config_tree_scalar_at_path(tree, (1, 0)) != "4": return None value = config_tree_scalar_at_path(tree, (1, 2)) return value if value and value.isdecimal() else None def template_type_candidates_from_features(features: dict[str, Any], content_parts: list[dict[str, Any]]) -> list[dict[str, Any]]: candidates: list[dict[str, Any]] = [] declared = normalize_onec_template_platform_type(features.get("declared_platform_type_id") or features.get("declared_platform_type")) if declared: declared.update({"confidence": "high", "evidence": [str(features.get("declared_platform_type_evidence") or "TemplateType metadata")]}) candidates.append(declared) def append_candidate(candidate: dict[str, Any]) -> None: if any(item.get("id") == candidate.get("id") for item in candidates): return candidates.append(candidate) if features.get("tabular_document"): candidate = onec_template_type_by_id("tabular_document") candidate.update({"confidence": "high", "evidence": ["MOXCEL marker"]}) append_candidate(candidate) if features.get("html"): candidate = onec_template_type_by_id("html_document") candidate.update({"confidence": "medium", "evidence": ["HTML marker in payload preview"]}) append_candidate(candidate) if features.get("bsl"): candidate = onec_template_type_by_id("text_document") candidate.update({"confidence": "low", "evidence": ["text payload with BSL markers"]}) append_candidate(candidate) has_content = bool(content_parts) if has_content and not candidates: candidate = onec_template_type_by_id("binary_data") candidate.update({"confidence": "low", "evidence": ["content payload without known text/tabular markers"]}) append_candidate(candidate) return candidates def public_template_summary( parts: list[dict[str, Any],], *, include_storage: bool = False, confirmed_platform_type_id: str | None = None, ) -> dict[str, Any]: content_parts = [part for part in parts if part.get("role") != "metadata_payload"] declared_platform_type = next( (template_type for template_type in (declared_template_platform_type_from_part(part) for part in parts) if template_type), None, ) # A payload carrier (for example XML root AppearanceTemplate) selects a # safe reader, but is not the 1C TemplateType. The latter is determined # only by a decoded template descriptor / confirmed template identity. declared_type_evidence = "TemplateType metadata" xml_roots = sorted( { str((part.get("classification") or {}).get("xml_root")) for part in parts if isinstance(part.get("classification"), dict) and (part.get("classification") or {}).get("xml_root") } ) descriptor_type_codes = sorted( { code for code in ( str(part.get("descriptor_type_code")) if str(part.get("descriptor_type_code") or "").isdecimal() else template_descriptor_type_code_from_part(part) for part in parts ) if code is not None }, key=int, ) features = { "tabular_document": any(((part.get("features") or {}).get("tabular_document")) for part in content_parts), "html": any(((part.get("features") or {}).get("html")) for part in content_parts), "bsl": any(((part.get("features") or {}).get("bsl")) for part in content_parts), **( { "declared_platform_type": declared_platform_type.get("name"), "declared_platform_type_id": declared_platform_type.get("id"), "declared_platform_xml_type": declared_platform_type.get("xml_type"), "declared_platform_type_evidence": declared_type_evidence, } if declared_platform_type else {} ), } template_type_candidates = template_type_candidates_from_features(features, content_parts) confirmed_platform_type = onec_template_type_by_id(confirmed_platform_type_id) if confirmed_platform_type_id else None if confirmed_platform_type and confirmed_platform_type.get("id") != confirmed_platform_type_id: confirmed_platform_type = None if confirmed_platform_type: # This classification comes from the report's decoded Template record, # not a marker guess in the compressed content stream. In particular, # an SCD stream has no MOXCEL/HTML marker and must not be presented as # a low-confidence binary template merely because it is compressed. confirmed_platform_type.update({"confidence": "high", "evidence": ["confirmed Template identity"]}) template_type_candidates = [confirmed_platform_type] + [ candidate for candidate in template_type_candidates if candidate.get("id") != confirmed_platform_type.get("id") ] summary: dict[str, Any] = { "features": features, "known_platform_types": ONEC_TEMPLATE_PLATFORM_TYPES, "template_type_candidates": template_type_candidates, "counts": {"parts": len(parts), "content_parts": len(content_parts)}, } if xml_roots: summary["content_type_evidence"] = { "xml_roots": xml_roots, "meaning": "payload_carrier_only_not_platform_template_type", } if descriptor_type_codes and not declared_platform_type and not confirmed_platform_type_id: summary["descriptor_type_evidence"] = { "codes": descriptor_type_codes, "status": "unresolved_mapping", "meaning": "Template descriptor type code; numeric mapping to 1C TemplateType is not yet fixture-proven.", } if confirmed_platform_type: summary["format"] = confirmed_platform_type.get("xml_type") or confirmed_platform_type.get("id") summary["platform_type"] = confirmed_platform_type.get("name") elif features.get("tabular_document"): summary["format"] = "ТабличныйДокумент" summary["platform_type"] = "Табличный документ" summary["preview"] = { "status": "preview_not_supported", "message": "Макет распознан как табличный документ; безопасный просмотр содержимого пока не поддержан.", } elif features.get("html"): summary["format"] = "HTML" summary["platform_type"] = "HTML документ" elif features.get("bsl"): summary["format"] = "ТекстМодуля" summary["platform_type"] = "Текстовый документ" else: summary["format"] = "НеОпределено" summary["platform_type"] = template_type_candidates[0].get("name") if template_type_candidates else "НеОпределено" if include_storage: summary["parts"] = parts summary["content"] = { "roles": sorted({str(part.get("role") or "") for part in content_parts if part.get("role")}), "kinds": sorted({str(part.get("content_kind") or "") for part in content_parts if part.get("content_kind")}), } return summary def module_profile( base_id: str, module: dict[str, Any], *, include_text: bool = False, include_storage: bool = False, display_name: str | None = None, timeout_seconds: int = 60, ) -> dict[str, Any]: module_id = str(module.get("module_id") or "") result = read_module({"base_id": base_id, "module_id": module_id, "include_text": True, "timeout_seconds": timeout_seconds}) if result.get("status") != "ok": profile = { "status": result.get("status"), "diagnostics": result.get("diagnostics"), } if include_storage: profile["module_id"] = module_id return profile text = str(result.get("text") or "") try: from parser.bsl_validation import routine_blocks, validate_bsl_text except Exception as exc: profile = { "status": "error", "diagnostics": {"message": f"BSL validator is unavailable: {exc}"}, } if include_storage: profile["module_id"] = module_id return profile routines = [ { "kind": block.get("kind"), "name": block.get("name"), "line_start": block.get("line_start"), "line_end": block.get("line_end"), } for block in routine_blocks(text) ] profile: dict[str, Any] = { "status": "ok", "kind": module.get("kind"), "name": display_name or module.get("name"), "validation": validate_bsl_text(text), "routines": routines, "counts": { "routines": len(routines), "procedures": sum(1 for item in routines if str(item.get("kind") or "").casefold() == "процедура"), "functions": sum(1 for item in routines if str(item.get("kind") or "").casefold() == "функция"), "lines": len(text.replace("\r\n", "\n").replace("\r", "\n").split("\n")) if text else 0, }, } if include_storage: profile.update( { "module_id": module_id, "bytes": module.get("bytes"), "sha1": module.get("sha1") or (((result.get("payload") or {}).get("stream") or {}).get("sha1")), "encoding": module.get("encoding"), } ) profile["completeness"] = "complete" if profile["validation"].get("status") == "ok" else "fragment_or_invalid" if profile["completeness"] != "complete": profile["diagnostics"] = { "message": "The stream contains BSL markers but does not pass full-module structural validation. Treat it as a fragment or invalid module text.", } if include_text: profile["text"] = text else: profile["text_preview"] = text[:1000] return profile def public_form_row(form: dict[str, Any], *, include_storage: bool = False) -> dict[str, Any]: public = dict(form) if not include_storage: public.pop("source", None) parts = [] for part in public.get("parts") or []: if not isinstance(part, dict): parts.append(part) continue public_part = dict(part) if not include_storage: for key in ("part_id", "source", "suffix", "sha1"): public_part.pop(key, None) parts.append(public_part) public["parts"] = parts return public def public_form_profile(profile: dict[str, Any], *, include_storage: bool = False) -> dict[str, Any]: if include_storage: return profile return strip_storage_traces(profile) def form_profile_capabilities(profile: dict[str, Any]) -> dict[str, Any]: counts = profile.get("counts") if isinstance(profile.get("counts"), dict) else {} module = profile.get("module") if isinstance(profile.get("module"), dict) else {} return { "elements": int(counts.get("items_total") or counts.get("items") or 0) > 0, "attributes": int(counts.get("attributes_total") or counts.get("attributes") or 0) > 0, "commands": int(counts.get("commands_total") or counts.get("commands") or 0) > 0, "tables": int(counts.get("tables_total") or counts.get("tables") or 0) > 0, "command_bars": int(counts.get("command_bars_total") or counts.get("command_bars") or 0) > 0, "events": int(counts.get("events") or 0) > 0, "handler_links": int(counts.get("handler_links") or 0) > 0, "button_command_links": int(counts.get("button_command_links") or 0) > 0, "module": int(module.get("routine_count") or counts.get("module_routines") or 0) > 0, } def form_profile_properties(profile: dict[str, Any]) -> dict[str, Any]: counts = profile.get("counts") if isinstance(profile.get("counts"), dict) else {} form_semantic = profile.get("form_semantic") if isinstance(profile.get("form_semantic"), dict) else {} return { "semantic": form_semantic.get("groups") or {}, "elements": counts.get("items"), "elements_total": counts.get("items_total"), "attributes": counts.get("attributes"), "attributes_total": counts.get("attributes_total"), "commands": counts.get("commands"), "commands_total": counts.get("commands_total"), "tables": counts.get("tables"), "tables_total": counts.get("tables_total"), "command_bars": counts.get("command_bars"), "events": counts.get("events"), "module_routines": counts.get("module_routines"), "handler_links": counts.get("handler_links"), "resolved_handlers": counts.get("resolved_handlers"), "missing_handlers": counts.get("missing_handlers"), "button_command_links": counts.get("button_command_links"), "truncated": { "elements": bool(counts.get("items_truncated")), "attributes": bool(counts.get("attributes_truncated")), "commands": bool(counts.get("commands_truncated")), "tables": bool(counts.get("tables_truncated")), "command_bars": bool(counts.get("command_bars_truncated")), }, } def form_public_commands(profile: dict[str, Any]) -> list[dict[str, Any]]: command_links_by_name = { normalize(str(link.get("command") or "")): str(link.get("handler") or "").strip() for link in (profile.get("command_links") or []) if isinstance(link, dict) and str(link.get("command") or "").strip() and str(link.get("handler") or "").strip() } commands: list[dict[str, Any]] = [] for command in profile.get("commands") or []: if not isinstance(command, dict): continue public = dict(command) command_name = str(public.get("name") or "").strip() action = str(public.get("action") or public.get("handler") or "").strip() if not action: action = command_links_by_name.get(normalize(command_name), "") if not action: semantic = public.get("semantic") if isinstance(public.get("semantic"), dict) else {} groups = semantic.get("groups") if isinstance(semantic.get("groups"), dict) else {} for values in groups.values(): if not isinstance(values, list): continue for item in values: if not isinstance(item, dict): continue if normalize(str(item.get("name") or "")) == "action" and str(item.get("value") or "").strip(): action = str(item.get("value") or "").strip() break if action: break if action: public["action"] = action public.setdefault("handler", action) commands.append(public) return commands def form_public_sections(profile: dict[str, Any]) -> dict[str, Any]: return { "elements": profile.get("items") or [], "attributes": profile.get("attributes") or [], "parameters": profile.get("parameters") or [], "commands": form_public_commands(profile), "events": profile.get("events") or [], "handler_links": profile.get("handler_links") or [], "command_links": profile.get("command_links") or [], "button_command_links": profile.get("button_command_links") or [], "module": profile.get("module") or {"status": "not_found"}, } def form_static_structure(profile: dict[str, Any]) -> dict[str, Any]: """Compact static form projection without manufacturing a logical parent tree. Payload paths describe serialized nodes, not necessarily the parent-child relationship shown by the 1C form editor. Until that mapping is proven, expose each decoded element and mark the unavailable edge explicitly. """ elements: list[dict[str, Any]] = [] for item in profile.get("items") or []: if not isinstance(item, dict): continue element = { key: item.get(key) for key in ("id", "name", "type_name", "path", "depth", "title", "data_path") if item.get(key) is not None } if isinstance(item.get("semantic"), dict): element["semantic"] = item["semantic"] element["parent"] = {"status": "unresolved", "reason": "logical_form_parent_codec_unproven"} element["children"] = {"status": "unresolved", "reason": "logical_form_parent_codec_unproven"} elements.append(element) return { "schema": "onec_form_static_structure.v1", "status": "partial" if elements else "not_found", "elements": elements, "capabilities": {"logical_parent_tree": False}, "diagnostics": { "message": "Static element facts are decoded; logical parent/child links are unresolved until their SQL codec is proven.", }, } def form_element_filter_from_payload(payload: dict[str, Any]) -> dict[str, Any]: return { "element": payload.get("element") or payload.get("element_name"), "element_path": payload.get("element_path") or payload.get("path"), "element_id": payload.get("element_id") or payload.get("id"), } def form_element_matches(item: dict[str, Any], selector: dict[str, Any]) -> tuple[bool, str | None]: element = selector.get("element") element_path = selector.get("element_path") element_id = selector.get("element_id") if element_path not in {None, ""} and str(item.get("path") or "") == str(element_path): return True, "path_exact" if element_id not in {None, ""} and normalize_exact(item.get("id")) == normalize_exact(element_id): return True, "id_exact" if element not in {None, ""}: if normalize_exact(item.get("name")) == normalize_exact(element): return True, "name_exact" if normalize_exact(item.get("title")) == normalize_exact(element): return True, "title_exact" if normalize(item.get("name")) == normalize(element): return True, "name_normalized" if normalize(item.get("title")) == normalize(element): return True, "title_normalized" return False, None def apply_form_element_filter(profile: dict[str, Any], selector: dict[str, Any]) -> dict[str, Any]: if not any(selector.get(key) not in {None, ""} for key in ("element", "element_path", "element_id")): return profile filtered = dict(profile) total_matches = 0 for section in ("items", "commands", "attributes", "tables", "command_bars"): rows = [item for item in profile.get(section) or [] if isinstance(item, dict)] matches: list[dict[str, Any]] = [] for item in rows: matched, match_by = form_element_matches(item, selector) if matched: row = dict(item) row["match_by"] = match_by matches.append(row) filtered[section] = matches total_matches += len(matches) counts = dict(profile.get("counts") or {}) counts["items"] = len(filtered.get("items") or []) counts["commands"] = len(filtered.get("commands") or []) counts["attributes"] = len(filtered.get("attributes") or []) counts["tables"] = len(filtered.get("tables") or []) counts["command_bars"] = len(filtered.get("command_bars") or []) counts["focused_elements"] = total_matches filtered["counts"] = counts filtered["element_filter"] = {key: value for key, value in selector.items() if value not in {None, ""}} if not total_matches: filtered["diagnostics"] = { "message": "Элемент/команда/атрибут формы по заданному имени, path или id не найден в декодированном профиле формы.", } return filtered def payload_public_properties(classification: dict[str, Any]) -> dict[str, Any]: role = str(classification.get("role") or "unknown") markers = [str(value) for value in classification.get("markers") or []] counts = classification.get("counts") or {} root = classification.get("root") or {} content_kind = { "metadata_payload": "metadata", "form_payload": "form", "template_payload": "template", "help_or_html_payload": "html_or_help", "bsl_module_payload": "bsl_module", "stream_container": "stream_container", "brace_payload": "brace_payload", "binary_or_unknown_payload": "binary_or_unknown", }.get(role, role) public: dict[str, Any] = { "role": role, "content_kind": content_kind, "root_marker": root.get("root_marker") if isinstance(root, dict) else None, "markers": markers, "counts": { "stream_blocks": counts.get("stream_blocks") or 0, "base64_blocks": counts.get("base64_blocks") or 0, }, "features": { "tabular_document": "MOXCEL" in markers, "html": any(block.get("has_html_marker") for block in classification.get("base64_blocks") or []), "bsl": any(block.get("has_bsl_marker") for block in classification.get("stream_blocks") or []), "streams": int(counts.get("stream_blocks") or 0) > 0, "base64": int(counts.get("base64_blocks") or 0) > 0, }, } return public def payload_public_preview(classification: dict[str, Any], *, include_text_preview: bool = True) -> dict[str, Any]: markers = [str(value) for value in classification.get("markers") or []] counts = classification.get("counts") or {} preview: dict[str, Any] = { "streams": [], "base64": [], } if include_text_preview: preview["streams"] = [ { "encoding": block.get("encoding"), "text_preview": block.get("text_preview"), "has_bsl_marker": bool(block.get("has_bsl_marker")), "has_html_marker": bool(block.get("has_html_marker")), } for block in classification.get("stream_blocks") or [] if block.get("text_preview") or block.get("has_bsl_marker") or block.get("has_html_marker") ][:20] preview["base64"] = [ { "encoding": block.get("encoding"), "text_preview": block.get("text_preview"), "has_bsl_marker": bool(block.get("has_bsl_marker")), "has_html_marker": bool(block.get("has_html_marker")), } for block in classification.get("base64_blocks") or [] if block.get("text_preview") or block.get("has_bsl_marker") or block.get("has_html_marker") ][:20] if "MOXCEL" in markers: preview["tabular_document"] = { "status": "preview_not_supported" if not preview["streams"] and not preview["base64"] else "partial", "format": "MOXCEL", "markers": markers, "structure": { "root_marker": (classification.get("root") or {}).get("root_marker") if isinstance(classification.get("root"), dict) else None, "stream_blocks": counts.get("stream_blocks") or 0, "base64_blocks": counts.get("base64_blocks") or 0, "strings_sample": (classification.get("strings_sample") or [])[:20], }, "diagnostics": { "message": "Макет распознан как табличный документ MOXCEL. Текст/области из бинарного табличного документа пока не извлекаются публичным preview." }, } return preview def payload_public_undecoded_evidence( classification: dict[str, Any], *, include_text_preview: bool = True, mode: str = "summary", allow_storage_details: bool = False, max_strings: int = 20, max_blocks: int = 12, max_excerpt_chars: int = 1200, ) -> dict[str, Any]: if not isinstance(classification, dict): return {} mode = str(mode or "summary").casefold() if mode == "none": return {} if mode == "full": max_strings = max(max_strings, 80) max_blocks = max(max_blocks, 50) max_excerpt_chars = max(max_excerpt_chars, 6000) include_text_preview = True elif mode == "raw": max_strings = max(max_strings, 200) max_blocks = max(max_blocks, 100) max_excerpt_chars = max(max_excerpt_chars, 20000) include_text_preview = True def public_block_samples(blocks: Any) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for block in blocks or []: if not isinstance(block, dict): continue item = { "encoding": block.get("encoding"), "has_bsl_marker": bool(block.get("has_bsl_marker")), "has_html_marker": bool(block.get("has_html_marker")), } if mode == "raw" and allow_storage_details: for key in ("header_offset", "header_end", "data_offset", "data_end", "block_length", "declared_1", "declared_2", "bytes", "sha1"): if key in block: item[key] = block.get(key) if include_text_preview and block.get("text_preview"): text_preview = str(block.get("text_preview") or "") item["text_preview"] = text_preview[:max_excerpt_chars] if mode in {"full", "raw"} and include_text_preview and isinstance(block.get("text"), str): item["text_excerpt"] = str(block.get("text") or "")[:max_excerpt_chars] items.append(item) if len(items) >= max_blocks: break return items evidence: dict[str, Any] = { "status": classification.get("status"), "role": classification.get("role"), "payload": { "compression": classification.get("compression"), "encoding": classification.get("encoding"), "raw_bytes": classification.get("raw_bytes"), "payload_bytes": classification.get("payload_bytes"), "sha1": classification.get("sha1"), "payload_sha1": classification.get("payload_sha1"), }, "root": classification.get("root"), "markers": list(classification.get("markers") or [])[:max_strings], "strings_sample": list(classification.get("strings_sample") or [])[:max_strings], "counts": dict(classification.get("counts") or {}), "stream_blocks_sample": public_block_samples(classification.get("stream_blocks")), "base64_blocks_sample": public_block_samples(classification.get("base64_blocks")), } text = classification.get("text") if include_text_preview and isinstance(text, str) and text: evidence["text_excerpt"] = text[:max_excerpt_chars] if mode == "raw" and allow_storage_details and classification.get("tree") is not None: evidence["tree"] = classification.get("tree") return evidence def public_child_identity(item: dict[str, Any]) -> dict[str, Any]: identity = item.get("identity") if isinstance(item.get("identity"), dict) else {} if not identity and isinstance(item.get("record_identity"), dict): identity = item.get("record_identity") or {} synonyms = identity.get("synonyms") if isinstance(identity, dict) else None return { "guid": identity.get("guid") or item.get("guid"), "name": identity.get("name"), "synonym": next(iter(synonyms.values()), None) if isinstance(synonyms, dict) and synonyms else None, "status": item.get("status"), } PUBLIC_PAYLOAD_ROLE_NAMES = { "metadata_payload": "Метаданные объекта", "bsl_module_payload": "Модуль", "form_payload": "Форма", "template_payload": "Макет", "help_or_html_payload": "Справка/HTML", "command_payload": "Команда", "unknown": "Не определено", } def public_payload_role(role: Any) -> str: role_text = str(role or "unknown") return PUBLIC_PAYLOAD_ROLE_NAMES.get(role_text, role_text) PUBLIC_FORBIDDEN_KEYS = { "path", "evidence_path", "event_path", "command_path", "button_path", "strings_sample", "guids_sample", "type_guid", "resolved", "storage_routes", "file_name", "source_file", "module_id", "raw_bytes", "payload", "root", "root_marker", "markers", "command_guid", "_history_evidence", "text_preview", } PUBLIC_ALLOWED_PATH_KEYS = { "canonical_path", "context_path", # Logical 1C form binding, for example `Объект.ДатаПлана`; this is not a # physical SQL route and is required to explain field lineage. "data_path", "form_path", "input_path", "safe_as_metadata_path", } def sanitize_public_result(value: Any) -> Any: if isinstance(value, dict): sanitized = { key: sanitize_public_result(item) for key, item in value.items() if key not in PUBLIC_FORBIDDEN_KEYS and (not str(key).endswith("_path") or str(key) in PUBLIC_ALLOWED_PATH_KEYS) } if "kind" in sanitized and "presentation" in sanitized: sanitized.pop("code", None) if sanitized.get("source") == "platform_standard_field": sanitized.pop("source", None) return sanitized if isinstance(value, list): return [sanitize_public_result(item) for item in value] return value def tree_ordered_strings(tree: Any, *, limit: int = 500) -> list[str]: try: from parser.payload import collect_strings except Exception: return [] seen: set[str] = set() result = [] for value in collect_strings(tree, limit=limit): text = str(value or "") if not text or text in seen: continue seen.add(text) result.append(text) return result def tree_ordered_scalars(tree: Any, *, limit: int = 1000) -> list[str]: try: from parser.payload import scalar except Exception: return [] values: list[str] = [] def children(node: Any) -> list[Any]: if isinstance(node, dict) and node.get("type") in {"list", "sequence"}: return node.get("items") or [] return [] def walk(node: Any) -> None: if len(values) >= limit: return text = scalar(node) if text not in {None, ""}: values.append(str(text)) return for child in children(node): walk(child) walk(tree) return values def public_pattern_type_from_tree(tree: Any, resolved_types: dict[str, dict[str, Any]] | None = None, *, raw: bool = False) -> dict[str, Any] | None: values = tree_ordered_scalars(tree) try: index = values.index("Pattern") except ValueError: return None if index + 1 >= len(values): return None code = values[index + 1] result: dict[str, Any] = {"code": code} if code == "D": result.update({"kind": "date", "presentation": "Дата"}) elif code == "B": result.update({"kind": "boolean", "presentation": "Булево"}) elif code == "S": result.update({"kind": "string", "presentation": "Строка"}) if index + 2 < len(values): try: length = int(values[index + 2]) result["length"] = length if length > 0: result["presentation"] = f"Строка({length})" except ValueError: pass elif code == "N": result.update({"kind": "number", "presentation": "Число"}) if index + 2 < len(values): try: result["precision"] = int(values[index + 2]) except ValueError: pass if index + 3 < len(values): try: result["scale"] = int(values[index + 3]) except ValueError: pass if "precision" in result: scale = result.get("scale") result["presentation"] = f"Число({result['precision']}, {scale})" if scale is not None else f"Число({result['precision']})" elif code == "#": result.update({"kind": "reference", "presentation": "Ссылка"}) if index + 2 < len(values) and is_guid_text(values[index + 2]): result["type_guid"] = values[index + 2].lower() elif code == "R": result.update({"kind": "binary", "presentation": "ДвоичныеДанные"}) else: result.update({"kind": "unknown", "presentation": code}) if raw: return result return public_type_info(result, resolved_types or {}, include_storage=False) def scheduled_job_method_name(tree: Any, identity: dict[str, Any] | None) -> str | None: values = tree_ordered_scalars(tree) for index, value in enumerate(values[:-1]): if not is_guid_text(value) or value == "00000000-0000-0000-0000-000000000000": continue candidate = values[index + 1] next_values = values[index + 2 : index + 5] schedule_numbers = [item for item in next_values if re.fullmatch(r"-?\d+", item or "")] has_schedule_numbers = len(schedule_numbers) >= 2 if re.fullmatch(r"[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*", candidate or "") and len(candidate) > 3 and has_schedule_numbers: return candidate return None def scheduled_job_schedule_parameters(tree: Any, method_name: str | None) -> dict[str, Any]: values = tree_ordered_scalars(tree) if method_name: indexes = [index for index, value in enumerate(values) if value == method_name] else: indexes = [] for index in indexes: numeric = [] for value in values[index + 1 : index + 8]: if re.fullmatch(r"-?\d+", value or ""): numeric.append(int(value)) elif numeric: break if len(numeric) >= 2: result: dict[str, Any] = { "status": "partial", "interval_seconds": numeric[1], } if len(numeric) >= 3: result["offset_seconds"] = numeric[2] result["periodicity"] = { "presentation": { 0: "Не задана", 1: "Однократно", 2: "Ежедневно", 3: "Повторять с интервалом", 4: "Еженедельно", 5: "Ежемесячно", }.get(numeric[0], "Неизвестная периодичность"), } result["repeat"] = { "interval_seconds": numeric[1], "interval_presentation": format_seconds_ru(numeric[1]), **({"offset_seconds": numeric[2], "offset_presentation": format_seconds_ru(numeric[2])} if len(numeric) >= 3 else {}), } missing_note = "Поле не найдено в текущем декодированном payload расписания." result["activity"] = {"status": "not_found_in_decoded_metadata", "diagnostics": {"message": missing_note}} result["day_restrictions"] = {"status": "not_found_in_decoded_metadata", "days_of_week": [], "days_of_month": [], "diagnostics": {"message": missing_note}} result["time_window"] = {"status": "not_found_in_decoded_metadata", "start_time": None, "end_time": None, "diagnostics": {"message": missing_note}} result["date_window"] = {"status": "not_found_in_decoded_metadata", "start_date": None, "end_date": None, "diagnostics": {"message": missing_note}} result["kind"] = {"status": "not_found_in_decoded_metadata", "predefined": None, "user_defined": None, "diagnostics": {"message": missing_note}} return result return {"status": "not_decoded_yet"} def format_seconds_ru(seconds: int) -> str: if seconds == 0: return "0 секунд" parts = [] days, rem = divmod(abs(seconds), 86400) hours, rem = divmod(rem, 3600) minutes, secs = divmod(rem, 60) if days: parts.append(f"{days} дн.") if hours: parts.append(f"{hours} ч.") if minutes: parts.append(f"{minutes} мин.") if secs or not parts: parts.append(f"{secs} сек.") return ("-" if seconds < 0 else "") + " ".join(parts) def document_journal_document_types( base_id: str, tree: Any, *, dbnames_records: list[Any] | None = None, timeout_seconds: int = 60, table: str = "Config", ) -> list[dict[str, Any]]: values = [value.lower() for value in tree_ordered_scalars(tree, limit=2000) if is_guid_text(value)] records = dbnames_records if records is None: records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: return [] document_guids = { str(getattr(record, "guid", "") or "").lower() for record in records or [] if DBNAMES_ROLE_KIND.get(str(getattr(record, "storage_role", "") or "")) == "Document" } result = [] seen: set[str] = set() for guid in values: if guid in seen or guid not in document_guids: continue identity, _ = live_config_identity(base_id, guid, timeout_seconds=timeout_seconds, table=table) result.append( { "guid": guid, "kind": "Document", "name": (identity or {}).get("name"), "synonym": next(iter(((identity or {}).get("synonyms") or {}).values()), None) if isinstance((identity or {}).get("synonyms"), dict) else None, } ) seen.add(guid) return result def document_journal_column_title(node: Any) -> tuple[str | None, str | None]: values = tree_ordered_scalars(node, limit=120) controls = {"#", "Pattern", "B", "U", "S", "N", "D", "ru", *[str(index) for index in range(20)]} name = next((value for value in values if value not in controls and not is_guid_text(value)), None) synonym = None for index, value in enumerate(values[:-1]): if value != "ru": continue candidate = values[index + 1] if candidate and candidate not in controls and not is_guid_text(candidate): synonym = candidate break return name, synonym def document_journal_record_containers(tree: Any) -> list[list[Any]]: try: from parser.child_records import declared_child_records from parser.payload import get_tree_path except Exception: return [] containers: list[list[Any]] = [] try: records = declared_child_records(get_tree_path(tree, "4"), "4") if records: containers.append(records) except Exception: pass def children(node: Any) -> list[Any]: if isinstance(node, dict) and node.get("type") in {"list", "sequence"}: return node.get("items") or [] return [] def walk(node: Any, path: str, depth: int) -> None: if depth > 4: return try: records = declared_child_records(node, path) except Exception: records = [] if records: containers.append(records) for index, child in enumerate(children(node)): walk(child, f"{path}.{index}" if path else str(index), depth + 1) walk(tree, "", 0) return containers def document_journal_field_type_map( base_id: str, document_types: list[dict[str, Any]], *, dbnames_records: list[Any] | None = None, timeout_seconds: int = 60, table: str = "Config", ) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} records = dbnames_records if records is None: records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: records = [] for document in document_types: guid = str(document.get("guid") or "").lower() if not guid: continue data, _, read_error = read_storage_file_bytes(base_id, table, guid, timeout_seconds=timeout_seconds) if read_error: continue decoded = decode_config_object_full(data, kind="Document", dbnames_records=records, max_depth=3) semantic = decoded.get("semantic") if decoded.get("status") == "ok" else None sections = (semantic or {}).get("sections") or [] type_guids = collect_reference_type_guids_from_sections(sections) resolved_types = resolve_type_guids(base_id, type_guids, timeout_seconds=timeout_seconds, table=table) for section in sections: if section.get("category") != "Attribute": continue for attribute in section.get("records") or []: identity = attribute.get("identity") if isinstance(attribute.get("identity"), dict) else {} attribute_guid = str(identity.get("guid") or "").lower() if not attribute_guid or not attribute.get("type"): continue result[attribute_guid] = sanitize_public_result( public_type_info(attribute.get("type"), resolved_types, include_storage=False) ) return result def document_journal_column_type(record_node: Any, field_types: dict[str, dict[str, Any]]) -> dict[str, Any] | None: if not field_types: return None values = [value.lower() for value in tree_ordered_scalars(record_node, limit=500) if is_guid_text(value)] types = [] seen: set[str] = set() for guid in values: field_type = field_types.get(guid) if not field_type: continue key = json.dumps(field_type, ensure_ascii=False, sort_keys=True) if key in seen: continue seen.add(key) types.append(field_type) if not types: return None if len(types) == 1: return types[0] return { "kind": "composite", "presentation": "Составной тип", "types": types, } def document_journal_column_field_guids(record_node: Any) -> set[str]: ignored = {"00000000-0000-0000-0000-000000000000", "157fa490-4ce9-11d4-9415-008048da11f9"} return { value.lower() for value in tree_ordered_scalars(record_node, limit=800) if is_guid_text(value) and value.lower() not in ignored } def document_journal_all_column_field_guids(tree: Any) -> set[str]: result: set[str] = set() for records in document_journal_record_containers(tree): for record in records: node = getattr(record, "node", None) name, _ = document_journal_column_title(node) if name: result.update(document_journal_column_field_guids(node)) return result def document_journal_columns( base_id: str, tree: Any, *, document_types: list[dict[str, Any]] | None = None, dbnames_records: list[Any] | None = None, include_column_types: bool = False, timeout_seconds: int = 60, max_columns: int | None = None, table: str = "Config", ) -> list[dict[str, Any]]: field_types = ( document_journal_field_type_map( base_id, document_types or [], table=table, dbnames_records=dbnames_records, timeout_seconds=timeout_seconds, ) if include_column_types else {} ) return document_journal_columns_from_field_types( base_id, tree, field_types, table=table, timeout_seconds=timeout_seconds, max_columns=max_columns, ) def document_journal_columns_from_field_types( base_id: str, tree: Any, field_types: dict[str, dict[str, Any]], *, timeout_seconds: int = 60, max_columns: int | None = None, table: str = "Config", ) -> list[dict[str, Any]]: candidates = [] raw_type_guids: set[str] = set() for records in document_journal_record_containers(tree): columns = [] for record in records: node = getattr(record, "node", None) name, synonym = document_journal_column_title(node) raw_type = document_journal_column_type(node, field_types) or public_pattern_type_from_tree(node, {}) if isinstance(raw_type, dict) and raw_type.get("type_guid"): raw_type_guids.add(str(raw_type.get("type_guid")).lower()) if not name: continue columns.append({"name": name, "synonym": synonym, "_raw_type": raw_type}) if columns: typed = sum(1 for column in columns if column.get("_raw_type")) candidates.append((typed, len(columns), columns)) if not candidates: return [] _, _, columns = max(candidates, key=lambda item: (item[1] >= 2, item[1], item[0])) resolved_types = ( resolve_type_guids(base_id, raw_type_guids, timeout_seconds=timeout_seconds, table=table) if raw_type_guids else {} ) public_columns = [] seen: set[str] = set() for column in columns: if max_columns is not None and len(public_columns) >= max_columns: break name = str(column.get("name") or "") if not name or name in seen: continue seen.add(name) item: dict[str, Any] = {"name": name} if column.get("synonym"): item["synonym"] = column.get("synonym") raw_type = column.get("_raw_type") if raw_type: item["type"] = public_type_info(raw_type, resolved_types, include_storage=False) public_columns.append(item) return public_columns def object_row(guid: str, item: dict[str, Any], top: dict[str, Any], *, score: float | None = None, match_by: str | None = None) -> dict[str, Any]: internal_kind = canonical_kind(str(top.get("xml_kind") or "")) row = { "guid": guid, "kind": internal_kind, "kind_ru": RU_KIND.get(str(internal_kind or ""), internal_kind), "public_kind": PUBLIC_KIND.get(str(internal_kind or ""), "other"), "name": top.get("name"), "synonym": top.get("synonym"), "source": "extension" if is_extension_object(item, top) else "base", "relative_path": top.get("relative_path"), "path": top.get("path"), } if score is not None: row["score"] = score if match_by: row["match_by"] = match_by row["storage"] = compact_storage(item) return row def match_top(top: dict[str, Any], *, kind: str | None, wanted: str) -> tuple[float, str] | None: top_kind = canonical_kind(str(top.get("xml_kind") or "")) if kind and top_kind != kind: return None wanted_norm = normalize(wanted) name = str(top.get("name") or "") synonym = str(top.get("synonym") or "") relative_path = str(top.get("relative_path") or "") if normalize(name) == wanted_norm: return 1.0, "name" if normalize(synonym) == wanted_norm: return 0.95, "synonym" if wanted_norm and wanted_norm in normalize(name): return 0.82, "name_contains" if wanted_norm and wanted_norm in normalize(synonym): return 0.78, "synonym_contains" if wanted_norm and wanted_norm in normalize(relative_path): return 0.65, "relative_path" return None def kind_matches_request(internal: str, wanted: str | None, requested_public: str | None) -> bool: if wanted: return internal == wanted if requested_public: return PUBLIC_KIND.get(internal, "other") == requested_public return True def parse_ordinal(value: Any, method: str, *, argument: str = "ordinal") -> tuple[int | None, dict[str, Any] | None]: if value is None or value == "": return None, None if isinstance(value, bool) or not isinstance(value, int): return None, invalid_argument(method, argument, f"{argument} must be a JSON integer.") ordinal = value if ordinal < 1: return None, invalid_argument(method, argument, f"{argument} is 1-based and must be >= 1.") return ordinal, None def parse_int_argument( payload: dict[str, Any], name: str, *, method: str, default: int, minimum: int = 0, maximum: int | None = None, ) -> tuple[int | None, dict[str, Any] | None]: if name not in payload: return default, None raw = payload.get(name) if isinstance(raw, bool) or not isinstance(raw, int): return None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": name, "diagnostics": {"message": f"{name} must be a JSON integer."}, } value = raw if value < minimum: return None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": name, "diagnostics": {"message": f"{name} must be >= {minimum}."}, } if maximum is not None and value > maximum: return None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": name, "diagnostics": {"message": f"{name} must be <= {maximum}."}, } return value, None def parse_int_alias_argument( payload: dict[str, Any], primary: str, alias: str, *, method: str, default: int, minimum: int = 0, maximum: int | None = None, ) -> tuple[int | None, dict[str, Any] | None]: if primary in payload and alias in payload: return None, invalid_argument(method, alias, f"Pass either {primary} or {alias}, not both.") key = primary if primary in payload else alias return parse_int_argument({key: payload.get(key)} if key in payload else {}, key, method=method, default=default, minimum=minimum, maximum=maximum) def first_non_empty_arg(payload: dict[str, Any], *names: str, default: Any = None) -> Any: for name in names: value = payload.get(name) if value is not None and value != "": return value return default def validate_explicit_ordinal_arguments(payload: dict[str, Any], method: str, names: tuple[str, ...] = ("ordinal", "index", "object_index")) -> dict[str, Any] | None: for name in names: value = payload.get(name) if name in payload and (value is None or value == ""): return invalid_argument(method, name, f"{name} must be a JSON integer when provided.") return None def validate_explicit_guid_argument(payload: dict[str, Any], method: str, argument: str = "guid") -> dict[str, Any] | None: if argument not in payload: return None value = payload.get(argument) if value is None or value == "": return invalid_argument(method, argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument(method, argument, f"{argument} must be a JSON string.") return None def parse_object_lookup_limit(payload: dict[str, Any], method: str, *, default: int = 20) -> tuple[int | None, dict[str, Any] | None]: return parse_int_argument(payload, "limit", method=method, default=default, minimum=1) OBJECT_VIEW_VALUES = ["effective", "base", "extension"] EVIDENCE_MODE_VALUES = ["none", "summary", "full", "raw"] def parse_view_argument(payload: dict[str, Any], method: str, *, default: str = "effective") -> tuple[str | None, dict[str, Any] | None]: if "view" not in payload: return default, None raw = payload.get("view") if raw is None or raw == "": return None, invalid_argument(method, "view", f"view must be one of: {', '.join(OBJECT_VIEW_VALUES)}.", allowed_values=OBJECT_VIEW_VALUES) if not isinstance(raw, str): return None, invalid_argument(method, "view", "view must be a JSON string.", allowed_values=OBJECT_VIEW_VALUES) value = raw.strip().casefold() if value not in OBJECT_VIEW_VALUES: return None, invalid_argument(method, "view", f"view must be one of: {', '.join(OBJECT_VIEW_VALUES)}.", allowed_values=OBJECT_VIEW_VALUES) return value, None def parse_evidence_mode_argument(payload: dict[str, Any], method: str, *, default: str = "summary") -> tuple[str | None, dict[str, Any] | None]: key = "evidence_mode" if "evidence_mode" in payload else "undecoded_evidence_mode" if "undecoded_evidence_mode" in payload else "" if not key: return default, None raw = payload.get(key) if raw is None or raw == "": return None, invalid_argument(method, key, f"{key} must be one of: {', '.join(EVIDENCE_MODE_VALUES)}.", allowed_values=EVIDENCE_MODE_VALUES) if not isinstance(raw, str): return None, invalid_argument(method, key, f"{key} must be a JSON string.", allowed_values=EVIDENCE_MODE_VALUES) value = raw.strip().casefold() if value not in EVIDENCE_MODE_VALUES: return None, invalid_argument(method, key, f"{key} must be one of: {', '.join(EVIDENCE_MODE_VALUES)}.", allowed_values=EVIDENCE_MODE_VALUES) return value, None def is_guid_text(value: Any) -> bool: return bool(re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", str(value or "").strip())) def config_tree_scalar(node: Any) -> str: if not isinstance(node, dict): return str(node or "") if node.get("type") in {"atom", "string"}: return str(node.get("value") or "") return "" def config_tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None: node = tree for index in path: if not isinstance(node, dict) or not isinstance(node.get("items"), list): return None items = node.get("items") or [] if index < 0 or index >= len(items): return None node = items[index] return node def config_tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str: return config_tree_scalar(config_tree_item_at_path(tree, path)) def config_tree_localized_text(node: Any) -> dict[str, str]: if not isinstance(node, dict) or not isinstance(node.get("items"), list): return {} items = node.get("items") or [] if len(items) >= 3 and config_tree_scalar(items[0]).isdigit(): language = config_tree_scalar(items[1]) content = config_tree_scalar(items[2]) return {language: content} if language and content else {} result: dict[str, str] = {} for item in items: result.update(config_tree_localized_text(item)) return result def sql_config_property(name: str, value: Any, *, raw: str, path: str, confidence: str, include_storage: bool) -> dict[str, Any]: return { "name": name, "value": value, "confidence": confidence, "evidence": "live_sql_config_decoder", **({"storage": {"config_path": path, "raw": raw}} if include_storage else {}), } def document_numerator_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: raw_values = { "NumberType": config_tree_scalar_at_path(tree, (1, 2)), "NumberLength": config_tree_scalar_at_path(tree, (1, 3)), "NumberAllowedLength": config_tree_scalar_at_path(tree, (1, 4)), "NumberPeriodicity": config_tree_scalar_at_path(tree, (1, 5)), "CheckUnique": config_tree_scalar_at_path(tree, (1, 6)), } values: dict[str, Any] = { "NumberType": {"1": "String"}.get(raw_values["NumberType"], {"status": "unknown_code", "code": raw_values["NumberType"]}), "NumberLength": int(raw_values["NumberLength"]) if raw_values["NumberLength"].isdigit() else None, "NumberAllowedLength": {"1": "Variable"}.get(raw_values["NumberAllowedLength"], {"status": "unknown_code", "code": raw_values["NumberAllowedLength"]}), "NumberPeriodicity": {"1": "Year"}.get(raw_values["NumberPeriodicity"], {"status": "unknown_code", "code": raw_values["NumberPeriodicity"]}), "CheckUnique": {"0": False, "1": True}.get(raw_values["CheckUnique"], None), } paths = {"NumberType": "1.2", "NumberLength": "1.3", "NumberAllowedLength": "1.4", "NumberPeriodicity": "1.5", "CheckUnique": "1.6"} properties = [ sql_config_property(name, values[name], raw=raw_values[name], path=paths[name], confidence="high", include_storage=include_storage) for name in paths ] return { "number_type": values["NumberType"], "number_length": values["NumberLength"], "number_allowed_length": values["NumberAllowedLength"], "number_periodicity": values["NumberPeriodicity"], "check_unique": values["CheckUnique"], "properties": properties, } def chart_of_calculation_types_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: """Decode stable scalar settings from a ChartOfCalculationTypes descriptor. The paths were verified against live Config payloads for Начисления and Удержания and their offline XML exports. XML is evidence only; runtime values always come from the SQL Config payload. """ paths = { "UseStandardCommands": "1.24", "CodeLength": "1.25", "CodeType": "1.26", "CodeAllowedLength": "1.27", "DescriptionLength": "1.30", "DefaultObjectForm": "1.32", "DefaultListForm": "1.33", "DefaultChoiceForm": "1.34", "AuxiliaryObjectForm": "1.45", "AuxiliaryListForm": "1.46", "AuxiliaryChoiceForm": "1.47", "ObjectPresentation": "1.48", "ExtendedObjectPresentation": "1.49", "ListPresentation": "1.50", "ExtendedListPresentation": "1.51", "Explanation": "1.52", "ActionPeriodUse": "1.57", } raw_values = { name: config_tree_scalar_at_path(tree, tuple(int(part) for part in path.split("."))) for name, path in paths.items() } def optional_guid(raw: str) -> dict[str, Any] | None: return {"guid": raw.lower(), "status": "unresolved"} if is_guid_text(raw) and raw != "00000000-0000-0000-0000-000000000000" else None values: dict[str, Any] = { "UseStandardCommands": {"0": False, "1": True}.get(raw_values["UseStandardCommands"]), "CodeLength": int(raw_values["CodeLength"]) if raw_values["CodeLength"].isdigit() else None, "CodeType": {"1": "String"}.get(raw_values["CodeType"], {"status": "unknown_code", "code": raw_values["CodeType"]}), "CodeAllowedLength": {"0": "Variable"}.get( raw_values["CodeAllowedLength"], {"status": "unknown_code", "code": raw_values["CodeAllowedLength"]}, ), "DescriptionLength": int(raw_values["DescriptionLength"]) if raw_values["DescriptionLength"].isdigit() else None, "DefaultObjectForm": optional_guid(raw_values["DefaultObjectForm"]), "DefaultListForm": optional_guid(raw_values["DefaultListForm"]), "DefaultChoiceForm": optional_guid(raw_values["DefaultChoiceForm"]), "AuxiliaryObjectForm": optional_guid(raw_values["AuxiliaryObjectForm"]), "AuxiliaryListForm": optional_guid(raw_values["AuxiliaryListForm"]), "AuxiliaryChoiceForm": optional_guid(raw_values["AuxiliaryChoiceForm"]), "ObjectPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 48))), "ExtendedObjectPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 49))), "ListPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 50))), "ExtendedListPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 51))), "Explanation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 52))), "ActionPeriodUse": {"0": False, "1": True}.get(raw_values["ActionPeriodUse"]), } properties = [ sql_config_property(name, values[name], raw=raw_values[name], path=paths[name], confidence="high", include_storage=include_storage) for name in paths ] return { "use_standard_commands": values["UseStandardCommands"], "code_length": values["CodeLength"], "code_type": values["CodeType"], "code_allowed_length": values["CodeAllowedLength"], "description_length": values["DescriptionLength"], "default_object_form": values["DefaultObjectForm"], "default_list_form": values["DefaultListForm"], "default_choice_form": values["DefaultChoiceForm"], "auxiliary_object_form": values["AuxiliaryObjectForm"], "auxiliary_list_form": values["AuxiliaryListForm"], "auxiliary_choice_form": values["AuxiliaryChoiceForm"], "object_presentation": values["ObjectPresentation"], "extended_object_presentation": values["ExtendedObjectPresentation"], "list_presentation": values["ListPresentation"], "extended_list_presentation": values["ExtendedListPresentation"], "explanation": values["Explanation"], "action_period_use": values["ActionPeriodUse"], "properties": properties, } def calculation_register_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: """Decode the verified scalar header of a CalculationRegister descriptor.""" paths = { "Periodicity": "1.16", "ActionPeriod": "1.17", "BasePeriod": "1.18", "DefaultListForm": "1.19", "AuxiliaryListForm": "1.20", "ChartOfCalculationTypes": "1.22", "UseStandardCommands": "1.24", "IncludeHelpInContents": "1.25", "DataLockControlMode": "1.26", "FullTextSearch": "1.27", "ListPresentation": "1.28", "ExtendedListPresentation": "1.30", "Explanation": "1.31", } raw_values = { name: config_tree_scalar_at_path(tree, tuple(int(part) for part in path.split("."))) for name, path in paths.items() } def optional_guid(raw: str, *, kind: str | None = None) -> dict[str, Any] | None: if not is_guid_text(raw) or raw == "00000000-0000-0000-0000-000000000000": return None return {"guid": raw.lower(), **({"kind": kind} if kind else {}), "status": "unresolved"} values: dict[str, Any] = { "Periodicity": {"2": "Month"}.get(raw_values["Periodicity"], {"status": "unknown_code", "code": raw_values["Periodicity"]}), "ActionPeriod": {"0": False, "1": True}.get(raw_values["ActionPeriod"]), "BasePeriod": {"0": False, "1": True}.get(raw_values["BasePeriod"]), "DefaultListForm": optional_guid(raw_values["DefaultListForm"], kind="Form"), "AuxiliaryListForm": optional_guid(raw_values["AuxiliaryListForm"], kind="Form"), "ChartOfCalculationTypes": optional_guid(raw_values["ChartOfCalculationTypes"], kind="ChartOfCalculationTypes"), "UseStandardCommands": {"0": False, "1": True}.get(raw_values["UseStandardCommands"]), "IncludeHelpInContents": {"0": False, "1": True}.get(raw_values["IncludeHelpInContents"]), "DataLockControlMode": {"1": "Managed"}.get( raw_values["DataLockControlMode"], {"status": "unknown_code", "code": raw_values["DataLockControlMode"]}, ), "FullTextSearch": {"0": "DontUse"}.get(raw_values["FullTextSearch"], {"status": "unknown_code", "code": raw_values["FullTextSearch"]}), "ListPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 28))), "ExtendedListPresentation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 30))), "Explanation": config_tree_localized_text(config_tree_item_at_path(tree, (1, 31))), } properties = [ sql_config_property(name, values[name], raw=raw_values[name], path=paths[name], confidence="high", include_storage=include_storage) for name in paths ] known_collection_roles = { 3: "Attribute", 4: "Recalculation", 5: "Template", 6: "Resource", 7: "Form", 8: "Command", 9: "Dimension", } child_collections = [] for path_index in range(3, 10): node = config_tree_item_at_path(tree, (path_index,)) items = node.get("items") if isinstance(node, dict) and isinstance(node.get("items"), list) else [] marker_guid = config_tree_scalar(items[0]) if items else "" declared_raw = config_tree_scalar(items[1]) if len(items) > 1 else "" declared_count = int(declared_raw) if declared_raw.isdigit() else 0 role = known_collection_roles.get(path_index) child_collections.append( { "path": str(path_index), "collection_ordinal": path_index - 2, "role": role or "UnclassifiedFieldCollection", "status": "classified" if role else "needs_non_empty_sample", "declared_count": declared_count, **({"possible_roles": ["Dimension", "Resource", "Attribute"]} if not role else {}), **({"storage": {"class_guid": marker_guid}} if include_storage and is_guid_text(marker_guid) else {}), } ) return { "periodicity": values["Periodicity"], "action_period": values["ActionPeriod"], "base_period": values["BasePeriod"], "default_list_form": values["DefaultListForm"], "auxiliary_list_form": values["AuxiliaryListForm"], "chart_of_calculation_types": values["ChartOfCalculationTypes"], "use_standard_commands": values["UseStandardCommands"], "include_help_in_contents": values["IncludeHelpInContents"], "data_lock_control_mode": values["DataLockControlMode"], "full_text_search": values["FullTextSearch"], "list_presentation": values["ListPresentation"], "extended_list_presentation": values["ExtendedListPresentation"], "explanation": values["Explanation"], "child_collections": child_collections, "properties": properties, } def configuration_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: paths: dict[str, tuple[int, ...]] = { "Name": (3, 1, 1, 1, 1, 2), "Synonym": (3, 1, 1, 1, 1, 3), "DetailedInformation": (3, 1, 1, 4), "BriefInformation": (3, 1, 1, 5), "Copyright": (3, 1, 1, 6), "VendorInformationAddress": (3, 1, 1, 7), "ConfigurationInformationAddress": (3, 1, 1, 8), "Vendor": (3, 1, 1, 14), "Version": (3, 1, 1, 15), "UpdateCatalogAddress": (3, 1, 1, 16), } localized = {"Synonym", "DetailedInformation", "BriefInformation", "Copyright", "VendorInformationAddress", "ConfigurationInformationAddress"} values: dict[str, Any] = {} properties: list[dict[str, Any]] = [] for name, path in paths.items(): node = config_tree_item_at_path(tree, path) value: Any = config_tree_localized_text(node) if name in localized else config_tree_scalar(node) values[name] = value properties.append( sql_config_property( name, value, raw=config_tree_scalar(node), path=".".join(str(index) for index in path), confidence="high", include_storage=include_storage, ) ) return { "name": values["Name"], "synonyms": values["Synonym"], "vendor": values["Vendor"], "version": values["Version"], "update_catalog_address": values["UpdateCatalogAddress"], "brief_information": values["BriefInformation"], "detailed_information": values["DetailedInformation"], "copyright": values["Copyright"], "vendor_information_address": values["VendorInformationAddress"], "configuration_information_address": values["ConfigurationInformationAddress"], "properties": properties, "counts": {"decoded_properties": sum(1 for value in values.values() if value not in (None, "", {})), "declared_properties": len(paths)}, } def integration_service_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: container = config_tree_item_at_path(tree, (3,)) items = container.get("items") if isinstance(container, dict) and isinstance(container.get("items"), list) else [] declared = int(config_tree_scalar(items[1])) if len(items) > 1 and config_tree_scalar(items[1]).isdigit() else 0 channels: list[dict[str, Any]] = [] for ordinal, wrapper in enumerate(items[2 : 2 + declared], start=1): wrapper_items = wrapper.get("items") if isinstance(wrapper, dict) and isinstance(wrapper.get("items"), list) else [] record = wrapper_items[0] if wrapper_items else None record_items = record.get("items") if isinstance(record, dict) and isinstance(record.get("items"), list) else [] if len(record_items) < 8: continue identity = record_items[1] identity_items = identity.get("items") if isinstance(identity, dict) and isinstance(identity.get("items"), list) else [] name = config_tree_scalar(identity_items[2]) if len(identity_items) > 2 else "" synonyms = config_tree_localized_text(identity_items[3]) if len(identity_items) > 3 else {} direction_raw = config_tree_scalar(record_items[6]) transaction_raw = config_tree_scalar(record_items[7]) channel = { "ordinal": ordinal, "name": name, "synonyms": synonyms, "external_channel_name": config_tree_scalar(record_items[4]), "message_direction": {"0": "Send", "1": "Receive"}.get(direction_raw, {"status": "unknown_code", "code": direction_raw}), "receive_message_processing": config_tree_scalar(record_items[5]) or None, "transactioned": {"0": False, "1": True}.get(transaction_raw), "confidence": "high", "evidence": "live_sql_config_decoder", } if include_storage: channel["storage"] = { "config_path": f"3.{ordinal + 1}.0", "generated_type_id": config_tree_scalar(record_items[2]), "generated_value_id": config_tree_scalar(record_items[3]), "direction_raw": direction_raw, "transactioned_raw": transaction_raw, } channels.append(channel) return { "external_integration_service_address": {"status": "not_decoded", "reason": "no_non_empty_sql_sample"}, "channels": channels, "counts": {"channels": len(channels), "declared_channels": declared}, } def public_pattern_value_type( base_id: str, type_node: Any, *, table: str = "Config", timeout_seconds: int = 60, ) -> dict[str, Any] | None: values = tree_ordered_scalars(type_node) type_guids: list[str] = [] for index, value in enumerate(values[:-1]): candidate = str(values[index + 1] or "").strip().lower() if value == "#" and is_guid_text(candidate) and candidate not in type_guids: type_guids.append(candidate) resolved_types = resolve_type_guids(base_id, set(type_guids), timeout_seconds=timeout_seconds, table=table) types: list[dict[str, Any]] = [] items = config_tree_list_items(type_node) for item in items[1:]: item_values = tree_ordered_scalars(item) if not item_values: continue code = item_values[0] if code == "#" and len(item_values) > 1 and is_guid_text(item_values[1]): resolved = resolved_types.get(item_values[1].lower()) or {} if resolved.get("guid_role") == "builtin_type": types.append( { "kind": "builtin", "name": resolved.get("name"), "presentation": resolved.get("presentation") or resolved.get("name"), "bsl_type": resolved.get("bsl_type"), } ) continue types.append( public_type_info( {"kind": "reference", "presentation": "Ссылка", "type_guid": item_values[1].lower()}, resolved_types, include_storage=False, ) ) continue if code in {"B", "D", "N", "R", "S"}: synthetic = {"type": "list", "items": [{"type": "string", "value": "Pattern"}, item]} primitive = public_pattern_type_from_tree(synthetic) if isinstance(primitive, dict): types.append(primitive) if not types: return public_pattern_type_from_tree(type_node, resolved_types) if len(types) == 1: return types[0] presentations = [str(item.get("presentation") or "Значение") for item in types if isinstance(item, dict)] return {"kind": "union", "presentation": " | ".join(presentations), "types": types, "count": len(types)} def public_tree_metadata_references( base_id: str, node: Any, *, table: str = "Config", timeout_seconds: int = 60, ) -> list[dict[str, Any]]: zero_guid = "00000000-0000-0000-0000-000000000000" guids: list[str] = [] for guid in config_tree_guids(node): normalized = str(guid or "").lower() if normalized != zero_guid and normalized not in guids: guids.append(normalized) resolved = public_metadata_guid_references(base_id, guids, table=table, timeout_seconds=timeout_seconds) result: list[dict[str, Any]] = [] seen_refs: set[str] = set() for guid in guids: item = resolved.get(guid) ref = str((item or {}).get("ref") or "") if isinstance(item, dict) else "" if not ref or ref in seen_refs or (item or {}).get("status") not in {"ok", "resolved"}: continue seen_refs.add(ref) result.append(item) return result def common_attribute_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: bool_use = {"0": "DontUse", "1": "Use"} separation = {"0": "DontUse", "1": "Separate"} return { "value_type": public_pattern_value_type(base_id, config_tree_item_at_path(tree, (1, 1, 1, 2)), table=table, timeout_seconds=timeout_seconds), "content": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 2)), table=table, timeout_seconds=timeout_seconds), "indexing": {"0": "DontIndex", "1": "Index", "2": "IndexWithAdditionalOrder"}.get(config_tree_scalar_at_path(tree, (1, 3)), "Unknown"), "full_text_search": bool_use.get(config_tree_scalar_at_path(tree, (1, 4))), "data_separation": {"0": "Separate", "1": "DontUse"}.get(config_tree_scalar_at_path(tree, (1, 5))), "separated_data_use": {"0": "IndependentlyAndSimultaneously", "1": "Independently"}.get(config_tree_scalar_at_path(tree, (1, 6))), "data_separation_value": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 7)), table=table, timeout_seconds=timeout_seconds), "data_separation_use": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 8)), table=table, timeout_seconds=timeout_seconds), "conditional_separation": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 9)), table=table, timeout_seconds=timeout_seconds), "users_separation": separation.get(config_tree_scalar_at_path(tree, (1, 10))), "authentication_separation": separation.get(config_tree_scalar_at_path(tree, (1, 11))), "configuration_extensions_separation": separation.get(config_tree_scalar_at_path(tree, (1, 13))), "data_history": bool_use.get(config_tree_scalar_at_path(tree, (1, 14))), } def session_parameter_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: return {"value_type": public_pattern_value_type(base_id, config_tree_item_at_path(tree, (1, 1, 2)), table=table, timeout_seconds=timeout_seconds)} def chart_of_characteristic_types_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: """Decode the chart-level allowed value types with an explicit tree path. This is chart metadata, not a claim about any one characteristic record. Record-level Type/PropertySet fields remain intentionally unresolved until their physical mapping is evidenced by a decoded configuration definition. """ value_type_path = (1, 18) value_type = public_pattern_value_type( base_id, config_tree_item_at_path(tree, value_type_path), table=table, timeout_seconds=timeout_seconds, ) return { "allowed_value_types": value_type or {"status": "not_decoded_yet"}, "evidence": {"config_path": ".".join(str(item) for item in value_type_path), "kind": "chart_metadata"}, } def functional_option_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: location_guid = config_tree_scalar_at_path(tree, (1, 2)).strip().lower() location_map = ( public_metadata_guid_references(base_id, [location_guid], table=table, timeout_seconds=timeout_seconds) if is_guid_text(location_guid) else {} ) return { "location": location_map.get(location_guid), "privileged_get_mode": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 4))), "content": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 3)), table=table, timeout_seconds=timeout_seconds), } def functional_options_parameter_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: return {"use": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 2)), table=table, timeout_seconds=timeout_seconds)} STANDARD_COMMAND_GROUP_GUIDS = { "1af6d528-0b86-4fba-ab95-bd7475db03ba": "NavigationPanelImportant", "4f499c31-050b-47c5-aa84-d0366c0a0da8": "ActionsPanelCreate", "5b360bff-01a1-49b6-93d2-26e7e8e3a038": "ActionsPanelReports", "77ea1b8f-dd79-4717-9dba-5628e7f348cf": "NavigationPanelOrdinary", "8ab1540c-0bfa-4fa6-a1e1-5d5069efc7d8": "FormNavigationPanelSeeAlso", "aabb34e1-98c1-4bd0-bf7f-243f95437b44": "ActionsPanelTools", "bc80566a-86a5-4e87-acd4-872239385a2e": "NavigationPanelSeeAlso", "cb50f5c0-8013-4262-93a2-f0db379d6b6b": "FormCommandBarImportant", "dc11a6be-de1f-4b64-a7a5-9b17bf4ec9f2": "FormNavigationPanelImportant", "dc2ade0f-383e-4c78-85f2-c0dabc0e2dc0": "FormCommandBarCreateBasedOn", "eacad741-96b9-4b3a-bf79-dde9ecead1a1": "FormNavigationPanelGoTo", } STYLE_VALUE_TYPE_GUIDS = { "9cd510c7-abfc-11d4-9434-004095e12fc7": "Color", "9cd510c8-abfc-11d4-9434-004095e12fc7": "Font", "4d10ca00-111a-4d43-9c96-92cd773716de": "Border", } STYLE_VALUE_TYPE_CODES = {"0": "Color", "1": "Font", "2": "Border"} # The numeric values are stable platform web-color identifiers. The map is # intentionally additive: an unknown code remains visible instead of being # guessed or discarded. STYLE_WEB_COLOR_CODES = { "8": "Black", "10": "Blue", "20": "Cream", "21": "Crimson", "23": "DarkBlue", "26": "DarkGray", "33": "DarkRed", "37": "DarkSlateGray", "44": "FireBrick", "46": "ForestGreen", "48": "Gainsboro", "49": "GhostWhite", "52": "Gray", "53": "Green", "55": "HoneyDew", "64": "LemonChiffon", "67": "LightCyan", "71": "LightGray", "72": "LightPink", "79": "LightYellow", "82": "Linen", "84": "Maroon", "86": "MediumBlue", "87": "MediumGray", "98": "MistyRose", "105": "Orange", "115": "Pink", "119": "Red", "128": "Silver", "130": "SlateBlue", "134": "SteelBlue", "140": "Violet", "141": "VioletRed", "144": "WhiteSmoke", "145": "Yellow", } STANDARD_STYLE_CODES = { "-42": "NavigationColor", "-32": "LargeTextFont", "-31": "NormalTextFont", "-23": "ToolTipBackColor", "-21": "ButtonTextColor", "-16": "SpecialTextColor", "-3": "FormTextColor", "-1": "FormBackColor", } def style_color_sql_value(node: Any) -> dict[str, Any]: variant = config_tree_scalar_at_path(node, (1,)) code = config_tree_scalar_at_path(node, (2, 0)) if variant == "0" and re.fullmatch(r"\d+", code or ""): number = int(code) rgb = f"#{number & 255:02X}{(number >> 8) & 255:02X}{(number >> 16) & 255:02X}" return {"kind": "absolute", "value": rgb, "storage_bgr": number} if variant == "2": name = STYLE_WEB_COLOR_CODES.get(code) return {"kind": "web", "code": int(code) if re.fullmatch(r"\d+", code or "") else code, "value": f"web:{name}" if name else None, "status": "ok" if name else "unknown_code"} if variant == "3": name = STANDARD_STYLE_CODES.get(code) return {"kind": "standard_style", "code": int(code) if re.fullmatch(r"-?\d+", code or "") else code, "value": f"style:{name}" if name else None, "status": "ok" if name else "unknown_code"} return {"kind": "unknown", "variant": variant, "code": code, "status": "unknown_encoding"} def style_font_sql_value(node: Any) -> dict[str, Any]: values = [config_tree_scalar_at_path(node, (index,)) for index in range(19)] height_raw = values[3] weight_raw = values[7] scale_raw = values[18] return { "kind": "absolute" if values[17] == "1" else "platform", "face_name": values[16] or None, "height": (int(height_raw) / 10) if re.fullmatch(r"-?\d+", height_raw or "") else None, "bold": int(weight_raw) >= 700 if re.fullmatch(r"-?\d+", weight_raw or "") else None, "italic": values[8] == "1", "underline": values[9] == "1", "strikeout": values[10] == "1", "scale": int(scale_raw) if re.fullmatch(r"\d+", scale_raw or "") else None, } def style_border_sql_value(node: Any) -> dict[str, Any]: style_code = config_tree_scalar_at_path(node, (2, 0)) width = config_tree_scalar_at_path(node, (3,)) return { "style": {"0": "Single"}.get(style_code, {"status": "unknown_code", "code": style_code}), "width": int(width) if re.fullmatch(r"\d+", width or "") else None, } def style_typed_sql_value(node: Any, value_type: str) -> dict[str, Any]: if value_type == "Color": return style_color_sql_value(node) if value_type == "Font": return style_font_sql_value(node) if value_type == "Border": return style_border_sql_value(node) return {"status": "unknown_type", "type": value_type} def style_item_sql_details(tree: Any) -> dict[str, Any]: type_code = config_tree_scalar_at_path(tree, (1, 1)) wrapper = config_tree_item_at_path(tree, (1, 2)) type_guid = config_tree_scalar_at_path(wrapper, (1,)).lower() value_type = STYLE_VALUE_TYPE_CODES.get(type_code) or STYLE_VALUE_TYPE_GUIDS.get(type_guid) or "Unknown" value_node = config_tree_item_at_path(wrapper, (3,)) return { "value_type": value_type, "value": style_typed_sql_value(value_node, value_type), } def language_sql_details(tree: Any) -> dict[str, Any]: return {"language_code": config_tree_scalar_at_path(tree, (1, 2)) or None} def binary_part_summary(data: bytes) -> dict[str, Any]: tree = parse_config_tree_from_bytes(data or b"") encoded = "" for node in iter_config_tree_nodes(tree): items = node.get("items") if isinstance(node.get("items"), list) else [] if items and config_tree_scalar(items[0]) == "#base64": encoded = "".join(config_tree_scalar(item) for item in items[1:]) break try: content = base64.b64decode(encoded, validate=True) if encoded else b"" except Exception: content = b"" media_type = None if content.startswith(b"\xff\xd8\xff"): media_type = "image/jpeg" elif content.startswith(b"\x89PNG\r\n\x1a\n"): media_type = "image/png" elif content.startswith((b"GIF87a", b"GIF89a")): media_type = "image/gif" elif content.startswith(b"BM"): media_type = "image/bmp" elif content.startswith(b"PK\x03\x04"): media_type = "application/zip" elif content.startswith(b"\x00\x00\x01\x00"): media_type = "image/x-icon" elif b" dict[str, Any]: return { "availability_for_choice": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 2))), "availability_for_appearance": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 3))), "binary": binary_part_summary(binary_data or b"") if binary_data is not None else {"status": "source_missing"}, } def style_sql_details(tree: Any, values_tree: Any) -> dict[str, Any]: root_items = values_tree.get("items") if isinstance(values_tree, dict) and isinstance(values_tree.get("items"), list) else [] declared = int(config_tree_scalar(root_items[1])) if len(root_items) > 1 and re.fullmatch(r"\d+", config_tree_scalar(root_items[1])) else 0 items: list[dict[str, Any]] = [] for entry in root_items[2:]: type_code = config_tree_scalar_at_path(entry, (1,)) value_type = STYLE_VALUE_TYPE_CODES.get(type_code, "Unknown") standard_code = config_tree_scalar_at_path(entry, (0, 0)) standard_name = STANDARD_STYLE_CODES.get(standard_code) items.append({ "standard_code": int(standard_code) if re.fullmatch(r"-?\d+", standard_code or "") else standard_code, "standard_name": standard_name, "ref": f"style:{standard_name}" if standard_name else None, "value_type": value_type, "value": style_typed_sql_value(config_tree_item_at_path(entry, (2,)), value_type), }) return {"items": items, "declared_items": declared} def xml_local_name(value: str) -> str: return str(value or "").rsplit("}", 1)[-1] def public_xml_attributes(element: ET.Element) -> dict[str, str]: return {xml_local_name(key): value for key, value in element.attrib.items()} def xdto_property_xml_details(element: ET.Element) -> dict[str, Any]: result: dict[str, Any] = public_xml_attributes(element) inline_type = next((child for child in element if xml_local_name(child.tag) == "typeDef"), None) if inline_type is not None: result["type_definition"] = xdto_type_xml_details(inline_type) return result def xdto_type_xml_details(element: ET.Element) -> dict[str, Any]: result: dict[str, Any] = { "kind": xml_local_name(element.tag), **public_xml_attributes(element), } properties = [xdto_property_xml_details(child) for child in element if xml_local_name(child.tag) == "property"] enumerations = [ {"value": (child.text or "").strip(), **public_xml_attributes(child)} for child in element if xml_local_name(child.tag) == "enumeration" ] if properties: result["properties"] = properties if enumerations: result["enumerations"] = enumerations return result def xdto_package_xml_details(data: bytes) -> dict[str, Any]: try: from parser.payload import decode_payload_lossless, parse_brace_text from parser.payload import parse_brace_text decoded = decode_payload_lossless(data or b"") payload = bytes(decoded.get("payload") or b"") root = ET.fromstring(payload) except Exception as exc: return {"status": "invalid_xml", "diagnostics": {"message": str(exc)}} imports = [public_xml_attributes(child).get("namespace") for child in root if xml_local_name(child.tag) == "import"] types = [ xdto_type_xml_details(child) for child in root if xml_local_name(child.tag) in {"objectType", "valueType"} ] tag_counts: dict[str, int] = {} for element in root.iter(): tag = xml_local_name(element.tag) tag_counts[tag] = tag_counts.get(tag, 0) + 1 return { "status": "ok", "namespace": root.attrib.get("targetNamespace"), "element_form_qualified": root.attrib.get("elementFormQualified"), "attribute_form_qualified": root.attrib.get("attributeFormQualified"), "imports": [value for value in imports if value], "types": types, "counts": { "types": len(types), "object_types": tag_counts.get("objectType", 0), "value_types": tag_counts.get("valueType", 0), "properties": tag_counts.get("property", 0), "enumerations": tag_counts.get("enumeration", 0), "inline_type_definitions": tag_counts.get("typeDef", 0), "imports": tag_counts.get("import", 0), }, } def wsdl_operation_xml_details(element: ET.Element, actions: dict[str, str]) -> dict[str, Any]: name = element.attrib.get("name") result: dict[str, Any] = {"name": name} for child in element: tag = xml_local_name(child.tag) if tag in {"input", "output", "fault"}: result[tag] = public_xml_attributes(child) if name in actions: result["soap_action"] = actions[name] return result def wsdl_xml_details(root: ET.Element) -> dict[str, Any]: actions: dict[str, str] = {} bindings: list[dict[str, Any]] = [] for binding in [item for item in root if xml_local_name(item.tag) == "binding"]: operations: list[dict[str, Any]] = [] for operation in [item for item in binding if xml_local_name(item.tag) == "operation"]: action_node = next( ( child for child in operation if xml_local_name(child.tag) == "operation" and child.attrib.get("soapAction") is not None ), None, ) action = action_node.attrib.get("soapAction") if action_node is not None else None name = operation.attrib.get("name") if name and action: actions[name] = action operations.append({"name": name, **({"soap_action": action} if action else {})}) bindings.append({**public_xml_attributes(binding), "operations": operations}) messages = [] for message in [item for item in root if xml_local_name(item.tag) == "message"]: messages.append({**public_xml_attributes(message), "parts": [public_xml_attributes(child) for child in message if xml_local_name(child.tag) == "part"]}) port_types = [] for port_type in [item for item in root if xml_local_name(item.tag) == "portType"]: port_types.append( { **public_xml_attributes(port_type), "operations": [wsdl_operation_xml_details(child, actions) for child in port_type if xml_local_name(child.tag) == "operation"], } ) services = [] for service in [item for item in root if xml_local_name(item.tag) == "service"]: ports = [] for port in [item for item in service if xml_local_name(item.tag) == "port"]: address = next((child.attrib.get("location") for child in port if xml_local_name(child.tag) == "address"), None) ports.append({**public_xml_attributes(port), **({"address": address} if address else {})}) services.append({**public_xml_attributes(service), "ports": ports}) return { "name": root.attrib.get("name"), "target_namespace": root.attrib.get("targetNamespace"), "messages": messages, "port_types": port_types, "bindings": bindings, "services": services, "counts": { "messages": len(messages), "operations": sum(len(item.get("operations") or []) for item in port_types), "bindings": len(bindings), "services": len(services), "ports": sum(len(item.get("ports") or []) for item in services), }, } def xsd_xml_details(root: ET.Element) -> dict[str, Any]: imports = [public_xml_attributes(item) for item in root if xml_local_name(item.tag) in {"import", "include"}] elements = [public_xml_attributes(item) for item in root if xml_local_name(item.tag) == "element"] types: list[dict[str, Any]] = [] for type_node in [item for item in root if xml_local_name(item.tag) in {"complexType", "simpleType"}]: members = [public_xml_attributes(item) for item in type_node.iter() if item is not type_node and xml_local_name(item.tag) in {"element", "attribute"}] enumerations = [(item.attrib.get("value") or (item.text or "").strip()) for item in type_node.iter() if xml_local_name(item.tag) == "enumeration"] restriction = next((public_xml_attributes(item) for item in type_node.iter() if xml_local_name(item.tag) == "restriction"), None) types.append({"kind": xml_local_name(type_node.tag), **public_xml_attributes(type_node), "members": members, "enumerations": enumerations, **({"restriction": restriction} if restriction else {})}) return { "target_namespace": root.attrib.get("targetNamespace"), "element_form_default": root.attrib.get("elementFormDefault"), "imports": imports, "elements": elements, "types": types, "counts": {"imports": len(imports), "elements": len(elements), "types": len(types)}, } def ws_reference_sql_details(tree: Any, definition_data: bytes) -> dict[str, Any]: try: from parser.cas_payload import stream_blocks_with_data from parser.payload import decode_payload_lossless decoded = decode_payload_lossless(definition_data or b"") streams = stream_blocks_with_data(bytes(decoded.get("payload") or b""), limit=500) except Exception as exc: return {"location_url": config_tree_scalar_at_path(tree, (1, 1, 0)) or None, "status": "invalid_container", "diagnostics": {"message": str(exc)}} wsdls: list[dict[str, Any]] = [] schemas: list[dict[str, Any]] = [] xml_streams = 0 for stream in streams: text = stream.get("text") if not text or "<" not in text: continue try: root = ET.fromstring(text.lstrip("\ufeff")) except Exception: continue tag = xml_local_name(root.tag) xml_streams += 1 if tag == "definitions": wsdls.append(wsdl_xml_details(root)) elif tag == "schema": schemas.append(xsd_xml_details(root)) return { "status": "ok" if wsdls else "partial", "location_url": config_tree_scalar_at_path(tree, (1, 1, 0)) or None, "manager_type_guid": config_tree_scalar_at_path(tree, (1, 3)).lower() or None, "manager_value_guid": config_tree_scalar_at_path(tree, (1, 4)).lower() or None, "definitions": wsdls, "schemas": schemas, "counts": { "streams": len(streams), "xml_streams": xml_streams, "wsdl_definitions": len(wsdls), "schemas": len(schemas), "operations": sum(int((item.get("counts") or {}).get("operations") or 0) for item in wsdls), "services": sum(int((item.get("counts") or {}).get("services") or 0) for item in wsdls), "ports": sum(int((item.get("counts") or {}).get("ports") or 0) for item in wsdls), }, } def common_command_sql_details(base_id: str, tree: Any, identity: dict[str, Any], *, table: str, timeout_seconds: int) -> dict[str, Any]: body = config_tree_item_at_path(tree, (1, 1, 2)) group_guid = common_command_group_guid(tree) or "" group_map = public_metadata_guid_references(base_id, [group_guid], table=table, timeout_seconds=timeout_seconds) if is_guid_text(group_guid) else {} group = group_map.get(group_guid) if not isinstance(group, dict) or not group.get("ref"): standard_name = STANDARD_COMMAND_GROUP_GUIDS.get(group_guid) group = {"kind": "standard_command_group", "name": standard_name, "ref": standard_name, "status": "ok"} if standard_name else None name = str(identity.get("name") or "") ref = object_selector_ref("CommonCommand", name) if name else None return { "group": group, "command_parameter_type": public_pattern_value_type(base_id, config_tree_item_at_path(body, (8,)), table=table, timeout_seconds=timeout_seconds), "module": { "kind": "command_module", "name": "Модуль команды", "read_selector": {"method": "modules.read", "base_id": base_id, "ref": ref, "module_ordinal": 1, "state": "working"} if ref else None, }, } def settings_storage_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: forms = public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (4,)), table=table, timeout_seconds=timeout_seconds) default_nodes = { "default_save_form": (1, 4), "default_load_form": (1, 5), "auxiliary_save_form": (1, 6), "auxiliary_load_form": (1, 7), } details: dict[str, Any] = {"forms": forms} by_guid = {str(item.get("guid") or "").lower(): item for item in forms if isinstance(item, dict)} for key, path in default_nodes.items(): guid = config_tree_scalar_at_path(tree, path).strip().lower() details[key] = by_guid.get(guid) if guid in by_guid else None return details def subsystem_sql_details( base_id: str, tree: Any, interface_tree: Any, *, table: str, timeout_seconds: int, ) -> dict[str, Any]: picture_items = public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 5)), table=table, timeout_seconds=timeout_seconds) interface_items = public_tree_metadata_references(base_id, interface_tree, table=table, timeout_seconds=timeout_seconds) if interface_tree else [] return { "include_help_in_contents": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 2))), "include_in_command_interface": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 4))), "use_one_command": {"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 6, 0))), "picture": picture_items[0] if picture_items else None, "content": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (1, 7)), table=table, timeout_seconds=timeout_seconds), "child_subsystems": public_tree_metadata_references(base_id, config_tree_item_at_path(tree, (3,)), table=table, timeout_seconds=timeout_seconds), "command_interface": { "status": "ok" if interface_tree else "not_configured", "items": interface_items, "counts": {"references": len(interface_items)}, }, } def command_group_sql_details(tree: Any, *, include_storage: bool) -> dict[str, Any]: category_raw = config_tree_scalar_at_path(tree, (1, 2)) representation_raw = config_tree_scalar_at_path(tree, (1, 3)) picture_flag = config_tree_scalar_at_path(tree, (1, 1, 1)) picture_node = config_tree_item_at_path(tree, (1, 1, 2)) picture_values = [config_tree_scalar(item) for item in (picture_node.get("items") or [])] if isinstance(picture_node, dict) else [] picture: dict[str, Any] | None = None if picture_flag == "1": picture_guid = next((value.lower() for value in picture_values if is_guid_text(value)), None) picture_code = next((value for value in picture_values if re.fullmatch(r"-?\d+", value) and value != "0"), None) if picture_guid: picture = {"kind": "metadata_or_standard_picture", "guid": picture_guid, "status": "requires_identity_resolution"} elif picture_code: picture = { "kind": "standard_picture", "code": int(picture_code), "ref": {"-13": "StdPicture.Print"}.get(picture_code), "status": "ok" if picture_code == "-13" else "unknown_code", } details = { "category": {"1": "NavigationPanel", "2": "FormNavigationPanel", "4": "ActionsPanel", "8": "FormCommandBar"}.get(category_raw, {"status": "unknown_code", "code": category_raw}), "representation": {"0": "Text", "1": "Picture", "2": "PictureAndText", "3": "Auto"}.get(representation_raw, {"status": "unknown_code", "code": representation_raw}), "tool_tip": config_tree_localized_text(config_tree_item_at_path(tree, (1, 4))), "picture": picture, "load_transparent": ({"0": False, "1": True}.get(config_tree_scalar_at_path(tree, (1, 1, 6))) if picture else None), "confidence": "high", "evidence": "live_sql_config_decoder", } if include_storage: details["storage"] = {"category_path": "1.2", "category_raw": category_raw, "representation_path": "1.3", "representation_raw": representation_raw, "picture_path": "1.1.2"} return details def iter_config_tree_nodes(tree: Any): stack = [tree] while stack: node = stack.pop() if not isinstance(node, dict): continue yield node items = node.get("items") if isinstance(items, list): stack.extend(reversed(items)) def live_base_root_metadata_index(base_id: str, *, table: str = "Config", timeout_seconds: int = 60) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """Read top-level metadata collections from Config/root without scanning payload files.""" source_table = "Config" if table not in {"Config", "ConfigSave"} else table cache_key = (str(base_id), source_table) now = time.time() with BASE_ROOT_METADATA_CACHE_LOCK: cached = BASE_ROOT_METADATA_CACHE.get(cache_key) if cached and now - float(cached.get("cached_at") or 0) <= BASE_ROOT_METADATA_CACHE_TTL_SECONDS: return [dict(row) for row in cached.get("rows") or []], [dict(item) for item in cached.get("diagnostics") or []] diagnostics: list[dict[str, Any]] = [] pointer_data, _, pointer_error = read_storage_file_bytes(base_id, source_table, "root", timeout_seconds=timeout_seconds) if pointer_error and source_table == "ConfigSave": source_table = "Config" cache_key = (str(base_id), source_table) pointer_data, _, pointer_error = read_storage_file_bytes(base_id, source_table, "root", timeout_seconds=timeout_seconds) if pointer_error: diagnostics.append({"code": "configuration_root_unavailable", "table": source_table, "diagnostics": pointer_error.get("diagnostics")}) return [], diagnostics pointer_tree = parse_config_tree_from_bytes(pointer_data or b"") pointer_items = pointer_tree.get("items") if isinstance(pointer_tree, dict) else [] root_file = next((config_tree_scalar(item).lower() for item in (pointer_items or [])[1:] if is_guid_text(config_tree_scalar(item))), "") if not root_file: return [], [{"code": "configuration_root_pointer_invalid", "table": source_table}] root_data, _, root_error = read_storage_file_bytes(base_id, source_table, root_file, timeout_seconds=timeout_seconds) if root_error: return [], [{"code": "configuration_root_descriptor_unavailable", "table": source_table, "file_name": root_file, "diagnostics": root_error.get("diagnostics")}] root_tree = parse_config_tree_from_bytes(root_data or b"") if not isinstance(root_tree, dict): return [], [{"code": "configuration_root_descriptor_invalid", "table": source_table, "file_name": root_file}] rows_by_key: dict[tuple[str, str], dict[str, Any]] = { ("Configuration", root_file): { "guid": root_file, "kind": "Configuration", "kind_ru": RU_KIND["Configuration"], "public_kind": PUBLIC_KIND["Configuration"], "source": "base", "storage": { "table": source_table, "file_name": root_file, "discovery": "configuration_root_descriptor", "root_file": root_file, }, } } application_block_found = False for node in iter_config_tree_nodes(root_tree): items = node.get("items") if isinstance(node.get("items"), list) else [] if len(items) >= 2: collection_guid = config_tree_scalar(items[0]).lower() kind = ROOT_COLLECTION_KIND.get(collection_guid) declared_text = config_tree_scalar(items[1]) if kind and re.fullmatch(r"\d+", declared_text): object_guids = [config_tree_scalar(item).lower() for item in items[2:] if is_guid_text(config_tree_scalar(item))] if int(declared_text) == len(object_guids): for guid in object_guids: rows_by_key[(kind, guid)] = { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": PUBLIC_KIND.get(kind, "other"), "source": "base", "storage": { "table": source_table, "file_name": guid, "discovery": "configuration_root", "root_file": root_file, "collection_guid": collection_guid, }, } if application_block_found or len(items) != 18 or config_tree_scalar(items[2]) != "15": continue collections = items[3:] if len(collections) != 15 or not all(isinstance(item, dict) and isinstance(item.get("items"), list) for item in collections): continue application_block_found = True for ordinal, collection in enumerate(collections): collection_items = collection.get("items") or [] if len(collection_items) < 2: continue class_guid = config_tree_scalar(collection_items[0]).lower() kind = ROOT_APPLICATION_CLASS_KIND.get(class_guid) or ROOT_APPLICATION_COLLECTION_KIND.get(ordinal) if not kind: diagnostics.append({"code": "application_collection_kind_unknown", "ordinal": ordinal, "class_guid": class_guid}) continue declared_text = config_tree_scalar(collection_items[1]) object_guids = [config_tree_scalar(item).lower() for item in collection_items[2:] if is_guid_text(config_tree_scalar(item))] if not re.fullmatch(r"\d+", declared_text) or int(declared_text) != len(object_guids): diagnostics.append({"code": "application_collection_count_mismatch", "ordinal": ordinal, "kind": kind, "declared": declared_text, "actual": len(object_guids)}) for guid in object_guids: rows_by_key[(kind, guid)] = { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": PUBLIC_KIND.get(kind, "other"), "source": "base", "storage": { "table": source_table, "file_name": guid, "discovery": "configuration_root", "root_file": root_file, "collection_ordinal": ordinal, "collection_guid": class_guid, }, } if not application_block_found: diagnostics.append({"code": "application_collections_not_found", "table": source_table, "file_name": root_file}) rows = sorted(rows_by_key.values(), key=lambda row: (str(row.get("kind") or ""), str(row.get("guid") or ""))) with BASE_ROOT_METADATA_CACHE_LOCK: BASE_ROOT_METADATA_CACHE[cache_key] = {"cached_at": now, "rows": rows, "diagnostics": diagnostics} return [dict(row) for row in rows], diagnostics def merge_root_metadata_candidates( candidates: dict[str, dict[str, Any]], root_rows: list[dict[str, Any]], *, wanted_kind: str | None, requested_public: str | None, ) -> None: for root_row in root_rows: internal = str(root_row.get("kind") or "") if not kind_matches_request(internal, wanted_kind, requested_public): continue guid = str(root_row.get("guid") or "").lower() if not guid: continue existing = candidates.get(guid) if existing: existing_storage = existing.setdefault("storage", {}) root_storage = root_row.get("storage") if isinstance(root_row.get("storage"), dict) else {} existing_storage.setdefault("file_name", root_storage.get("file_name") or guid) existing_storage["root_discovery"] = {key: value for key, value in root_storage.items() if key not in {"table", "file_name"}} continue candidates[guid] = dict(root_row) def kind_request_needs_root_discovery(wanted_kind: str | None, requested_public: str | None) -> bool: if not wanted_kind and not requested_public: return True return any(kind_matches_request(kind, wanted_kind, requested_public) for kind in ROOT_DISCOVERY_KIND_SET) def get_kinds(base_id: str | None = None) -> dict[str, Any]: if not base_id: return base_id_required("metadata.kinds") records, error = live_dbnames_records(str(base_id)) if error: return error identities: dict[str, set[str]] = {} for record in records or []: role = getattr(record, "storage_role", "") kind = DBNAMES_ROLE_KIND.get(role) guid = str(getattr(record, "guid", "") or "").lower() if not kind or not guid: continue identities.setdefault(kind, set()).add(guid) root_rows, root_diagnostics = live_base_root_metadata_index(str(base_id), table="Config") for row in root_rows: kind = str(row.get("kind") or "") guid = str(row.get("guid") or "").lower() if kind and guid: identities.setdefault(kind, set()).add(guid) internal_counts = {kind: len(guids) for kind, guids in identities.items()} public_counts: dict[str, int] = {} for kind, count in internal_counts.items(): public = PUBLIC_KIND.get(kind, "other") public_counts[public] = public_counts.get(public, 0) + count kinds = [{"kind": key, "count": public_counts[key]} for key in sorted(public_counts)] return { "schema": "onec_metadata_kinds.v1", "status": "ok", "base_id": str(base_id), "source": {"kind": "live_metadata"}, "kinds": kinds, "internal_kind_counts": dict(sorted(internal_counts.items())), **({"diagnostics": root_diagnostics} if root_diagnostics else {}), } def metadata_capabilities(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.capabilities") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error kind_error = validate_optional_string_arguments(payload, "metadata.capabilities", ["kind"]) if kind_error: return kind_error include_missing, include_missing_error = strict_bool_argument(payload, "include_missing", method="metadata.capabilities", default=False) if include_missing_error: return include_missing_error wanted_kind, requested_public = parse_kind_request(payload.get("kind")) kinds_result = get_kinds(base_id) if kinds_result.get("status") != "ok": result = dict(kinds_result) result["method"] = "metadata.capabilities" return result internal_counts = kinds_result.get("internal_kind_counts") or {} capabilities = [] for kind in sorted(KIND_CAPABILITIES): if wanted_kind or requested_public: if not kind_matches_request(kind, wanted_kind, requested_public): continue count = int(internal_counts.get(kind) or 0) if count <= 0 and not include_missing: continue capabilities.append( { "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": PUBLIC_KIND.get(kind, "other"), "count": count, "capabilities": KIND_CAPABILITIES[kind], } ) if payload.get("kind") and not capabilities: return { "schema": "onec_metadata_capabilities.v1", "status": "not_found", "error": "not_found", "base_id": base_id, "source": {"kind": "live_metadata"}, "query": {"kind": payload.get("kind"), "include_missing": include_missing}, "capabilities": [], "counts": {"kinds": 0}, "diagnostics": {"message": "Вид метаданных не найден или не поддерживается адаптером."}, } return { "schema": "onec_metadata_capabilities.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"}, "query": {"kind": payload.get("kind"), "include_missing": include_missing}, **({"selected": capabilities[0]} if payload.get("kind") and len(capabilities) == 1 else {}), "capabilities": capabilities, "counts": {"kinds": len(capabilities)}, } def classify_unmapped_source_role(role: str) -> str: if re.search(r"(ChngR|Opt|SInf|BfK|DlK|Agg|Turnover|TurnoverCt|TurnoverDt)$", role): return "auxiliary_storage" if role.startswith(("AccumRg", "InfoRg", "AccRg", "Reference", "Document", "Task", "BPr", "CKinds", "Chrc", "Const")): return "auxiliary_storage" if role in {"Fld", "VT", "LineNo", "ByDims", "ByField", "ByParentField", "ByProperty", "ByResource", "FrmDtSettings", "DynListSettings", "RepSettings", "RepVarSettings", "BPrPoints"}: return "metadata_parts" if role.startswith(("DataHistory", "DbCopies", "DbSegments", "Extensions", "IntegService", "IntegChannel", "Users")): return "platform_system_storage" if role in { "CommonSettings", "ConfigChngR", "Consts", "DataSeparationUse", "DefaultInternalSettings", "DefaultSystemSettings", "Descr", "EDBT", "ErrorProcessingSettings", "ExtsChngR", "InternalSettings", "ODataSettings", "SystemSettings", }: return "platform_system_storage" # Live DBNames verification shows these as singleton platform tables # (normally with the zero GUID), not configuration metadata collections. # ExtDataSrcPrms is storage attached to an ExternalDataSource or its global # fallback row, so it is auxiliary rather than a standalone metadata kind. if role == "ExtDataSrcPrms": return "auxiliary_storage" if role.startswith(("STT",)) or role in { "Acoustic", "Bots", "Ecs", "LangModel", "MobileClientDataExchange", "URLExternalData", "WebSocketClients", }: return "platform_system_storage" return "unclassified" def metadata_adapter_audit(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.adapter.audit") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if "include_details" in payload: return invalid_argument( "metadata.adapter.audit", "include_details", "metadata.adapter.audit does not support include_details; use include_unmapped=true for additional audit sections.", ) include_missing, include_missing_error = strict_bool_argument(payload, "include_missing", method="metadata.adapter.audit", default=False) if include_missing_error: return include_missing_error include_unmapped, include_unmapped_error = strict_bool_argument(payload, "include_unmapped", method="metadata.adapter.audit", default=False) if include_unmapped_error: return include_unmapped_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.adapter.audit", default=60, minimum=1) if timeout_error: return timeout_error records, error = live_dbnames_records(base_id, timeout_seconds=int(timeout_seconds or 60)) if error: result = dict(error) result["method"] = "metadata.adapter.audit" return result recognized: dict[str, set[str]] = {} recognized_source_roles: dict[str, set[str]] = {} source_role_counts: dict[str, int] = {} unknown_role_counts: dict[str, int] = {} unknown_role_categories: dict[str, int] = {} for record in records or []: role = str(getattr(record, "storage_role", "") or "") if not role: continue source_role_counts[role] = source_role_counts.get(role, 0) + 1 kind = DBNAMES_ROLE_KIND.get(role) guid = str(getattr(record, "guid", "") or "").lower() if kind and guid: recognized.setdefault(kind, set()).add(guid) recognized_source_roles.setdefault(kind, set()).add(role) elif role: unknown_role_counts[role] = unknown_role_counts.get(role, 0) + 1 category = classify_unmapped_source_role(role) unknown_role_categories[category] = unknown_role_categories.get(category, 0) + 1 root_rows, root_diagnostics = live_base_root_metadata_index(base_id, table="Config", timeout_seconds=int(timeout_seconds or 60)) for row in root_rows: kind = str(row.get("kind") or "") guid = str(row.get("guid") or "").lower() if not kind or not guid: continue recognized.setdefault(kind, set()).add(guid) storage = row.get("storage") if isinstance(row.get("storage"), dict) else {} source_role = "ConfigRoot" if storage.get("collection_guid"): source_role = f"ConfigRoot:{storage['collection_guid']}" elif storage.get("collection_ordinal") is not None: source_role = f"ConfigRoot:application:{storage['collection_ordinal']}" recognized_source_roles.setdefault(kind, set()).add(source_role) kind_support = [] recognized_kind_counts: dict[str, int] = {} public_kind_counts: dict[str, int] = {} missing_supported_kinds: list[dict[str, Any]] = [] for kind in sorted(TOP_LEVEL_METADATA_KINDS): count = len(recognized.get(kind, set())) recognized_kind_counts[kind] = count public_kind = PUBLIC_KIND.get(kind, "other") public_kind_counts[public_kind] = public_kind_counts.get(public_kind, 0) + count if count <= 0: missing_supported_kinds.append( { "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": public_kind, "presence_status": "supported_absent_in_selected_base", "capabilities": KIND_CAPABILITIES[kind], } ) if count <= 0 and not include_missing: continue kind_support.append( { "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": public_kind, "count": count, "source_roles": sorted(recognized_source_roles.get(kind, set())), "capabilities": KIND_CAPABILITIES[kind], } ) result = { "schema": "onec_adapter_audit.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"}, "metadata_kinds": kind_support, "recognized_kind_counts": {key: value for key, value in sorted(recognized_kind_counts.items()) if value > 0}, "public_kind_counts": {key: value for key, value in sorted(public_kind_counts.items()) if value > 0}, "child_objects": { "forms": { "status": "supported", "properties": ["name", "synonym", "elements", "element_types_xml_confirmed", "attributes", "commands", "tables", "command_bars", "events", "handler_links", "module_summary"], "methods": ["metadata.object.forms", "metadata.object.form.details", "metadata.form.decode"], }, "templates": { "status": "supported", "properties": ["name", "synonym", "format", "tabular_document", "html", "bsl", "safe_preview", "bounded_content_export"], "methods": ["metadata.object.templates", "metadata.object.template.details", "templates.read"], }, "commands": { "status": "supported", "properties": ["name", "synonym", "role", "object_commands", "form_commands"], "methods": ["metadata.object.commands", "metadata.object.form.details"], }, }, "code_carriers": CODE_CARRIER_MATRIX, "write_capabilities": METADATA_WRITE_CAPABILITIES, "special_objects": { "Configuration": ["vendor", "version", "information", "addresses"], "CommandGroup": ["category", "representation", "tool_tip", "picture"], "Constant": ["value_type"], "DocumentJournal": ["document_types", "column_names", "column_synonyms", "column_types_explicit"], "DocumentNumerator": ["number_type", "number_length", "number_allowed_length", "number_periodicity", "check_unique"], "IntegrationService": ["channels", "external_integration_service_address"], "ScheduledJob": [ "method", "use", "predefined", "restart_count_on_failure", "restart_interval_on_failure", "begin_date", "end_date", "begin_time", "end_time", "completion_time", "completion_interval", "repeat_period_in_day", "repeat_pause", "week_days", "week_day_in_month", "day_in_month", "months", "weeks_period", "days_repeat_period", ], }, "not_yet_decoded": [ "Редкие свойства оформления и поведения элементов управляемых форм, для которых ещё не подтверждены стабильные SQL-позиции; все типы элементов из эталонного UPO Form.xml уже распознаются", "Платформенный визуальный рендер макетов (пиксели/PDF); безопасный ограниченный экспорт исходного содержимого уже поддержан templates.read include_content=true", ], "optional_deep_reads": [ { "kind": "DocumentJournal", "property": "column_types", "flag": "include_column_types=true", "execution": "adapter.job.start", "reason": "Типы разрешаются по реквизитам всех документов журнала и могут требовать длительного чтения метаданных.", } ], "counts": { "metadata_kinds": len(kind_support), "recognized_kinds": len([count for count in recognized_kind_counts.values() if count > 0]), "supported_kinds": len(TOP_LEVEL_METADATA_KINDS), "missing_supported_kinds": len([count for count in recognized_kind_counts.values() if count <= 0]), }, **({"diagnostics": root_diagnostics} if root_diagnostics else {}), } if include_missing: result["missing_supported_kinds"] = missing_supported_kinds if include_unmapped: result["metadata_candidates"] = [ {"source_role": role, "category": classify_unmapped_source_role(role), "records": unknown_role_counts[role]} for role in sorted(unknown_role_counts) if classify_unmapped_source_role(role) in {"platform_feature_candidate", "unclassified"} ] result["unmapped_source_roles"] = [ { "source_role": role, "category": classify_unmapped_source_role(role), "records": unknown_role_counts[role], } for role in sorted(unknown_role_counts) ] result["counts"].update( { "source_records": len(records or []), "known_source_roles": len([role for role in source_role_counts if role in DBNAMES_ROLE_KIND]), "unmapped_source_roles": len(unknown_role_counts), "unmapped_categories": dict(sorted(unknown_role_categories.items())), } ) return result def metadata_write_capabilities(payload: dict[str, Any]) -> dict[str, Any]: base_id = payload.get("base_id") if isinstance(payload.get("base_id"), str) else None return { "schema": "onec_metadata_write_capabilities.v1", "status": "ok", "base_id": base_id, "default_write_layer": "save", "agent_rule": "Agent-facing writes must target the saved-state layer; active configuration writes are not exposed.", "code_carriers": CODE_CARRIER_MATRIX, "write_capabilities": METADATA_WRITE_CAPABILITIES, "registered_handlers": registered_handlers(), "workflow_priority": METADATA_WRITE_WORKFLOW, "safe_methods": ["code.write", "metadata.write", "metadata.write.plan"], "technical_apply_method": "storage.saved_state.apply_proposal", "unsupported_summary": [ key for key, value in METADATA_WRITE_CAPABILITIES.items() if str(value.get("status") or "").startswith(("not_supported", "read_only")) ], } def list_objects( kind: str | None, *, base_id: str | None = None, limit: Any = 200, offset: Any = 0, include_storage: bool = False, include_missing: bool = False, only_missing: bool = False, exact_counts: bool = False, refresh_cache: bool = False, table: str = "Config", name_filter: str | None = None, ) -> dict[str, Any]: if not base_id: return base_id_required("metadata.objects.list") resolved_base_id = str(base_id) parsed_limit, limit_error = parse_int_argument({"limit": limit}, "limit", method="metadata.objects.list", default=200, minimum=1) if limit_error: return limit_error parsed_offset, offset_error = parse_int_argument({"offset": offset}, "offset", method="metadata.objects.list", default=0, minimum=0) if offset_error: return offset_error limit = int(parsed_limit or 200) offset = int(parsed_offset or 0) storage_table = str(table or "Config") if storage_table not in STORAGE_TABLES: return invalid_argument("metadata.objects.list", "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) normalized_name_filter = normalize(name_filter or "") wanted, requested_public = parse_kind_request(kind) if not include_storage and not include_missing and not only_missing and not exact_counts and not refresh_cache and not normalized_name_filter: cached = metadata_cache_list_rows(resolved_base_id, kind, limit=limit, offset=offset) if cached: rows, cached_total = cached root_cache_incomplete = False if wanted and kind_request_needs_root_discovery(wanted, requested_public): cached_root_rows, _cached_root_diagnostics = live_base_root_metadata_index(resolved_base_id, table=storage_table) root_candidate_count = sum( 1 for row in cached_root_rows if kind_matches_request(str(row.get("kind") or ""), wanted, requested_public) ) root_cache_incomplete = root_candidate_count > cached_total if not root_cache_incomplete: public_page = [metadata_cache_public_row(row) for row in rows] return { "schema": "onec_metadata_objects.v1", "status": "ok", "base_id": resolved_base_id, "source": {"kind": "metadata_cache"}, "query": { "kind": kind, "limit": limit, "offset": offset, "include_storage": include_storage, "include_missing": include_missing, "only_missing": only_missing, "exact_counts": exact_counts, "refresh_cache": refresh_cache, "table": storage_table, }, "objects": public_page, "counts": { "returned": len(public_page), "page": len(public_page), "total": cached_total, "scanned": 0, "counts_exact": False, "visible_counts_exact": True, "total_visible": cached_total, "missing": None, "hidden_missing": None, "page_missing": 0, "visible_missing": 0, }, "cache": {"status": "hit", "role": "metadata_identity_cache"}, "diagnostics": [ { "message": "Обычный список получен из локального кеша метаданных. Для проверки отсутствующих объектов передайте include_missing=true, only_missing=true или exact_counts=true.", } ], } records, error = live_dbnames_records(resolved_base_id) if error: return error candidates: dict[str, dict[str, Any]] = {} for record in records or []: role = getattr(record, "storage_role", "") internal = DBNAMES_ROLE_KIND.get(role) if not internal: continue public = PUBLIC_KIND.get(internal, "other") if not kind_matches_request(internal, wanted, requested_public): continue guid = str(getattr(record, "guid", "") or "").lower() if not guid: continue row = candidates.setdefault( guid, { "guid": guid, "kind": internal, "kind_ru": RU_KIND.get(internal, internal), "public_kind": public, "name": None, "synonym": None, "source": "extension" if dbnames_record_storage_table(record, storage_table) == "ConfigCAS" else "base", "storage": {"table": dbnames_record_storage_table(record, storage_table), "dbnames": []}, }, ) record_table = dbnames_record_storage_table(record, storage_table) if record_table == "ConfigCAS": row["source"] = "extension" row.setdefault("storage", {})["table"] = "ConfigCAS" row["storage"]["dbnames"].append( { "source_file": getattr(record, "source", None), "storage_role": role, "table": record_table, "sql_number": getattr(record, "sql_number", None), "index": getattr(record, "index", None), } ) root_diagnostics: list[dict[str, Any]] = [] if storage_table in {"Config", "ConfigSave"} and kind_request_needs_root_discovery(wanted, requested_public): root_rows, root_diagnostics = live_base_root_metadata_index(resolved_base_id, table=storage_table) merge_root_metadata_candidates(candidates, root_rows, wanted_kind=wanted, requested_public=requested_public) candidate_rows = list(candidates.values()) candidate_rows.sort(key=lambda row: (row["kind"] or "", row["guid"])) diagnostics = list(root_diagnostics) page: list[dict[str, Any]] = [] visible_rows: list[dict[str, Any]] = [] visible_seen = 0 hidden_missing = 0 missing = 0 total_visible = 0 scanned_candidates = 0 scanned_all = True chunk_size = 80 if exact_counts or only_missing else min(80, max(10, offset + limit)) for start in range(0, len(candidate_rows), chunk_size): chunk = [dict(row) for row in candidate_rows[start : start + chunk_size]] payloads: dict[str, bytes] = {} for chunk_table in sorted({preferred_object_storage_table(row, storage_table) for row in chunk}): table_rows = [row for row in chunk if preferred_object_storage_table(row, storage_table) == chunk_table] table_payloads, _, payload_error = read_storage_files_bytes(resolved_base_id, chunk_table, [row["guid"] for row in table_rows]) if payload_error: diagnostics.append({"message": (payload_error.get("diagnostics") or {}).get("message"), "table": chunk_table}) continue payloads.update(table_payloads or {}) for row in chunk: if not payloads or row["guid"] not in payloads: row["status"] = "source_missing" row["diagnostics"] = metadata_payload_missing_diagnostics() else: identity = config_identity_from_bytes(payloads[row["guid"]]) if identity: row["name"] = identity.get("name") synonyms = identity.get("synonyms") or {} row["synonym"] = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None row["identity"] = identity row["status"] = "ok" else: row["status"] = "partial" row["diagnostics"] = {"message": "Не удалось прочитать имя объекта из описания метаданных."} scanned_candidates += 1 is_missing = not bool(row.get("name")) if normalized_name_filter and normalized_name_filter not in normalize(row.get("name") or "") and normalized_name_filter not in normalize(row.get("synonym") or "") and normalized_name_filter not in normalize(row.get("guid") or ""): continue if is_missing: missing += 1 if only_missing: visible = is_missing else: visible = include_missing or not is_missing if is_missing and not include_missing and not only_missing: hidden_missing += 1 if not visible: continue total_visible += 1 if exact_counts: visible_rows.append(row) elif visible_seen >= offset and len(page) < limit: page.append(row) visible_seen += 1 if not exact_counts and len(page) >= limit: scanned_all = start + len(chunk) >= len(candidate_rows) break if exact_counts: visible_rows.sort(key=lambda row: (row["kind"] or "", normalize(row.get("name") or row["guid"]))) page = visible_rows[offset : offset + limit] page.sort(key=lambda row: (row["kind"] or "", normalize(row.get("name") or row["guid"]))) public_page = [public_metadata_row(row, include_storage=include_storage) for row in page] counts_are_exact = bool(exact_counts or scanned_all) page_missing = sum(1 for row in page if not row.get("name")) if not counts_are_exact: diagnostics.append( { "message": "Счетчики total_visible, missing и hidden_missing не вычислялись полностью в быстром режиме. Передайте exact_counts=true, если нужны точные счетчики по всему виду.", } ) return { "schema": "onec_metadata_objects.v1", "status": "ok", "base_id": resolved_base_id, "source": {"kind": "live_metadata"}, "query": { "kind": kind, "name_filter": name_filter, "limit": limit, "offset": offset, "include_storage": include_storage, "include_missing": include_missing, "only_missing": only_missing, "exact_counts": exact_counts, "table": storage_table, }, "objects": public_page, "counts": { "returned": len(page), "page": len(page), "total": len(candidate_rows), "scanned": scanned_candidates, "counts_exact": counts_are_exact, "visible_counts_exact": counts_are_exact, "total_visible": total_visible if counts_are_exact else None, "missing": missing if counts_are_exact else None, "hidden_missing": hidden_missing if counts_are_exact else None, "page_missing": page_missing, "visible_missing": page_missing, }, "diagnostics": diagnostics, } def metadata_objects_list_extension_not_supported(payload: dict[str, Any]) -> dict[str, Any]: return { "schema": "onec_adapter_request_error.v1", "method": "metadata.objects.list", "status": "invalid_argument", "error": "invalid_argument", "argument": "extension", "base_id": payload.get("base_id"), "diagnostics": { "message": "metadata.objects.list не фильтрует объекты по расширению. Для объектов расширения используйте extension.objects.find или metadata.definition.find с extension.", "next_method": "extension.objects.find", "next_payload": { "base_id": payload.get("base_id"), "extension": payload.get("extension"), **({"kind": payload.get("kind")} if payload.get("kind") else {}), **({"query": payload.get("name_filter") or payload.get("name_contains") or payload.get("name")} if (payload.get("name_filter") or payload.get("name_contains") or payload.get("name")) else {}), **({"limit": payload.get("limit")} if payload.get("limit") else {}), }, }, } def get_object( kind: str | None, name: str, *, base_id: str | None = None, view: str = "effective", limit: int = 20, include_storage: bool = False, ordinal: Any = None, include_semantic: bool = True, timeout_seconds: int = 60, table: str = "Config", file_name: str | None = None, extension_guid: str | None = None, resolve_semantic_types: bool = True, semantic_include_generic: bool = True, semantic_categories: set[str] | list[str] | tuple[str, ...] | None = None, semantic_lightweight: bool = False, ) -> dict[str, Any]: if not base_id: return base_id_required("metadata.object.get") resolved_base_id = str(base_id) request_started = time.monotonic() request_deadline = request_started + max(1, int(timeout_seconds or 60)) def deadline_error(stage: str, *, scanned: int = 0) -> dict[str, Any]: return { "schema": "onec_metadata_object.v1", "method": "metadata.object.get", "status": "error", "error": "query_timeout", "base_id": resolved_base_id, "diagnostics": { "stage": stage, "elapsed_seconds": round(time.monotonic() - request_started, 3), "timeout_seconds": int(timeout_seconds or 60), "scanned": scanned, }, } def remaining_seconds() -> int: return max(0, int(request_deadline - time.monotonic())) view_value, view_error = parse_view_argument({"view": view}, "metadata.object.get") if view_error: return view_error view = str(view_value or "effective") storage_table = str(table or "Config") if storage_table not in STORAGE_TABLES: return invalid_argument("metadata.object.get", "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) wanted_kind, wanted_name = parse_object_query(kind, name) direct_file_name = str(file_name or "").strip() public_extension_guid = str(extension_guid or "").strip().lower() if ( not direct_file_name and storage_table == "ConfigCASSave" and is_guid_text(public_extension_guid) and wanted_name and not is_guid_text(wanted_name) ): saved_matches = extension_objects_find( { "base_id": resolved_base_id, "extension": public_extension_guid, "state": "save", "kind": wanted_kind, "query": wanted_name, "limit": max(20, int(limit or 20)), "include_storage": True, "timeout_seconds": timeout_seconds, } ) exact_saved = [ item for item in saved_matches.get("objects") or [] if isinstance(item, dict) and (not wanted_kind or canonical_kind(str(item.get("kind") or "")) == wanted_kind) and normalize_exact(item.get("name") or "") == normalize_exact(wanted_name) ] if len(exact_saved) == 1: saved_item = exact_saved[0] saved_route = saved_item.get("route") if isinstance(saved_item.get("route"), dict) else {} saved_file_name = str(saved_route.get("file_name") or saved_route.get("descriptor_file_name") or "").strip() if saved_file_name: return get_object( saved_item.get("kind") or wanted_kind, str(saved_item.get("guid") or wanted_name), base_id=resolved_base_id, view=view, limit=limit, include_storage=include_storage, include_semantic=include_semantic, timeout_seconds=timeout_seconds, table=storage_table, file_name=saved_file_name, extension_guid=public_extension_guid, resolve_semantic_types=resolve_semantic_types, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if not direct_file_name and storage_table == "ConfigCASSave" and public_extension_guid and is_guid_text(wanted_name): direct_file_name = f"{public_extension_guid}__{wanted_name.lower()}" if ( not direct_file_name # A caller that explicitly selected Config is asking for the base # layer. Extension auto-resolution is useful only for the effective # ConfigCAS route; silently crossing this boundary makes origin and # any future write target unsafe. and storage_table == "ConfigCAS" and wanted_name ): extension_match = fast_extension_object_exact_match( resolved_base_id, kind=wanted_kind, name=wanted_name, extension_guid=public_extension_guid or None, table="ConfigCAS", timeout_seconds=timeout_seconds, ) if extension_match: origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} route_file_name = str(route.get("file_name") or "").strip() if route_file_name: resolved = get_object( extension_match.get("kind") or wanted_kind, str(extension_match.get("guid") or wanted_name), base_id=resolved_base_id, view=view, limit=limit, include_storage=include_storage, include_semantic=include_semantic, timeout_seconds=timeout_seconds, table=str(route.get("table") or "ConfigCAS"), file_name=route_file_name, extension_guid=str(extension.get("guid") or public_extension_guid or "") or None, resolve_semantic_types=resolve_semantic_types, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if resolved.get("status") == "ok": extension_origin = { "source": "extension", "presentation": "Расширение", "extension": { "guid": extension.get("guid") or public_extension_guid or None, "name": extension.get("name"), }, "status": "ok", } resolved = dict(resolved) resolved_object = dict(resolved.get("object") or {}) resolved_object["origin"] = extension_origin resolved["object"] = resolved_object resolved["matches"] = [ {**dict(item), "origin": extension_origin} for item in resolved.get("matches") or [] if isinstance(item, dict) ] return resolved if direct_file_name: if Path(direct_file_name).name != direct_file_name: return invalid_argument("metadata.object.get", "file_name", "file_name must be a safe storage file name.") data, _config, read_error = read_storage_file_bytes( resolved_base_id, storage_table, direct_file_name, timeout_seconds=timeout_seconds, ) if read_error or data is None: result = dict( read_error or { "schema": "onec_adapter_source_error.v1", "status": "source_missing", "base_id": resolved_base_id, "source": {"kind": "live_sql", "table": storage_table}, "diagnostics": {"message": "The requested storage file was not found."}, } ) result["method"] = "metadata.object.get" return result identity = config_identity_from_bytes(data) or saved_state_descriptor_identity_from_bytes(data, direct_file_name) or {} try: from parser.cas_payload import classify_payload detected_kind = extension_metadata_payload_kind(data, identity, classify_payload(data, include_text=False)) except Exception: detected_kind = None object_kind = wanted_kind or detected_kind guid = str(identity.get("guid") or direct_file_name.split("__", 1)[-1]).strip().lower() object_row = { "guid": guid, "kind": object_kind, "kind_ru": RU_KIND.get(str(object_kind or ""), object_kind), "public_kind": PUBLIC_KIND.get(str(object_kind or ""), "other"), "name": identity.get("name") or wanted_name, "synonym": identity.get("synonym") or next(iter((identity.get("synonyms") or {}).values()), None), "source": "extension_saved_state" if storage_table == "ConfigCASSave" else "live_sql", "storage": {"table": storage_table, "file_name": direct_file_name}, "match_by": "direct_file_name", "score": 1.0, } semantic = None if include_semantic: records, records_error = live_dbnames_records(resolved_base_id, timeout_seconds=timeout_seconds) if records_error: records = [] decoded = decode_config_object_full( data, kind=str(object_kind or ""), dbnames_records=records or [], include_text=False, include_tree=False, max_depth=3, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if decoded.get("status") == "ok": semantic = public_semantic_profile(decoded.get("semantic"), include_storage=include_storage, resolved_types={}) public_object = public_metadata_row(object_row, include_storage=include_storage) return { "schema": "onec_metadata_object.v1", "status": "ok", "base_id": resolved_base_id, "source": {"kind": "live_sql", "table": storage_table}, "view": view, "query": { "kind": wanted_kind, "name": wanted_name, "raw_kind": kind, "raw_name": name, "file_name": direct_file_name if include_storage else None, "include_storage": include_storage, }, "object": public_object, **({"semantic": semantic} if semantic else {}), "extension_overlays": [], "matches": [public_object], "counts": {"matches": 1, "extension_overlays": 0, "candidates": 1, "scanned": 1}, "diagnostics": {"note": "Object descriptor was read directly from the selected live SQL storage table."}, } ordinal_value, ordinal_error = parse_ordinal(ordinal, "metadata.object.get") if ordinal_error: return ordinal_error if ordinal_value is not None: if not wanted_kind: return { "schema": "onec_adapter_request_error.v1", "method": "metadata.object.get", "status": "error", "error": "kind_required", "diagnostics": {"message": "kind is required when selecting an object by ordinal."}, } ordinal_result = list_objects( wanted_kind, base_id=resolved_base_id, limit=1, offset=ordinal_value - 1, include_storage=False, table=storage_table, ) if ordinal_result.get("status") != "ok" or not ordinal_result.get("objects"): result = dict(ordinal_result) result["method"] = "metadata.object.get" result["status"] = "not_found" result["error"] = "not_found" result["diagnostics"] = {"message": f"Object ordinal {ordinal_value} was not found for kind {wanted_kind}."} return public_error_result(result, include_storage=include_storage, method="metadata.object.get") selected = dict((ordinal_result.get("objects") or [])[0]) return get_object( selected.get("kind") or wanted_kind, str(selected.get("guid") or ""), base_id=resolved_base_id, view=view, limit=limit, include_storage=include_storage, table=storage_table, include_semantic=include_semantic, timeout_seconds=timeout_seconds, resolve_semantic_types=resolve_semantic_types, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) cache_hit = metadata_cache_lookup_row(resolved_base_id, wanted_kind, wanted_name) if wanted_name and not is_guid_text(wanted_name) else None if wanted_kind in {"DataProcessor", "Report"}: cache_hit = None if cache_hit: direct = metadata_cache_public_row(cache_hit) # Cache entries may be incomplete after a configuration change and a # synonym can collide with an actual technical 1C name. A caller's # exact public name must always win over a synonym match. if normalize(str(direct.get("name") or "")) != normalize(wanted_name): exact_probe = list_objects( wanted_kind, base_id=resolved_base_id, limit=50, offset=0, include_storage=False, refresh_cache=True, table=storage_table, name_filter=wanted_name, ) exact_match = next((item for item in (exact_probe.get("objects") or []) if isinstance(item, dict) and normalize(str(item.get("name") or "")) == normalize(wanted_name)), None) if exact_match: direct = dict(exact_match) semantic = None if include_semantic: records, error = live_dbnames_records(resolved_base_id, timeout_seconds=timeout_seconds) if error: result = dict(error) result["method"] = "metadata.object.get" return result data, _, read_error = read_storage_file_bytes(resolved_base_id, storage_table, str(direct["guid"]), timeout_seconds=timeout_seconds) if read_error: result = dict(read_error) result["method"] = "metadata.object.get" return result decoded = decode_config_object_full( data or b"", kind=str(direct.get("kind") or wanted_kind or ""), dbnames_records=records, include_text=False, include_tree=False, max_depth=3, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if decoded.get("status") == "ok": semantic_raw = decoded.get("semantic") resolved_types = ( resolve_type_guids( resolved_base_id, collect_reference_type_guids_from_sections((semantic_raw or {}).get("sections") or []), timeout_seconds=timeout_seconds, table=storage_table, ) if resolve_semantic_types else {} ) semantic = public_semantic_profile(semantic_raw, include_storage=include_storage, resolved_types=resolved_types) return { "schema": "onec_metadata_object.v1", "status": "ok", "base_id": resolved_base_id, "source": {"kind": "live_metadata"}, "view": view, "query": {"kind": wanted_kind, "name": wanted_name, "raw_kind": kind, "raw_name": name, "include_storage": include_storage}, "object": direct, **({"semantic": semantic} if semantic else {}), "extension_overlays": [], "matches": [direct], "counts": {"matches": 1, "extension_overlays": 0, "cache_hit": 1, "scanned": 0}, "diagnostics": {"note": "Object identity was resolved from the local metadata cache."}, } if remaining_seconds() <= 0: return deadline_error("metadata.object.get.dbnames") records, error = live_dbnames_records(resolved_base_id, timeout_seconds=max(1, remaining_seconds())) if error: result = dict(error) result["method"] = "metadata.object.get" return result _, requested_public = parse_kind_request(kind) candidates: dict[str, dict[str, Any]] = {} for record in records or []: role = getattr(record, "storage_role", "") internal = DBNAMES_ROLE_KIND.get(role) if not internal: continue public = PUBLIC_KIND.get(internal, "other") if not kind_matches_request(internal, wanted_kind, requested_public): continue guid = str(getattr(record, "guid", "") or "").lower() if not guid: continue row = candidates.setdefault( guid, { "guid": guid, "kind": internal, "kind_ru": RU_KIND.get(internal, internal), "public_kind": public, "name": None, "synonym": None, "source": "extension" if dbnames_record_storage_table(record, storage_table) == "ConfigCAS" else "base", "storage": {"table": dbnames_record_storage_table(record, storage_table), "dbnames": []}, }, ) record_table = dbnames_record_storage_table(record, storage_table) if record_table == "ConfigCAS": row["source"] = "extension" row.setdefault("storage", {})["table"] = "ConfigCAS" row["storage"]["dbnames"].append( { "source_file": getattr(record, "source", None), "storage_role": role, "table": record_table, "sql_number": getattr(record, "sql_number", None), "index": getattr(record, "index", None), } ) if storage_table in {"Config", "ConfigSave"} and kind_request_needs_root_discovery(wanted_kind, requested_public): if remaining_seconds() <= 0: return deadline_error("metadata.object.get.root_discovery") root_rows, _root_diagnostics = live_base_root_metadata_index( resolved_base_id, table=storage_table, timeout_seconds=max(1, remaining_seconds()) ) merge_root_metadata_candidates(candidates, root_rows, wanted_kind=wanted_kind, requested_public=requested_public) matches = [] candidate_rows = list(candidates.values()) candidate_rows.sort(key=lambda row: (row["kind"] or "", row["guid"])) if is_guid_text(wanted_name): direct = candidates.get(wanted_name.lower()) if direct: direct_table = preferred_object_storage_table(direct, storage_table) data, _, read_error = read_storage_file_bytes(resolved_base_id, direct_table, str(direct["guid"]), timeout_seconds=timeout_seconds) if read_error: result = dict(read_error) result["method"] = "metadata.object.get" return result identity = config_identity_from_bytes(data or b"") if identity: direct["name"] = identity.get("name") synonyms = identity.get("synonyms") or {} direct["synonym"] = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None direct["identity"] = identity direct["score"] = 1.0 direct["match_by"] = "guid" if include_semantic: decoded = decode_config_object_full( data or b"", kind=str(direct.get("kind") or wanted_kind or ""), dbnames_records=records, include_text=False, include_tree=False, max_depth=3, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if decoded.get("status") == "ok": semantic_raw = decoded.get("semantic") resolved_types = ( resolve_type_guids( resolved_base_id, collect_reference_type_guids_from_sections((semantic_raw or {}).get("sections") or []), timeout_seconds=timeout_seconds, table=direct_table, ) if resolve_semantic_types else {} ) semantic = public_semantic_profile(semantic_raw, include_storage=include_storage, resolved_types=resolved_types) else: semantic = None else: semantic = None return { "schema": "onec_metadata_object.v1", "status": "ok", "base_id": resolved_base_id, "source": {"kind": "live_metadata"}, "view": view, "query": {"kind": wanted_kind, "name": wanted_name, "raw_kind": kind, "raw_name": name, "include_storage": include_storage}, "object": public_metadata_row(direct, include_storage=include_storage), **({"semantic": semantic} if semantic else {}), "extension_overlays": [], "matches": [public_metadata_row(direct, include_storage=include_storage)], "counts": {"matches": 1, "extension_overlays": 0, "candidates": len(candidate_rows), "scanned": 1}, "diagnostics": { "note": "High-level metadata response. Physical storage routes are hidden unless include_storage=true.", }, } scanned = 0 for start in range(0, len(candidate_rows), 80): if remaining_seconds() <= 0: return deadline_error("metadata.object.get.candidate_scan", scanned=scanned) chunk = candidate_rows[start : start + 80] payloads: dict[str, bytes] = {} for chunk_table in sorted({preferred_object_storage_table(row, storage_table) for row in chunk}): table_rows = [row for row in chunk if preferred_object_storage_table(row, storage_table) == chunk_table] table_payloads, _, payload_error = read_storage_files_bytes( resolved_base_id, chunk_table, [row["guid"] for row in table_rows], timeout_seconds=max(1, remaining_seconds()), ) if payload_error: result = dict(payload_error) result["method"] = "metadata.object.get" return result payloads.update(table_payloads or {}) for row in chunk: scanned += 1 identity = config_identity_from_bytes(payloads[row["guid"]]) if payloads and row["guid"] in payloads else None if identity: row["name"] = identity.get("name") synonyms = identity.get("synonyms") or {} row["synonym"] = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None row["identity"] = identity match = match_top( { "xml_kind": row.get("kind"), "name": row.get("name"), "synonym": row.get("synonym"), "relative_path": row.get("guid"), }, kind=wanted_kind, wanted=wanted_name, ) if not match: continue score, match_by = match item = dict(row) item["score"] = score item["match_by"] = match_by matches.append(item) if any(float(row.get("score") or 0) >= 0.95 for row in matches): break matches.sort(key=lambda row: (-float(row["score"]), normalize(row.get("name") or ""), row.get("guid") or "")) canonical = next((row for row in matches if row["score"] >= 0.95), None) or (matches[0] if matches else None) if not canonical and storage_table == "ConfigCAS" and wanted_kind in {"DataProcessor", "Report"} and wanted_name and not is_guid_text(wanted_name): extension_matches, _ = metadata_extension_definition_matches( base_id=resolved_base_id, query=wanted_name, max_files=5000, max_matches=max(limit, 20), timeout_seconds=timeout_seconds, include_storage=True, use_cache=True, ) for match in extension_matches: kind_ru = str(match.get("kind") or "") if kind_ru not in {RU_KIND.get(wanted_kind), wanted_kind, "Определение расширения"} and wanted_kind: continue identity_name = str(match.get("name") or "") if normalize(identity_name) != normalize(wanted_name): continue row = { "guid": str(match.get("guid") or "").lower(), "kind": wanted_kind, "kind_ru": RU_KIND.get(wanted_kind, wanted_kind), "public_kind": PUBLIC_KIND.get(wanted_kind, "other"), "name": identity_name, "synonym": match.get("synonym"), "source": "extension", "storage": { "table": "ConfigCAS", "file_name": ( ((match.get("source") or {}).get("file_name") if isinstance(match.get("source"), dict) else None) or match.get("source_file") ), }, "score": 1.0, "match_by": "extension_definition", } if row["guid"]: matches.append(row) canonical = row break semantic = None if canonical: config, _ = sql_config_for_base(resolved_base_id) if config: metadata_cache_upsert(config, canonical) if include_semantic: canonical_table = preferred_object_storage_table(canonical, storage_table) data, _, read_error = read_storage_file_bytes(resolved_base_id, canonical_table, str(canonical["guid"]), timeout_seconds=timeout_seconds) if read_error: result = dict(read_error) result["method"] = "metadata.object.get" return result decoded = decode_config_object_full( data or b"", kind=str(canonical.get("kind") or wanted_kind or ""), dbnames_records=records, include_text=False, include_tree=False, max_depth=3, semantic_include_generic=semantic_include_generic, semantic_categories=semantic_categories, semantic_lightweight=semantic_lightweight, ) if decoded.get("status") == "ok": semantic_raw = decoded.get("semantic") resolved_types = ( resolve_type_guids( resolved_base_id, collect_reference_type_guids_from_sections((semantic_raw or {}).get("sections") or []), timeout_seconds=timeout_seconds, table=canonical_table, ) if resolve_semantic_types else {} ) semantic = public_semantic_profile(semantic_raw, include_storage=include_storage, resolved_types=resolved_types) else: semantic = None return { "schema": "onec_metadata_object.v1", "status": "ok" if canonical else "not_found", **({"method": "metadata.object.get", "error": "not_found"} if not canonical else {}), "base_id": resolved_base_id, "source": {"kind": "live_metadata"}, "view": view, "query": {"kind": wanted_kind, "name": wanted_name, "raw_kind": kind, "raw_name": name, "include_storage": include_storage}, "object": public_metadata_row(canonical, include_storage=include_storage) if canonical else None, **({"semantic": semantic} if semantic else {}), "extension_overlays": [], "matches": [public_metadata_row(row, include_storage=include_storage) for row in matches[:limit]], "counts": {"matches": len(matches), "extension_overlays": 0, "candidates": len(candidate_rows), "scanned": scanned}, "diagnostics": { "note": "High-level metadata response. Physical storage routes are hidden unless include_storage=true.", **({"message": "Объект метаданных не найден по заданному виду и имени."} if not canonical else {}), }, } def metadata_snapshot(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.snapshot") if isinstance(base_id_or_error, dict): return base_id_or_error include_modules, include_modules_error = strict_bool_argument(payload, "include_modules", method="metadata.snapshot", default=False) if include_modules_error: return include_modules_error if "limit" in payload: return invalid_argument("metadata.snapshot", "limit", "metadata.snapshot does not support limit; use metadata.objects.list for paged object lists.") base_id = base_id_or_error kinds = get_kinds(base_id) if kinds.get("status") != "ok": result = dict(kinds) result["method"] = "metadata.snapshot" return result return { "schema": "onec_metadata_snapshot.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"}, "include_modules": bool(include_modules), "kinds": kinds.get("kinds"), } def access_item_id(item: Any) -> str: if isinstance(item, dict): for key in ("id", "ref", "uuid", "guid", "name", "code"): value = item.get(key) if value not in {None, ""}: return str(value) return json.dumps(item, ensure_ascii=False, sort_keys=True) return str(item) def access_item_name(item: dict[str, Any], fallback: str) -> str: for key in ("name", "presentation", "synonym", "title", "full_name"): value = item.get(key) if value not in {None, ""}: return str(value) return fallback def access_name_is_placeholder(name: Any, item_id: Any) -> bool: name_text = str(name or "").strip() item_text = str(item_id or "").strip() if not name_text: return True return name_text.casefold() == item_text.casefold() or access_ref_tail(name_text).casefold() == access_ref_tail(item_text).casefold() def access_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value if isinstance(value, tuple): return list(value) if isinstance(value, str): return [part.strip() for part in re.split(r"[,;]", value) if part.strip()] return [value] def access_pick_list(item: dict[str, Any], *keys: str) -> list[Any]: for key in keys: if key not in item: continue value = item.get(key) if value is not None and value != "": return access_list(value) return [] def access_bool(value: Any, default: bool = False) -> bool: if value is None: return default if isinstance(value, bool): return value return str(value).strip().casefold() in {"1", "true", "yes", "y", "да", "истина"} def access_subject_refs(raw: Any, default_type: str | None = None) -> list[dict[str, str]]: refs: list[dict[str, str]] = [] for item in access_list(raw): if isinstance(item, dict): subject_type = str(item.get("type") or item.get("subject_type") or default_type or "").strip() subject_id = access_item_id(item) else: subject_type = str(default_type or "").strip() subject_id = access_item_id(item) if subject_id: refs.append({"type": subject_type, "id": subject_id}) return refs def access_permission_key(permission: dict[str, Any], action: str) -> tuple[str, str, str]: object_ref = str(permission.get("object") or permission.get("object_ref") or permission.get("metadata") or "*") action_ref = str(action or permission.get("action") or "*") scope = str(permission.get("scope") or permission.get("mode") or "") return object_ref.casefold(), action_ref.casefold(), scope.casefold() def access_normalize_permission(raw: Any) -> list[dict[str, Any]]: if isinstance(raw, str): return [{"object": raw, "actions": ["*"]}] if not isinstance(raw, dict): return [] permission = dict(raw) object_ref = permission.get("object") or permission.get("object_ref") or permission.get("metadata") or permission.get("target") or "*" actions = access_pick_list(permission, "actions", "rights", "permissions") if not actions and permission.get("action") not in {None, ""}: actions = [permission.get("action")] if not actions: actions = ["*"] return [ { **{key: value for key, value in permission.items() if key not in {"action", "actions", "rights", "permissions", "metadata", "target"}}, "object": str(object_ref), "action": str(action), } for action in actions ] def access_snapshot_from_payload(payload: dict[str, Any], method: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: source = payload.get("access") if source is None and isinstance(payload.get("snapshot"), dict): source = payload["snapshot"].get("access") if source is None: source = payload.get("data") if source is None: return None, invalid_argument(method, "access", "Pass access snapshot data in access, snapshot.access, or data.") if not isinstance(source, dict): return None, invalid_argument(method, "access", "access must be a JSON object.") return source, None ACCESS_DISCOVERY_MARKERS = { "users": ["пользовател", "user"], "groups": ["групп", "group"], "profiles": ["профил", "profile"], "roles": ["роль", "роли", "role"], "permissions": ["прав", "доступ", "permission", "access"], "restrictions": ["огранич", "rls", "restriction"], } ACCESS_BSP_RIGHT_FIELD_LABELS = { 27940: "Добавление", 27941: "Изменение", 27942: "Чтение", 27943: "Изменение", 27944: "ДобавлениеБезОграничения", 27945: "ЧтениеБезОграничения", 27946: "Удаление", 27947: "ИзменениеБезОграничения", } def access_identifier_parts(value: Any) -> dict[str, str] | None: raw = str(value or "").strip() if not raw: return None type_code = "" ref = raw if ":" in raw: type_code, ref = [part.strip() for part in raw.split(":", 1)] normalized_ref = re.sub(r"[^0-9a-fA-F]", "", ref).upper() if len(normalized_ref) != 32: return None result = {"id": raw, "ref": normalized_ref} if type_code: result["type_code"] = type_code.upper() return result def access_ref_tail(value: Any) -> str: parts = access_identifier_parts(value) if parts: return parts["ref"] raw = str(value or "").strip() return (raw.split(":", 1)[1] if ":" in raw else raw).upper() def access_identifier_guid_variants(value: Any) -> list[str]: parts = access_identifier_parts(value) if not parts: return [] try: raw_bytes = bytes.fromhex(parts["ref"]) except ValueError: return [] variants: list[str] = [] for guid in ( str(uuid.UUID(bytes=raw_bytes)), str(uuid.UUID(bytes=bytes(raw_bytes[12:16] + raw_bytes[10:12] + raw_bytes[8:10] + raw_bytes[0:2] + raw_bytes[2:8]))), ): if guid not in variants: variants.append(guid) return variants def access_identifier_payload_from_index(config: dict[str, str] | None, value: Any) -> dict[str, Any] | None: if not config: return None for guid in access_identifier_guid_variants(value): with cache_connection() as conn: row = conn.execute( """ SELECT payload_json, guid_role, kind, kind_ru, public_kind, name, synonym, full_name, presentation FROM metadata_guid_index WHERE server_key = ? AND database_name = ? AND guid = ? ORDER BY CASE guid_role WHEN 'metadata_object' THEN 0 WHEN 'metadata_type' THEN 1 WHEN 'generated_type' THEN 2 ELSE 3 END LIMIT 1 """, (cache_server_key(config), cache_database_name(config), guid), ).fetchone() if not row: continue payload: dict[str, Any] = {} try: loaded = json.loads(row["payload_json"] or "{}") if isinstance(loaded, dict): payload.update(loaded) except Exception: pass for key in ("guid_role", "kind", "kind_ru", "public_kind", "name", "synonym", "full_name", "presentation"): if row[key] not in {None, ""}: payload.setdefault(key, row[key]) payload.setdefault("guid", guid) payload.setdefault("match_by", "metadata_guid_index") return payload return None def access_public_identifier_resolution(value: Any, payload: dict[str, Any]) -> dict[str, Any]: result = { "id": str(value or ""), "guid": payload.get("guid"), "match_by": payload.get("match_by") or "metadata_guid_index", } for key in ("kind", "kind_ru", "public_kind", "name", "synonym", "full_name", "presentation"): if payload.get(key) not in {None, ""}: result[key] = payload.get(key) return result def access_enrich_snapshot_identifiers(access: dict[str, Any], base_id: str) -> dict[str, Any]: config, _ = sql_config_for_base(base_id) role_ids = {str(role.get("id") or "") for role in access_list(access.get("roles")) if isinstance(role, dict)} object_ids: set[str] = set() for role in access_list(access.get("roles")): if not isinstance(role, dict): continue for permission in access_list(role.get("permissions")): if isinstance(permission, dict) and permission.get("object") not in {None, ""}: object_ids.add(str(permission.get("object"))) role_map = {value: access_identifier_payload_from_index(config, value) for value in sorted(role_ids) if value} object_map = {value: access_identifier_payload_from_index(config, value) for value in sorted(object_ids) if value} resolved_roles = {key: value for key, value in role_map.items() if isinstance(value, dict)} resolved_objects = {key: value for key, value in object_map.items() if isinstance(value, dict)} for role in access_list(access.get("roles")): if not isinstance(role, dict): continue role_id = str(role.get("id") or "") resolved_role = resolved_roles.get(role_id) if resolved_role: role.setdefault("name", resolved_role.get("name") or resolved_role.get("full_name") or role_id) role["resolution"] = access_public_identifier_resolution(role_id, resolved_role) for permission in access_list(role.get("permissions")): if not isinstance(permission, dict): continue object_id = str(permission.get("object") or "") resolved_object = resolved_objects.get(object_id) if not resolved_object: continue permission["object_resolution"] = access_public_identifier_resolution(object_id, resolved_object) for source_key, target_key in (("name", "object_name"), ("kind", "object_kind"), ("full_name", "object_full_name")): if resolved_object.get(source_key) not in {None, ""}: permission[target_key] = resolved_object.get(source_key) return { "status": "ok" if (resolved_roles or resolved_objects) else "unresolved", "source": "metadata_guid_index", "roles": { "total": len(role_ids), "resolved": len(resolved_roles), "unresolved": len(role_ids) - len(resolved_roles), }, "objects": { "total": len(object_ids), "resolved": len(resolved_objects), "unresolved": len(object_ids) - len(resolved_objects), }, } def access_schema_candidate_score(table_name: str, columns: list[str]) -> tuple[int, list[str]]: haystack = " ".join([table_name, *columns]).casefold() reasons: list[str] = [] score = 0 for area, markers in ACCESS_DISCOVERY_MARKERS.items(): matched = [marker for marker in markers if marker in haystack] if matched: score += 10 + len(matched) reasons.append(area) if any("состав" in column.casefold() or "member" in column.casefold() for column in columns): score += 5 reasons.append("membership") if any("владел" in column.casefold() or "owner" in column.casefold() for column in columns): score += 2 reasons.append("owner_column") return score, sorted(set(reasons)) def access_rows_from_query(base_id: str, query: str, *, limit: int, timeout_seconds: int) -> tuple[list[dict[str, Any]], dict[str, Any] | None, bool]: validation = validate_query({"base_id": base_id, "query": query, "timeout_seconds": timeout_seconds}) if not validation.get("valid"): return [], {"status": "rejected", "validation": validation}, False conn, config, error = connect_live_sql(base_id, "access.snapshot.extract", timeout_seconds=timeout_seconds) if error: return [], error, False rows: list[dict[str, Any]] = [] truncated = False try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute(query) fetched = cursor.fetchmany(limit + 1) truncated = len(fetched) > limit rows = [{key: jsonable(value) for key, value in row.items()} for row in fetched[:limit]] except Exception as exc: return [], { "schema": "onec_access_snapshot_extract.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database")}, "diagnostics": {"message": str(exc)}, }, False return rows, None, truncated def access_map_row(row: dict[str, Any], mapping: dict[str, str] | None, defaults: dict[str, Any] | None = None) -> dict[str, Any]: if not mapping: return dict(row) result = dict(defaults or {}) for target, source in mapping.items(): if source in row: result[target] = row.get(source) return result def access_snapshot_from_extractor_rows(rows_by_area: dict[str, list[dict[str, Any]]], mappings: dict[str, Any]) -> dict[str, Any]: access: dict[str, Any] = {"users": [], "groups": [], "profiles": [], "roles": [], "data_restrictions": []} for area in ("users", "groups", "profiles", "roles", "data_restrictions"): area_mapping = mappings.get(area) if isinstance(mappings.get(area), dict) else None access[area] = [access_map_row(row, area_mapping) for row in rows_by_area.get(area) or []] access_keys: dict[str, list[dict[str, Any]]] = {} for area in ("access_group_keys", "access_user_keys", "access_object_keys", "access_set_keys"): area_mapping = mappings.get(area) if isinstance(mappings.get(area), dict) else None rows = [access_map_row(row, area_mapping) for row in rows_by_area.get(area) or []] if rows: access_keys[area] = rows if access_keys: access["access_keys"] = access_keys for row in rows_by_area.get("group_users") or []: mapped = access_map_row(row, mappings.get("group_users") if isinstance(mappings.get("group_users"), dict) else None) group_id = str(mapped.get("group") or mapped.get("group_id") or "") user_id = str(mapped.get("user") or mapped.get("user_id") or "") for group in access["groups"]: if str(group.get("id") or group.get("name") or "") == group_id: group.setdefault("users", []).append(user_id) user_group_members: dict[str, list[str]] = {} for row in rows_by_area.get("user_group_members") or []: mapped = access_map_row(row, mappings.get("user_group_members") if isinstance(mappings.get("user_group_members"), dict) else None) user_group_id = str(mapped.get("group") or mapped.get("group_id") or "") user_id = str(mapped.get("user") or mapped.get("user_id") or "") if user_group_id and user_id: user_group_members.setdefault(user_group_id, []).append(user_id) if user_group_members: for group in access["groups"]: expanded_users: list[str] = [] for user_id in list(group.get("users") or []): expanded_users.extend(user_group_members.get(str(user_id), [])) for user_id in expanded_users: if user_id not in group.setdefault("users", []): group["users"].append(user_id) for row in rows_by_area.get("group_profiles") or []: mapped = access_map_row(row, mappings.get("group_profiles") if isinstance(mappings.get("group_profiles"), dict) else None) group_id = str(mapped.get("group") or mapped.get("group_id") or "") profile_id = str(mapped.get("profile") or mapped.get("profile_id") or "") for group in access["groups"]: if str(group.get("id") or group.get("name") or "") == group_id: group.setdefault("profiles", []).append(profile_id) for row in rows_by_area.get("profile_roles") or []: mapped = access_map_row(row, mappings.get("profile_roles") if isinstance(mappings.get("profile_roles"), dict) else None) profile_id = str(mapped.get("profile") or mapped.get("profile_id") or "") role_id = str(mapped.get("role") or mapped.get("role_id") or "") for profile in access["profiles"]: if str(profile.get("id") or profile.get("name") or "") == profile_id: profile.setdefault("roles", []).append(role_id) for row in rows_by_area.get("role_permissions") or []: mapped = access_map_row(row, mappings.get("role_permissions") if isinstance(mappings.get("role_permissions"), dict) else None) role_id = str(mapped.get("role") or mapped.get("role_id") or "") permission = { "object": mapped.get("object") or mapped.get("object_ref") or "*", **({"object_name": mapped.get("object_name")} if mapped.get("object_name") not in {None, ""} else {}), **( {"actions": mapped.get("actions")} if mapped.get("actions") not in {None, ""} else {"action": mapped.get("action") or mapped.get("right") or "*"} ), **({"source_field": mapped.get("source_field")} if mapped.get("source_field") not in {None, ""} else {}), **({"source_fields": mapped.get("source_fields")} if mapped.get("source_fields") not in {None, ""} else {}), } for role in access["roles"]: if str(role.get("id") or role.get("name") or "") == role_id: if mapped.get("role_name") not in {None, ""} and role.get("name") in {None, "", role.get("id")}: role["name"] = mapped.get("role_name") role.setdefault("permissions", []).append(permission) return access def access_sql_hex(column: str) -> str: return f"CONVERT(varchar(64), {column}, 2)" def access_sql_ref_expr(prefix: str, field: str) -> str: rrref = f"{prefix}.{field}_RRRef" rtref = f"{prefix}.{field}_RTRef" return f"CASE WHEN {rrref} IS NULL THEN NULL ELSE CONCAT({access_sql_hex(rtref)}, ':', {access_sql_hex(rrref)}) END" def access_route_sql_number(item: dict[str, Any] | None) -> int | None: if not isinstance(item, dict): return None for route in item.get("storage_routes") or []: if isinstance(route, dict) and route.get("sql_number") is not None: try: return int(route.get("sql_number")) except (TypeError, ValueError): return None return None def access_object_sql_number(profile: dict[str, Any]) -> int | None: obj = profile.get("object") if isinstance(profile.get("object"), dict) else {} storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {} for route in storage.get("dbnames") or []: if isinstance(route, dict) and route.get("sql_number") is not None: try: return int(route.get("sql_number")) except (TypeError, ValueError): return None return None def access_tabular_column_sql_number(profile: dict[str, Any], tabular_section: str, column: str) -> int | None: for section in profile.get("tabular_sections") or []: if not isinstance(section, dict) or str(section.get("name") or "").casefold() != tabular_section.casefold(): continue for item in section.get("columns") or []: if isinstance(item, dict) and str(item.get("name") or "").casefold() == column.casefold(): return access_route_sql_number(item) return None def access_bsp_metadata_profile(base_id: str, kind: str, name: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: result = metadata_object_attributes( { "base_id": base_id, "kind": kind, "name": name, "include_storage": True, "only": "all", "limit": 500, "timeout_seconds": 60, } ) if result.get("status") != "ok": return None, result return result, None def access_sql_existing_tables(base_id: str, table_prefix: str, *, timeout_seconds: int = 30) -> list[str]: conn, _, error = connect_live_sql(base_id, "access.snapshot.extract", timeout_seconds=timeout_seconds) if error: return [] escaped = table_prefix.replace("[", "[[]").replace("%", "[%]").replace("_", "[_]") try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( """ SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND (TABLE_NAME = %s OR TABLE_NAME LIKE %s) ORDER BY TABLE_NAME """, (table_prefix, f"{escaped}X%"), ) rows = cursor.fetchall() except Exception: return [] tables: list[str] = [] for row in rows: name = str(row.get("TABLE_NAME") or "") if name == table_prefix or re.fullmatch(rf"{re.escape(table_prefix)}X\d+", name): tables.append(name) return tables def access_identifier_union_sql(tables: list[str]) -> str: selected = [table for table in tables if re.fullmatch(r"_Reference\d+(X\d+)?", table)] if not selected: return "SELECT CAST(NULL AS varbinary(16)) AS _IDRRef, CAST(NULL AS nvarchar(512)) AS _Description WHERE 1 = 0" return "\nUNION ALL\n".join( f"SELECT _IDRRef, _Description FROM dbo.[{table}] WHERE _Marked = 0x00" for table in selected ) def access_reference_description_union_sql(tables: list[str]) -> str: selected = [table for table in tables if re.fullmatch(r"_Reference\d+(X\d+)?", table)] if not selected: return "SELECT CAST(NULL AS varbinary(16)) AS _IDRRef, CAST(NULL AS nvarchar(512)) AS _Description, CAST(0 AS bit) AS _Marked WHERE 1 = 0" return "\nUNION ALL\n".join( f"SELECT _IDRRef, _Description, _Marked FROM dbo.[{table}]" for table in selected ) def access_resolve_user_names(base_id: str, refs: set[str], diagnostics: dict[str, Any] | None = None, *, timeout_seconds: int = 60, max_refs: int = 20000) -> dict[str, str]: normalized_refs = sorted({access_ref_tail(ref) for ref in refs if re.fullmatch(r"[0-9A-F]{32}", access_ref_tail(ref) or "")})[:max_refs] if not normalized_refs: return {} sql_numbers = (((diagnostics or {}).get("metadata") or {}).get("sql_numbers") or {}) if isinstance(diagnostics, dict) else {} table_numbers: list[int] = [] for key in ("users", "external_users", "user_groups"): try: number = int(sql_numbers.get(key)) except (TypeError, ValueError): continue if number not in table_numbers: table_numbers.append(number) if not table_numbers: return {} table_unions: list[str] = [] for number in table_numbers: table_prefix = f"_Reference{number}" tables = access_sql_existing_tables(base_id, table_prefix, timeout_seconds=timeout_seconds) or [table_prefix] table_unions.append(access_reference_description_union_sql(tables)) conn, _, error = connect_live_sql(base_id, "access.user_names.resolve", timeout_seconds=timeout_seconds) if error: return {} where = ",".join(f"0x{ref}" for ref in normalized_refs) names: dict[str, str] = {} try: with conn: with conn.cursor(as_dict=True) as cursor: for union_sql in table_unions: cursor.execute( f""" SELECT {access_sql_hex('u._IDRRef')} AS id, u._Description AS name FROM ({union_sql}) u WHERE u._IDRRef IN ({where}) AND u._Marked = 0x00 """ ) for row in cursor.fetchall(): ref = access_ref_tail(row.get("id")) name = str(row.get("name") or "").strip() if ref and name and name != ref: names.setdefault(ref, name) except Exception: return names return names ACCESS_BSP_EXTRACTOR_PLAN_CACHE: dict[str, tuple[dict[str, str], dict[str, dict[str, str]], dict[str, Any]]] = {} def access_bsp_extractor_plan(base_id: str) -> tuple[dict[str, str] | None, dict[str, dict[str, str]] | None, dict[str, Any] | None]: cache_key = str(base_id or "") cached = ACCESS_BSP_EXTRACTOR_PLAN_CACHE.get(cache_key) if cached: queries, mappings, diagnostics = cached return queries, mappings, {**diagnostics, "cache": {"hit": True, "key": cache_key}} required = { "users": ("Catalog", "Пользователи"), "external_users": ("Catalog", "ВнешниеПользователи"), "groups": ("Catalog", "ГруппыДоступа"), "user_groups": ("Catalog", "ГруппыПользователей"), "profiles": ("Catalog", "ПрофилиГруппДоступа"), "metadata_identifiers": ("Catalog", "ИдентификаторыОбъектовМетаданных"), "access_keys": ("Catalog", "КлючиДоступа"), "access_sets": ("Catalog", "НаборыГруппДоступа"), "role_rights": ("InformationRegister", "ПраваРолей"), "access_group_keys": ("InformationRegister", "КлючиДоступаГруппДоступа"), "access_user_keys": ("InformationRegister", "КлючиДоступаПользователей"), "access_object_keys": ("InformationRegister", "КлючиДоступаКОбъектам"), "access_set_keys": ("InformationRegister", "КлючиДоступаНаборовГруппДоступа"), } profiles: dict[str, dict[str, Any]] = {} errors: dict[str, Any] = {} for key, (kind, name) in required.items(): profile, error = access_bsp_metadata_profile(base_id, kind, name) if error: errors[key] = error elif profile: profiles[key] = profile if errors: return None, None, {"status": "error", "diagnostics": {"message": "Could not resolve required BSP access metadata objects.", "errors": errors}} numbers = {key: access_object_sql_number(profile) for key, profile in profiles.items()} group_user_field = access_tabular_column_sql_number(profiles["groups"], "Пользователи", "Пользователь") profile_role_field = access_tabular_column_sql_number(profiles["profiles"], "Роли", "Роль") user_group_member_field = access_tabular_column_sql_number(profiles["user_groups"], "Состав", "Пользователь") missing = [key for key, value in {**numbers, "groups.Пользователи.Пользователь": group_user_field, "profiles.Роли.Роль": profile_role_field, "user_groups.Состав.Пользователь": user_group_member_field}.items() if value is None] if missing: return None, None, {"status": "error", "diagnostics": {"message": "Could not resolve required SQL numbers for BSP access metadata.", "missing": missing}} group_table = f"_Reference{numbers['groups']}" profile_table = f"_Reference{numbers['profiles']}" users_table = f"_Reference{numbers['users']}" external_users_table = f"_Reference{numbers['external_users']}" user_groups_table = f"_Reference{numbers['user_groups']}" users_union = access_reference_description_union_sql(access_sql_existing_tables(base_id, users_table, timeout_seconds=30) or [users_table]) external_users_union = access_reference_description_union_sql(access_sql_existing_tables(base_id, external_users_table, timeout_seconds=30) or [external_users_table]) user_groups_union = access_reference_description_union_sql(access_sql_existing_tables(base_id, user_groups_table, timeout_seconds=30) or [user_groups_table]) group_users_table = f"{group_table}_VT{int(group_user_field) - 2}" profile_roles_table = f"{profile_table}_VT{int(profile_role_field) - 2}" user_group_members_table = f"{user_groups_table}_VT{int(user_group_member_field) - 2}" rights_table = f"_InfoRg{numbers['role_rights']}" access_keys_table = f"_Reference{numbers['access_keys']}" access_sets_table = f"_Reference{numbers['access_sets']}" access_group_keys_table = f"_InfoRg{numbers['access_group_keys']}" access_user_keys_table = f"_InfoRg{numbers['access_user_keys']}" access_object_keys_table = f"_InfoRg{numbers['access_object_keys']}" access_set_keys_table = f"_InfoRg{numbers['access_set_keys']}" identifier_table = f"_Reference{numbers['metadata_identifiers']}" identifier_tables = access_sql_existing_tables(base_id, identifier_table, timeout_seconds=30) or [identifier_table] identifier_union = access_identifier_union_sql(identifier_tables) rights_actions_expr = " + ".join( f"CASE WHEN rr._Fld{field_number} = 0x01 THEN N'{ACCESS_BSP_RIGHT_FIELD_LABELS[field_number]},' ELSE N'' END" for field_number in ACCESS_BSP_RIGHT_FIELD_LABELS ) rights_source_fields_expr = " + ".join( f"CASE WHEN rr._Fld{field_number} = 0x01 THEN '_Fld{field_number},' ELSE '' END" for field_number in ACCESS_BSP_RIGHT_FIELD_LABELS ) queries = { "users": f""" SELECT CONCAT('{int(numbers['users']):08X}', ':', {access_sql_hex('u._IDRRef')}) AS id, u._Description AS name, CAST(1 AS bit) AS active, CASE WHEN u._Marked = 0x01 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END AS marked, N'user' AS user_type, CAST(0 AS bit) AS service, CAST(0 AS bit) AS administrator FROM ({users_union}) u WHERE u._Marked = 0x00 UNION ALL SELECT CONCAT('{int(numbers['external_users']):08X}', ':', {access_sql_hex('eu._IDRRef')}) AS id, eu._Description AS name, CAST(1 AS bit) AS active, CASE WHEN eu._Marked = 0x01 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END AS marked, N'external_user' AS user_type, CAST(0 AS bit) AS service, CAST(0 AS bit) AS administrator FROM ({external_users_union}) eu WHERE eu._Marked = 0x00 UNION ALL SELECT CONCAT('{int(numbers['user_groups']):08X}', ':', {access_sql_hex('ug._IDRRef')}) AS id, ug._Description AS name, CAST(1 AS bit) AS active, CASE WHEN ug._Marked = 0x01 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END AS marked, N'user_group' AS user_type, CAST(0 AS bit) AS service, CAST(0 AS bit) AS administrator FROM ({user_groups_union}) ug WHERE ug._Marked = 0x00 """, "groups": f""" SELECT {access_sql_hex('g._IDRRef')} AS id, g._Description AS name, {access_sql_hex('g._Fld14123RRef')} AS profile FROM dbo.[{group_table}] g WHERE g._Marked = 0x00 """, "profiles": f""" SELECT {access_sql_hex('p._IDRRef')} AS id, p._Description AS name FROM dbo.[{profile_table}] p WHERE p._Marked = 0x00 """, "group_users": f""" SELECT {access_sql_hex('gu._Reference' + str(numbers['groups']) + '_IDRRef')} AS [group], {access_sql_ref_expr('gu', '_Fld' + str(group_user_field))} AS [user] FROM dbo.[{group_users_table}] gu """, "profile_roles": f""" SELECT {access_sql_hex('pr._Reference' + str(numbers['profiles']) + '_IDRRef')} AS [profile], {access_sql_ref_expr('pr', '_Fld' + str(profile_role_field))} AS [role] FROM dbo.[{profile_roles_table}] pr """, "roles": f""" SELECT DISTINCT {access_sql_ref_expr('pr', '_Fld' + str(profile_role_field))} AS id, COALESCE(role_ident._Description, {access_sql_ref_expr('pr', '_Fld' + str(profile_role_field))}) AS name FROM dbo.[{profile_roles_table}] pr LEFT JOIN ({identifier_union}) role_ident ON role_ident._IDRRef = pr._Fld{profile_role_field}_RRRef """, "group_profiles": f""" SELECT {access_sql_hex('g._IDRRef')} AS [group], {access_sql_hex('g._Fld14123RRef')} AS [profile] FROM dbo.[{group_table}] g WHERE g._Marked = 0x00 AND g._Fld14123RRef <> 0x00000000000000000000000000000000 """, "user_group_members": f""" SELECT {access_sql_hex('ug._Reference' + str(numbers['user_groups']) + '_IDRRef')} AS [group], {access_sql_hex('ug._Fld' + str(user_group_member_field) + 'RRef')} AS [user] FROM dbo.[{user_group_members_table}] ug """, "role_permissions": f""" SELECT CONCAT('000000C4:', {access_sql_hex('rr._Fld27939RRef')}) AS [role], CONCAT('000000C4:', {access_sql_hex('rr._Fld27938RRef')}) AS [object], role_ident._Description AS role_name, object_ident._Description AS object_name, {rights_actions_expr} AS actions, {rights_source_fields_expr} AS source_fields FROM dbo.[{rights_table}] rr LEFT JOIN ({identifier_union}) role_ident ON role_ident._IDRRef = rr._Fld27939RRef LEFT JOIN ({identifier_union}) object_ident ON object_ident._IDRRef = rr._Fld27938RRef WHERE rr._Fld27940 = 0x01 OR rr._Fld27941 = 0x01 OR rr._Fld27942 = 0x01 OR rr._Fld27943 = 0x01 OR rr._Fld27944 = 0x01 OR rr._Fld27945 = 0x01 OR rr._Fld27946 = 0x01 OR rr._Fld27947 = 0x01 """, "access_group_keys": f""" SELECT {access_sql_hex('gk._Fld25997_RRRef')} AS [group], {access_sql_ref_expr('gk', '_Fld25997')} AS group_ref, g._Description AS group_name, {access_sql_hex('gk._Fld25998RRef')} AS access_key, k._Description AS access_key_name, k._Fld15806 AS access_key_hash, k._Fld15807 AS access_key_field_mask FROM dbo.[{access_group_keys_table}] gk LEFT JOIN dbo.[{group_table}] g ON g._IDRRef = gk._Fld25997_RRRef LEFT JOIN dbo.[{access_keys_table}] k ON k._IDRRef = gk._Fld25998RRef """, "access_user_keys": f""" SELECT {access_sql_hex('uk._Fld26026RRef')} AS user_set, ns._Description AS user_set_name, {access_sql_ref_expr('ns', '_Fld16819')} AS [user], {access_sql_hex('uk._Fld26027RRef')} AS access_key, k._Description AS access_key_name, k._Fld15806 AS access_key_hash, k._Fld15807 AS access_key_field_mask FROM dbo.[{access_user_keys_table}] uk LEFT JOIN dbo.[{access_sets_table}] ns ON ns._IDRRef = uk._Fld26026RRef LEFT JOIN dbo.[{access_keys_table}] k ON k._IDRRef = uk._Fld26027RRef """, "access_object_keys": f""" SELECT {access_sql_ref_expr('ok', '_Fld26004')} AS object, {access_sql_hex('ok._Fld26004_RTRef')} AS object_type_code, CONVERT(int, ok._Fld26004_RTRef) AS object_sql_number, {access_sql_hex('ok._Fld26004_RRRef')} AS object_id, {access_sql_hex('ok._Fld26005RRef')} AS access_key, {access_sql_hex('ok._Fld26006RRef')} AS access_key_value, k._Description AS access_key_name, k._Fld15806 AS access_key_hash, k._Fld15807 AS access_key_field_mask FROM dbo.[{access_object_keys_table}] ok LEFT JOIN dbo.[{access_keys_table}] k ON k._IDRRef = ok._Fld26005RRef """, "access_set_keys": f""" SELECT {access_sql_hex('sk._Fld26019RRef')} AS access_set, ns._Description AS access_set_name, {access_sql_hex('sk._Fld26020RRef')} AS access_key, k._Description AS access_key_name, k._Fld15806 AS access_key_hash, k._Fld15807 AS access_key_field_mask FROM dbo.[{access_set_keys_table}] sk LEFT JOIN dbo.[{access_sets_table}] ns ON ns._IDRRef = sk._Fld26019RRef LEFT JOIN dbo.[{access_keys_table}] k ON k._IDRRef = sk._Fld26020RRef """, } mappings = { "users": {"id": "id", "name": "name", "active": "active", "marked": "marked", "user_type": "user_type", "service": "service", "administrator": "administrator"}, "groups": {"id": "id", "name": "name"}, "profiles": {"id": "id", "name": "name"}, "roles": {"id": "id", "name": "name"}, "group_users": {"group": "group", "user": "user"}, "group_profiles": {"group": "group", "profile": "profile"}, "profile_roles": {"profile": "profile", "role": "role"}, "user_group_members": {"group": "group", "user": "user"}, "role_permissions": {"role": "role", "object": "object", "role_name": "role_name", "object_name": "object_name", "actions": "actions", "source_fields": "source_fields"}, "access_group_keys": {"group": "group", "group_ref": "group_ref", "group_name": "group_name", "access_key": "access_key", "access_key_name": "access_key_name", "access_key_hash": "access_key_hash", "access_key_field_mask": "access_key_field_mask"}, "access_user_keys": {"user_set": "user_set", "user_set_name": "user_set_name", "user": "user", "access_key": "access_key", "access_key_name": "access_key_name", "access_key_hash": "access_key_hash", "access_key_field_mask": "access_key_field_mask"}, "access_object_keys": {"object": "object", "object_type_code": "object_type_code", "object_sql_number": "object_sql_number", "object_id": "object_id", "access_key": "access_key", "access_key_value": "access_key_value", "access_key_name": "access_key_name", "access_key_hash": "access_key_hash", "access_key_field_mask": "access_key_field_mask"}, "access_set_keys": {"access_set": "access_set", "access_set_name": "access_set_name", "access_key": "access_key", "access_key_name": "access_key_name", "access_key_hash": "access_key_hash", "access_key_field_mask": "access_key_field_mask"}, } diagnostics = { "preset": "bsp", "metadata": { "sql_numbers": numbers, "tables": { "users": users_table, "external_users": external_users_table, "groups": group_table, "profiles": profile_table, "group_users": group_users_table, "profile_roles": profile_roles_table, "user_group_members": user_group_members_table, "role_permissions": rights_table, "metadata_identifiers": identifier_tables, "access_keys": access_keys_table, "access_sets": access_sets_table, "access_group_keys": access_group_keys_table, "access_user_keys": access_user_keys_table, "access_object_keys": access_object_keys_table, "access_set_keys": access_set_keys_table, }, "fields": { "groups.Пользователи.Пользователь": group_user_field, "profiles.Роли.Роль": profile_role_field, "user_groups.Состав.Пользователь": user_group_member_field, "role_permissions.flags": [f"_Fld{number}" for number in ACCESS_BSP_RIGHT_FIELD_LABELS], }, "permission_action_labels": { "status": "adapter_mapping", "mapping": {f"_Fld{number}": label for number, label in ACCESS_BSP_RIGHT_FIELD_LABELS.items()}, "message": "BSP ПраваРолей flag labels are decoded by the adapter mapping; source_fields are kept in permissions for live-base verification.", }, }, } ACCESS_BSP_EXTRACTOR_PLAN_CACHE[cache_key] = (queries, mappings, diagnostics) return queries, mappings, {**diagnostics, "cache": {"hit": False, "key": cache_key}} def access_snapshot_discover(base_id: str, *, limit: int, timeout_seconds: int) -> dict[str, Any]: conn, config, error = connect_live_sql(base_id, "access.snapshot.extract", timeout_seconds=timeout_seconds) if error: return error rows: list[dict[str, Any]] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( """ SELECT TOP (%d) c.TABLE_SCHEMA AS schema_name, c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS c WHERE c.TABLE_NAME LIKE N'%%Пользовател%%' OR c.TABLE_NAME LIKE N'%%Групп%%' OR c.TABLE_NAME LIKE N'%%Профил%%' OR c.TABLE_NAME LIKE N'%%Рол%%' OR c.TABLE_NAME LIKE N'%%Доступ%%' OR c.TABLE_NAME LIKE N'%%Прав%%' OR c.COLUMN_NAME LIKE N'%%Пользовател%%' OR c.COLUMN_NAME LIKE N'%%Групп%%' OR c.COLUMN_NAME LIKE N'%%Профил%%' OR c.COLUMN_NAME LIKE N'%%Рол%%' OR c.COLUMN_NAME LIKE N'%%Доступ%%' OR c.COLUMN_NAME LIKE N'%%Прав%%' ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION """ % max(1, min(limit * 50, 5000)) ) rows = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] except Exception as exc: return { "schema": "onec_access_snapshot_extract.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database")}, "diagnostics": {"message": str(exc)}, } grouped: dict[str, list[str]] = {} for row in rows: key = f"{row.get('schema_name')}.{row.get('table_name')}" grouped.setdefault(key, []).append(str(row.get("column_name") or "")) candidates = [] for table_ref, columns in grouped.items(): score, reasons = access_schema_candidate_score(table_ref, columns) if score > 0: candidates.append({"table": table_ref, "score": score, "reasons": reasons, "columns": columns[:50]}) candidates.sort(key=lambda item: (-int(item["score"]), str(item["table"]))) return { "schema": "onec_access_snapshot_extract.v1", "status": "discovery", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database")}, "access": {"users": [], "groups": [], "profiles": [], "roles": [], "data_restrictions": []}, "candidates": candidates[:limit], "counts": {"candidate_tables": len(candidates), "scanned_columns": len(rows)}, "diagnostics": { "message": "No extractor queries were provided. Review candidates and pass queries+mappings to build a normalized access snapshot.", "required_areas": ["users", "groups", "profiles", "roles", "group_users", "group_profiles", "profile_roles", "role_permissions", "data_restrictions"], }, } def access_snapshot_extract(payload: dict[str, Any]) -> dict[str, Any]: method = "access.snapshot.extract" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error max_effective_permissions, max_effective_permissions_error = parse_int_argument( payload, "max_effective_permissions_per_user", method=method, default=5000, minimum=0, maximum=200000, ) if max_effective_permissions_error: return max_effective_permissions_error resolve_identifiers, resolve_identifiers_error = strict_bool_argument(payload, "resolve_identifiers", method=method, default=True) if resolve_identifiers_error: return resolve_identifiers_error preset = str(payload.get("preset") or payload.get("profile") or "").strip().casefold() if not preset and payload.get("queries") is None: preset = "bsp" if preset in {"bsp", "бсп"}: queries, mappings, preset_diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: result = access_snapshot_discover(base_id, limit=int(limit or 200), timeout_seconds=int(timeout_seconds or 30)) result["status"] = "partial" if result.get("status") == "discovery" else result.get("status") result["preset"] = "bsp" result["diagnostics"] = { **(result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else {}), "preset_error": (preset_diagnostics or {}).get("diagnostics") or preset_diagnostics, } return result payload = {**payload, "queries": queries, "mappings": mappings} payload_diagnostics = preset_diagnostics else: payload_diagnostics = None queries = payload.get("queries") if queries is None: return access_snapshot_discover(base_id, limit=int(limit or 200), timeout_seconds=int(timeout_seconds or 30)) if not isinstance(queries, dict): return invalid_argument(method, "queries", "queries must be a JSON object keyed by extractor area.") mappings = payload.get("mappings") if isinstance(payload.get("mappings"), dict) else {} rows_by_area: dict[str, list[dict[str, Any]]] = {} diagnostics: dict[str, Any] = {"extractors": {}} for area, query in queries.items(): if not isinstance(query, str) or not query.strip(): return invalid_argument(method, f"queries.{area}", "Each extractor query must be a non-empty JSON string.") rows, error, truncated = access_rows_from_query(base_id, query, limit=int(limit or 1000), timeout_seconds=int(timeout_seconds or 30)) if error: return error rows_by_area[str(area)] = rows diagnostics["extractors"][str(area)] = {"rows": len(rows), "limit": int(limit or 1000), "truncated": bool(truncated)} access = access_snapshot_from_extractor_rows(rows_by_area, mappings) graph = build_access_graph_from_snapshot( access, base_id=base_id, max_permissions_per_user=max_effective_permissions, resolve_identifiers=bool(resolve_identifiers), ) return { "schema": "onec_access_snapshot_extract.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "mode": "explicit_readonly_extractors"}, **({"preset": preset} if preset else {}), "access": access, "graph": graph, "counts": graph.get("counts"), "diagnostics": {**diagnostics, **(payload_diagnostics or {})}, } ACCESS_KEY_QUERY_AREAS = { "group": "access_group_keys", "groups": "access_group_keys", "access_group": "access_group_keys", "access_group_keys": "access_group_keys", "user": "access_user_keys", "users": "access_user_keys", "user_set": "access_user_keys", "access_user_keys": "access_user_keys", "object": "access_object_keys", "objects": "access_object_keys", "access_object_keys": "access_object_keys", "set": "access_set_keys", "access_set": "access_set_keys", "access_set_keys": "access_set_keys", } ACCESS_RECORD_TABLE_PREFIXES = [ "_Reference", "_Document", "_BPr", "_Task", "_Enum", "_Acc", "_CKinds", "_Chrc", "_Node", ] def access_sql_string_literal(value: Any) -> str: return "N'" + str(value or "").replace("'", "''") + "'" def access_record_table_candidates(sql_number: int) -> list[str]: return [f"{prefix}{sql_number}" for prefix in ACCESS_RECORD_TABLE_PREFIXES] def normalize_access_object_record_filters( payload: dict[str, Any], method: str, base_id: str, *, timeout_seconds: int, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]: """Resolve public metadata/record refs without overloading access.keys.query kind.""" argument_error = validate_optional_string_arguments(payload, method, ["object_ref", "record_ref"]) if argument_error: return payload, {}, argument_error normalized = dict(payload) resolution: dict[str, Any] = {} object_ref = str(payload.get("object_ref") or "").strip() if object_ref: schema_result = data_object_schema( { "base_id": base_id, "object_ref": object_ref, "timeout_seconds": timeout_seconds, } ) if schema_result.get("status") != "ok": error = dict(schema_result) error["method"] = method error.setdefault("argument", "object_ref") return payload, {}, error object_card = schema_result.get("object") if isinstance(schema_result.get("object"), dict) else {} object_kind = str(object_card.get("kind") or "") table = schema_result.get("table") if isinstance(schema_result.get("table"), dict) else {} table_name = str(table.get("name") or "") expected_prefix = DATA_TABLE_PREFIXES.get(object_kind) table_match = re.fullmatch(rf"{re.escape(expected_prefix or '')}(\d+)", table_name) if expected_prefix else None if not table_match: return payload, {}, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "unsupported_kind", "error": "unsupported_kind", "argument": "object_ref", "base_id": base_id, "object": object_card, "diagnostics": { "message": "The selected metadata object has no BSP application-record route.", }, } object_sql_number = int(table_match.group(1)) explicit_sql_number = payload.get("object_sql_number") if explicit_sql_number not in {None, ""}: try: explicit_sql_number = int(explicit_sql_number) except (TypeError, ValueError): return payload, {}, invalid_argument(method, "object_sql_number", "object_sql_number must be an integer.") if explicit_sql_number != object_sql_number: return payload, {}, invalid_argument( method, "object_sql_number", "object_sql_number conflicts with the metadata object resolved from object_ref.", ) normalized["object_sql_number"] = object_sql_number resolution["metadata_object"] = { "input": object_ref, "ref": object_selector_ref(object_kind, object_card.get("name")) or object_ref, "kind": object_kind or None, "name": object_card.get("name"), "guid": object_card.get("guid"), "object_sql_number": object_sql_number, } record_ref = str(payload.get("record_ref") or "").strip() if record_ref: record_parts = access_identifier_parts(record_ref) if not record_parts: return payload, {}, invalid_argument( method, "record_ref", "record_ref must contain exactly one 32-hex-character 1C application-data reference.", ) record_id = record_parts["ref"] explicit_object_id = payload.get("object_id") if explicit_object_id not in {None, ""}: explicit_record_id = access_ref_tail(explicit_object_id) if explicit_record_id != record_id: return payload, {}, invalid_argument( method, "object_id", "object_id conflicts with record_ref.", ) normalized["object_id"] = record_id resolution["data_record"] = { "input": record_ref, "record_ref": record_id, **({"type_code": record_parts["type_code"]} if record_parts.get("type_code") else {}), } return normalized, resolution, None def access_resolve_object_key_records(base_id: str, rows: list[dict[str, Any]], *, timeout_seconds: int = 60, max_records: int = 200) -> dict[str, Any]: wanted: dict[int, set[str]] = {} for row in rows: try: sql_number = int(row.get("object_sql_number")) except (TypeError, ValueError): continue object_id = access_ref_tail(row.get("object_id") or row.get("object")) if re.fullmatch(r"[0-9A-F]{32}", object_id or ""): wanted.setdefault(sql_number, set()).add(object_id) if not wanted: return {"rows": rows, "diagnostics": {"resolved": 0, "requested": 0, "tables": {}}} conn, _, error = connect_live_sql(base_id, "access.object_keys.resolve", timeout_seconds=timeout_seconds) if error: return {"rows": rows, "diagnostics": {"resolved": 0, "requested": sum(len(values) for values in wanted.values()), "error": error}} table_info: dict[int, dict[str, Any]] = {} resolved: dict[tuple[int, str], dict[str, Any]] = {} try: with conn: with conn.cursor(as_dict=True) as cursor: all_candidates = [table for number in wanted for table in access_record_table_candidates(number)] if all_candidates: placeholders = ",".join(["%s"] * len(all_candidates)) cursor.execute( f""" SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME IN ({placeholders}) ORDER BY TABLE_NAME, ORDINAL_POSITION """, tuple(all_candidates), ) columns_by_table: dict[str, set[str]] = {} for row in cursor.fetchall(): columns_by_table.setdefault(str(row.get("TABLE_NAME") or ""), set()).add(str(row.get("COLUMN_NAME") or "")) for number in wanted: for table in access_record_table_candidates(number): columns = columns_by_table.get(table) or set() if "_IDRRef" not in columns: continue presentation_columns = [column for column in ("_Description", "_Number", "_Date_Time") if column in columns] table_info[number] = {"table": table, "columns": sorted(columns), "presentation_columns": presentation_columns} break remaining_budget = max(0, max_records) for number, ids in wanted.items(): info = table_info.get(number) if not info or remaining_budget <= 0: continue selected_ids = sorted(ids)[:remaining_budget] remaining_budget -= len(selected_ids) table = str(info["table"]) select_parts = [f"CONVERT(varchar(64), _IDRRef, 2) AS object_id"] if "_Description" in info.get("presentation_columns", []): select_parts.append("_Description AS description") if "_Number" in info.get("presentation_columns", []): select_parts.append("_Number AS number") if "_Date_Time" in info.get("presentation_columns", []): select_parts.append("_Date_Time AS date") if "_Marked" in info.get("columns", []): select_parts.append("CASE WHEN _Marked = 0x01 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END AS marked") where = ",".join(f"0x{object_id}" for object_id in selected_ids) cursor.execute(f"SELECT {', '.join(select_parts)} FROM dbo.[{table}] WHERE _IDRRef IN ({where})") for record in cursor.fetchall(): object_id = str(record.get("object_id") or "").upper() presentation = ( record.get("description") or " ".join(str(record.get(key) or "") for key in ("number", "date") if record.get(key) not in {None, ""}).strip() or object_id ) resolved[(number, object_id)] = { "table": table, "object_sql_number": number, "object_id": object_id, "presentation": presentation, **({"description": record.get("description")} if record.get("description") not in {None, ""} else {}), **({"number": record.get("number")} if record.get("number") not in {None, ""} else {}), **({"date": jsonable(record.get("date"))} if record.get("date") not in {None, ""} else {}), **({"marked": bool(record.get("marked"))} if record.get("marked") is not None else {}), } except Exception as exc: return {"rows": rows, "diagnostics": {"resolved": len(resolved), "requested": sum(len(values) for values in wanted.values()), "error": str(exc), "tables": table_info}} enriched: list[dict[str, Any]] = [] for row in rows: item = dict(row) try: sql_number = int(item.get("object_sql_number")) except (TypeError, ValueError): enriched.append(item) continue record = resolved.get((sql_number, access_ref_tail(item.get("object_id") or item.get("object")))) if record: item["object_record"] = record item["object_presentation"] = record.get("presentation") enriched.append(item) return { "rows": enriched, "diagnostics": { "requested": sum(len(values) for values in wanted.values()), "resolved": len(resolved), "max_records": max_records, "tables": {str(number): {"table": info.get("table"), "presentation_columns": info.get("presentation_columns")} for number, info in table_info.items()}, }, } def access_key_query_filters(payload: dict[str, Any], area: str) -> list[str]: filters: list[str] = [] area_columns = { "access_group_keys": ["group", "group_ref", "group_name", "access_key"], "access_user_keys": ["user_set", "user_set_name", "user", "access_key"], "access_object_keys": ["object", "object_type_code", "object_sql_number", "object_id", "access_key", "access_key_value"], "access_set_keys": ["access_set", "access_set_name", "access_key"], }.get(area, []) aliases = { "group_id": "group", "group": "group", "user": "user", "user_set": "user_set", "object": "object", "object_id": "object_id", "object_type_code": "object_type_code", "access_set": "access_set", "set": "access_set", "key": "access_key", "access_key": "access_key", } for argument, column in aliases.items(): if column not in area_columns or payload.get(argument) in {None, ""}: continue filters.append(f"q.[{column}] = {access_sql_string_literal(payload.get(argument))}") if "object_sql_number" in area_columns and payload.get("object_sql_number") not in {None, ""}: try: filters.append(f"q.[object_sql_number] = {int(payload.get('object_sql_number'))}") except (TypeError, ValueError): pass name_query = str(payload.get("query") or payload.get("name") or "").strip() if name_query: name_columns = [column for column in area_columns if column.endswith("_name")] if name_columns: like_value = access_sql_string_literal(f"%{name_query}%") filters.append("(" + " OR ".join(f"q.[{column}] LIKE {like_value}" for column in name_columns) + ")") return filters def access_key_query_run_area( base_id: str, area: str, query: str, mapping: dict[str, str], payload: dict[str, Any], *, limit: int, offset: int, timeout_seconds: int, ) -> dict[str, Any]: filters = access_key_query_filters(payload, area) where = (" WHERE " + " AND ".join(filters)) if filters else "" order_column = { "access_group_keys": "group", "access_user_keys": "user_set", "access_object_keys": "object", "access_set_keys": "access_set", }.get(area, "access_key") paged_query = f""" SELECT * FROM ({query}) q {where} ORDER BY q.[{order_column}], q.[access_key] OFFSET {offset} ROWS FETCH NEXT {limit + 1} ROWS ONLY """ rows, error, truncated_by_fetch = access_rows_from_query(base_id, paged_query, limit=limit + 1, timeout_seconds=timeout_seconds) if error: return error truncated = truncated_by_fetch or len(rows) > limit rows = rows[:limit] mapped = [access_map_row(row, mapping) for row in rows] return { "area": area, "rows": mapped, "counts": {"rows": len(mapped), "limit": limit, "offset": offset, "truncated": bool(truncated)}, "filters": { key: payload.get(key) for key in ( "group", "group_id", "user", "user_set", "object_ref", "record_ref", "object", "object_id", "object_sql_number", "access_set", "set", "key", "access_key", "query", "name", ) if payload.get(key) not in {None, ""} }, } def access_keys_query(payload: dict[str, Any]) -> dict[str, Any]: method = str(payload.get("_method") or "access.keys.query") base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error offset, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0, maximum=10000000) if offset_error: return offset_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=120) if timeout_error: return timeout_error resolve_records, resolve_records_error = strict_bool_argument(payload, "resolve_records", method=method, default=False) if resolve_records_error: return resolve_records_error max_resolved_records, max_resolved_records_error = parse_int_argument(payload, "max_resolved_records", method=method, default=200, minimum=0, maximum=5000) if max_resolved_records_error: return max_resolved_records_error raw_kind = str(payload.get("kind") or payload.get("area") or "all").strip().casefold() if method == "access.object_keys.resolve" and raw_kind in {"", "all", "*"}: raw_kind = "object" areas = list(ACCESS_KEY_QUERY_AREAS.values()) if raw_kind in {"", "all", "*"} else [ACCESS_KEY_QUERY_AREAS.get(raw_kind, "")] areas = [area for area in dict.fromkeys(areas) if area] if not areas: return invalid_argument(method, "kind", "kind must be one of group, user_set, object, set, or all.", allowed_values=["group", "user_set", "object", "set", "all"]) selector_resolution: dict[str, Any] = {} if "access_object_keys" in areas: normalized_payload, selector_resolution, selector_error = normalize_access_object_record_filters( payload, method, base_id, timeout_seconds=int(timeout_seconds), ) if selector_error: return selector_error payload = normalized_payload queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: return {"schema": "onec_access_keys_query.v1", "status": "error", "base_id": base_id, "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} results: dict[str, Any] = {} for area in areas: result = access_key_query_run_area( base_id, area, queries[area], mappings.get(area) if isinstance(mappings.get(area), dict) else {}, payload, limit=int(limit), offset=int(offset), timeout_seconds=int(timeout_seconds), ) if result.get("status") in {"error", "rejected", "invalid_argument"} or result.get("error"): return result if resolve_records and area == "access_object_keys": resolution = access_resolve_object_key_records( base_id, result.get("rows") if isinstance(result.get("rows"), list) else [], timeout_seconds=int(timeout_seconds), max_records=int(max_resolved_records), ) result["rows"] = resolution.get("rows") or [] result["record_resolution"] = resolution.get("diagnostics") results[area] = result return { "schema": "onec_access_object_keys_resolve.v1" if method == "access.object_keys.resolve" else "onec_access_keys_query.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, "areas": results, "counts": {area: result.get("counts") for area, result in results.items()}, **({"selector_resolution": selector_resolution} if selector_resolution else {}), "diagnostics": diagnostics, } def access_object_keys_resolve(payload: dict[str, Any]) -> dict[str, Any]: return access_keys_query({**payload, "_method": "access.object_keys.resolve", "kind": "object", "resolve_records": True}) def access_object_explain(payload: dict[str, Any]) -> dict[str, Any]: method = "access.object.explain" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if not any(payload.get(key) not in {None, ""} for key in ("object_ref", "record_ref", "object", "object_id", "access_key")): return invalid_argument( method, "record_ref", "Pass object_ref and/or record_ref, or a legacy raw object, object_id, or access_key filter.", ) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error subject_limit, subject_limit_error = parse_int_argument(payload, "subject_limit", method=method, default=1000, minimum=1, maximum=20000) if subject_limit_error: return subject_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error max_resolved_records, max_resolved_records_error = parse_int_argument(payload, "max_resolved_records", method=method, default=200, minimum=0, maximum=5000) if max_resolved_records_error: return max_resolved_records_error normalized_payload, selector_resolution, selector_error = normalize_access_object_record_filters( payload, method, base_id, timeout_seconds=int(timeout_seconds), ) if selector_error: return selector_error payload = normalized_payload queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: return {"schema": "onec_access_object_explain.v1", "status": "error", "base_id": base_id, "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} object_result = access_key_query_run_area( base_id, "access_object_keys", queries["access_object_keys"], mappings.get("access_object_keys") if isinstance(mappings.get("access_object_keys"), dict) else {}, payload, limit=int(limit), offset=int(payload.get("offset") if isinstance(payload.get("offset"), int) else 0), timeout_seconds=int(timeout_seconds), ) if object_result.get("error") or object_result.get("status") in {"error", "rejected", "invalid_argument"}: return object_result resolution = access_resolve_object_key_records( base_id, object_result.get("rows") if isinstance(object_result.get("rows"), list) else [], timeout_seconds=int(timeout_seconds), max_records=int(max_resolved_records), ) object_rows = resolution.get("rows") if isinstance(resolution.get("rows"), list) else [] access_key_ids = sorted({access_ref_tail(row.get("access_key")) for row in object_rows if row.get("access_key") not in {None, ""}}) subject_rows: dict[str, list[dict[str, Any]]] = {"access_group_keys": [], "access_user_keys": [], "access_set_keys": []} for access_key in access_key_ids[:100]: key_payload = {**payload, "access_key": access_key} for area in ("access_group_keys", "access_user_keys", "access_set_keys"): result = access_key_query_run_area( base_id, area, queries[area], mappings.get(area) if isinstance(mappings.get(area), dict) else {}, key_payload, limit=int(subject_limit), offset=0, timeout_seconds=int(timeout_seconds), ) if result.get("error") or result.get("status") in {"error", "rejected", "invalid_argument"}: return result subject_rows[area].extend(result.get("rows") if isinstance(result.get("rows"), list) else []) users_by_ref: dict[str, dict[str, Any]] = {} user_names_by_ref: dict[str, str] = {} for row in subject_rows["access_user_keys"]: user_ref = str(row.get("user") or "").strip() user_name = str(row.get("user_set_name") or "").strip() if user_ref and user_name: user_names_by_ref[access_ref_tail(user_ref)] = user_name if user_ref: users_by_ref[access_ref_tail(user_ref)] = {"id": user_ref, "name": user_name or user_ref, "source": "access_user_key", "access_key": row.get("access_key")} group_ids = sorted({access_ref_tail(row.get("group") or row.get("group_ref")) for row in subject_rows["access_group_keys"] if row.get("group") or row.get("group_ref")}) if group_ids: rows_by_area: dict[str, list[dict[str, Any]]] = {"groups": [], "group_users": [], "user_group_members": [], "users": []} for area in ("users", "groups", "group_users", "user_group_members"): rows, error, _ = access_rows_from_query(base_id, queries[area], limit=20000, timeout_seconds=int(timeout_seconds)) if error: return error rows_by_area[area] = rows mini_access = access_snapshot_from_extractor_rows(rows_by_area, mappings) groups_by_id = {access_ref_tail(group.get("id")): group for group in mini_access.get("groups") or [] if isinstance(group, dict)} users_by_id = {access_ref_tail(user.get("id")): user for user in mini_access.get("users") or [] if isinstance(user, dict)} wanted_user_refs = {access_ref_tail(user_ref) for group_id in group_ids for user_ref in (groups_by_id.get(group_id) or {}).get("users") or []} user_names_by_ref.update(access_resolve_user_names(base_id, wanted_user_refs, diagnostics, timeout_seconds=int(timeout_seconds), max_refs=int(subject_limit))) if wanted_user_refs and "access_user_keys" in queries: name_rows, name_error, _ = access_rows_from_query(base_id, queries["access_user_keys"], limit=20000, timeout_seconds=int(timeout_seconds)) if name_error: return name_error name_mapping = mappings.get("access_user_keys") if isinstance(mappings.get("access_user_keys"), dict) else {} for name_row in name_rows: mapped_name_row = access_map_row(name_row, name_mapping) user_ref = access_ref_tail(mapped_name_row.get("user")) user_name = str(mapped_name_row.get("user_set_name") or "").strip() if user_ref in wanted_user_refs and user_name: user_names_by_ref.setdefault(user_ref, user_name) for group_id in group_ids: group = groups_by_id.get(group_id) or {} for user_ref in group.get("users") or []: user = users_by_id.get(access_ref_tail(user_ref)) or {"id": user_ref, "name": user_ref} user_ref_tail = access_ref_tail(user_ref) user_name = user_names_by_ref.get(user_ref_tail) or user.get("name") or user_ref users_by_ref.setdefault(user_ref_tail, {"id": user.get("id") or user_ref, "name": user_name, "source": "access_group_key", "group": group.get("id") or group_id}) groups = sorted(subject_rows["access_group_keys"], key=lambda item: (str(item.get("group_name") or ""), str(item.get("group") or "")))[:subject_limit] user_sets = sorted(subject_rows["access_user_keys"], key=lambda item: (str(item.get("user_set_name") or ""), str(item.get("user") or "")))[:subject_limit] users = sorted(users_by_ref.values(), key=lambda item: str(item.get("name") or item.get("id")))[:subject_limit] summary = { "text": ( f"Object access explanation: {len(object_rows)} object key rows, " f"{len(access_key_ids)} access keys, {len(groups)} group sources, " f"{len(user_sets)} user-set sources, {len(users)} users resolved." ) } return { "schema": "onec_access_object_explain.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, "query": { key: payload.get(key) for key in ("object_ref", "record_ref", "object", "object_id", "object_sql_number", "access_key") if payload.get(key) not in {None, ""} }, **({"selector_resolution": selector_resolution} if selector_resolution else {}), "summary": summary, "object_keys": object_rows, "access_keys": access_key_ids, "groups": groups, "user_sets": user_sets, "users": users, "counts": { "object_keys": len(object_rows), "access_keys": len(access_key_ids), "groups": len(groups), "user_sets": len(user_sets), "users": len(users), }, "diagnostics": {"object_key_counts": object_result.get("counts"), "record_resolution": resolution.get("diagnostics"), "bsp": diagnostics}, } ACCESS_PERMISSION_ACTION_KEYS = { "просмотр": "read", "read": "read", "чтение": "read", "чтениебезограничения": "read", "view": "read", "добавление": "insert", "добавлениебезограничения": "insert", "insert": "insert", "create": "insert", "изменение": "update", "изменениебезограничения": "update", "update": "update", "write": "update", "запись": "write", "записи": "write", "edit": "update", "удаление": "delete", "удалениебезограничения": "delete", "delete": "delete", "remove": "delete", } def access_permission_action_key(value: Any) -> str: compact = re.sub(r"[^0-9a-zа-яё]+", "", str(value or "").strip().casefold()) return ACCESS_PERMISSION_ACTION_KEYS.get(compact, compact) def access_permission_is_unrestricted(value: Any) -> bool: return "безогранич" in str(value or "").strip().casefold() def access_action_filter_keys(value: Any) -> set[str]: key = access_permission_action_key(value) if not key: return set() if key == "write": return {"insert", "update"} return {key} def access_permission_matches_action(permission: dict[str, Any], filter_keys: set[str]) -> bool: if not filter_keys: return True return bool(set(access_permission_action_keys(permission)) & filter_keys) def access_permission_action_keys(permission: dict[str, Any]) -> list[str]: keys: list[str] = [] raw_actions = access_list(permission.get("actions")) if permission.get("actions") not in {None, ""} else access_list(permission.get("action")) for action in raw_actions: key = access_permission_action_key(action) if key and key not in keys: keys.append(key) return keys def access_permission_rights(permissions: list[dict[str, Any]]) -> dict[str, bool]: rights = {"read": False, "insert": False, "update": False, "delete": False} for permission in permissions: for key in access_permission_action_keys(permission): if key in rights: rights[key] = True return rights def access_permission_rights_detail(permissions: list[dict[str, Any]]) -> dict[str, dict[str, bool]]: detail = { "read": {"allowed": False, "unrestricted": False}, "insert": {"allowed": False, "unrestricted": False}, "update": {"allowed": False, "unrestricted": False}, "delete": {"allowed": False, "unrestricted": False}, } for permission in permissions: raw_actions = access_list(permission.get("actions")) if permission.get("actions") not in {None, ""} else access_list(permission.get("action")) for action in raw_actions: key = access_permission_action_key(action) if key not in detail: continue detail[key]["allowed"] = True if access_permission_is_unrestricted(action): detail[key]["unrestricted"] = True return detail def access_object_selector_from_card(payload: dict[str, Any], object_kind: str | None, object_card: dict[str, Any] | None) -> tuple[str, dict[str, Any]]: object_name = str((object_card or {}).get("name") or payload.get("name") or payload.get("object_name") or "").strip() object_synonym = str((object_card or {}).get("synonym") or "").strip() object_kind_ru = str((object_card or {}).get("kind_ru") or RU_KIND.get(str(object_kind or ""), object_kind or "") or "").strip() object_selector = ( object_selector_ref(object_kind, object_name) or str(payload.get("ref") or "").strip() or str(payload.get("name") or payload.get("object_name") or "").strip() ) object_payload = { "guid": (object_card or {}).get("guid"), "kind": object_kind, "kind_ru": object_kind_ru or None, "name": object_name or None, "synonym": object_synonym or None, "ref": object_selector or None, } return object_selector, object_payload def access_object_permission_candidates(permission: dict[str, Any]) -> set[str]: candidates: set[str] = set() def add(value: Any) -> None: text = str(value or "").strip() if text: candidates.add(text.casefold()) add(permission.get("object")) add(access_ref_tail(permission.get("object"))) add(permission.get("object_name")) add(permission.get("object_full_name")) resolution = permission.get("object_resolution") if isinstance(permission.get("object_resolution"), dict) else {} for key in ("guid", "name", "synonym", "full_name", "presentation"): add(resolution.get(key)) object_name = str(permission.get("object_name") or resolution.get("name") or "").strip() for kind_key in ("kind", "kind_ru", "public_kind"): object_kind = str(permission.get("object_kind") or resolution.get(kind_key) or "").strip() if object_kind and object_name: add(f"{object_kind}.{object_name}") return candidates def access_object_match_candidates(selector: str, object_payload: dict[str, Any] | None = None) -> set[str]: candidates: set[str] = set() def add(value: Any) -> None: text = str(value or "").strip() if text: candidates.add(text.casefold()) object_payload = object_payload if isinstance(object_payload, dict) else {} add(selector) add(access_ref_tail(selector)) for key in ("guid", "name", "synonym", "ref", "full_name", "presentation"): add(object_payload.get(key)) object_name = str(object_payload.get("name") or "").strip() object_synonym = str(object_payload.get("synonym") or "").strip() for kind_key in ("kind", "kind_ru", "public_kind"): object_kind = str(object_payload.get(kind_key) or "").strip() for name in (object_name, object_synonym): if object_kind and name: add(f"{object_kind}.{name}") add(f"{name} ({object_kind})") return candidates def access_object_role_permission_search_terms(object_payload: dict[str, Any]) -> list[str]: terms: list[str] = [] for key in ("synonym", "name"): value = str(object_payload.get(key) or "").strip() if len(value) >= 3 and value not in terms: terms.append(value) return terms def access_object_roles_fast_permissions( base_id: str, *, object_payload: dict[str, Any], object_selector: str, object_guid: str | None, action_filter_keys: set[str], timeout_seconds: int, limit: int, ) -> dict[str, Any]: queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or "role_permissions" not in queries: return {"status": "error", "diagnostics": diagnostics or {"message": "BSP role_permissions extractor is unavailable."}} terms = access_object_role_permission_search_terms(object_payload) if not terms: return {"status": "not_found", "terms": [], "roles": [], "diagnostics": diagnostics} like_filters = " OR ".join(f"q.[object_name] LIKE {access_sql_string_literal('%' + term + '%')}" for term in terms) query = f"SELECT * FROM ({queries['role_permissions']}) q WHERE ({like_filters})" rows, error, truncated = access_rows_from_query(base_id, query, limit=max(limit, 20000), timeout_seconds=timeout_seconds) if error: return {"status": "error", "diagnostics": error.get("diagnostics") or error} role_mapping = mappings.get("role_permissions") if isinstance(mappings.get("role_permissions"), dict) else None matched_roles_by_tail: dict[str, dict[str, Any]] = {} for row in rows: mapped = access_map_row(row, role_mapping) permission = { "object": mapped.get("object") or mapped.get("object_ref") or "*", **({"object_name": mapped.get("object_name")} if mapped.get("object_name") not in {None, ""} else {}), **({"actions": mapped.get("actions")} if mapped.get("actions") not in {None, ""} else {"action": mapped.get("action") or mapped.get("right") or "*"}), **({"source_field": mapped.get("source_field")} if mapped.get("source_field") not in {None, ""} else {}), **({"source_fields": mapped.get("source_fields")} if mapped.get("source_fields") not in {None, ""} else {}), } if not access_permission_matches_object(permission, selector=object_selector, object_guid=object_guid, object_payload=object_payload): continue if not access_permission_matches_action(permission, action_filter_keys): continue role_id = str(mapped.get("role") or mapped.get("role_id") or "") role_tail = access_ref_tail(role_id) if not role_tail: continue role = matched_roles_by_tail.setdefault( role_tail, { "id": role_id, "name": mapped.get("role_name") or role_id, "permissions": [], }, ) role["permissions"].append(permission) for role in matched_roles_by_tail.values(): permissions = [item for item in role.get("permissions") or [] if isinstance(item, dict)] role["rights"] = access_permission_rights(permissions) role["rights_detail"] = access_permission_rights_detail(permissions) role["source"] = "role_permissions_filtered" roles = sorted(matched_roles_by_tail.values(), key=lambda item: (str(item.get("name") or item.get("id")), str(item.get("id") or ""))) return { "status": "ok" if roles else "not_found", "roles": roles, "terms": terms, "truncated": truncated, "counts": {"rows": len(rows), "roles": len(roles), "permissions": sum(len(role.get("permissions") or []) for role in roles)}, "diagnostics": diagnostics, } def access_object_subjects_fast_chain( base_id: str, *, matched_roles: list[dict[str, Any]], timeout_seconds: int, limit: int, scope_subject_limit: int | None = None, ) -> dict[str, Any]: queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: return {"status": "error", "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} if not matched_roles: return {"status": "not_found", "roles": [], "profiles": [], "groups": [], "users": [], "counts": {"roles": 0, "profiles": 0, "groups": 0, "users": 0}} rows_by_area: dict[str, list[dict[str, Any]]] = {} extractor_counts: dict[str, Any] = {} for area in ("profiles", "profile_roles", "group_profiles", "groups", "group_users", "user_group_members", "users"): query = queries.get(area) if not query: rows_by_area[area] = [] extractor_counts[area] = {"rows": 0, "truncated": False, "skipped": True} continue rows, error, truncated = access_rows_from_query(base_id, query, limit=20000, timeout_seconds=int(timeout_seconds)) if error: return {"status": "error", "diagnostics": error.get("diagnostics") or error} rows_by_area[area] = rows extractor_counts[area] = {"rows": len(rows), "truncated": bool(truncated)} access = access_snapshot_from_extractor_rows(rows_by_area, mappings) role_tails = {access_ref_tail(role.get("id")) for role in matched_roles if access_ref_tail(role.get("id"))} profiles_by_id = {access_ref_tail(profile.get("id")): profile for profile in access.get("profiles") or [] if isinstance(profile, dict)} groups_by_id = {access_ref_tail(group.get("id")): group for group in access.get("groups") or [] if isinstance(group, dict)} users_by_id = {access_ref_tail(user.get("id")): user for user in access.get("users") or [] if isinstance(user, dict)} matched_profile_ids = { access_ref_tail(profile.get("id")) for profile in profiles_by_id.values() if {access_ref_tail(role_id) for role_id in profile.get("roles") or []} & role_tails } matched_group_ids = { access_ref_tail(group.get("id")) for group in groups_by_id.values() if {access_ref_tail(profile_id) for profile_id in group.get("profiles") or []} & matched_profile_ids } matched_group_ids.update( access_ref_tail(group.get("id")) for group in groups_by_id.values() if {access_ref_tail(role_id) for role_id in group.get("roles") or []} & role_tails ) matched_user_ids: set[str] = set() matched_user_ids.update( access_ref_tail(user.get("id")) for user in users_by_id.values() if {access_ref_tail(role_id) for role_id in user.get("roles") or []} & role_tails ) for group_id in matched_group_ids: group = groups_by_id.get(group_id) or {} for user_id in group.get("users") or []: user_tail = access_ref_tail(user_id) if user_tail: matched_user_ids.add(user_tail) matched_profiles = sorted( [profiles_by_id[profile_id] for profile_id in matched_profile_ids if profile_id in profiles_by_id], key=lambda item: str(item.get("name") or item.get("id")), ) matched_groups = sorted( [groups_by_id[group_id] for group_id in matched_group_ids if group_id in groups_by_id], key=lambda item: str(item.get("name") or item.get("id")), ) matched_roles_by_tail = {access_ref_tail(role.get("id")): role for role in matched_roles if access_ref_tail(role.get("id"))} role_names_by_tail = {role_tail: role.get("name") or role.get("id") for role_tail, role in matched_roles_by_tail.items()} matched_users: list[dict[str, Any]] = [] for user_id in matched_user_ids: user = users_by_id.get(user_id) or {"id": user_id, "name": user_id} user_groups: list[dict[str, Any]] = [] role_sources: list[dict[str, Any]] = [] user_role_tails: set[str] = set() for role_id in user.get("roles") or []: role_tail = access_ref_tail(role_id) if role_tail not in role_tails: continue source = { "type": "direct_user_role", "user": user.get("id") or user_id, "role": role_id, "role_name": role_names_by_tail.get(role_tail) or role_id, } if source not in role_sources: role_sources.append(source) user_role_tails.add(role_tail) for group_id in sorted(matched_group_ids): group = groups_by_id.get(group_id) or {} if user_id not in {access_ref_tail(item) for item in group.get("users") or []}: continue group_item = {"id": group.get("id") or group_id, "name": group.get("name") or group_id} if group_item not in user_groups: user_groups.append(group_item) for role_id in group.get("roles") or []: role_tail = access_ref_tail(role_id) if role_tail not in role_tails: continue source = { "type": "group_role", "group": group.get("id") or group_id, "group_name": group.get("name") or group_id, "role": role_id, "role_name": role_names_by_tail.get(role_tail) or role_id, } if source not in role_sources: role_sources.append(source) user_role_tails.add(role_tail) for profile_id in group.get("profiles") or []: profile_tail = access_ref_tail(profile_id) if profile_tail not in matched_profile_ids: continue profile = profiles_by_id.get(profile_tail) or {"id": profile_id, "name": profile_id} for role_id in profile.get("roles") or []: role_tail = access_ref_tail(role_id) if role_tail not in role_tails: continue source = { "type": "group_profile_role", "group": group.get("id") or group_id, "group_name": group.get("name") or group_id, "profile": profile.get("id") or profile_id, "profile_name": profile.get("name") or profile_id, "role": role_id, "role_name": role_names_by_tail.get(role_tail) or role_id, } if source not in role_sources: role_sources.append(source) user_role_tails.add(role_tail) user_permissions: list[dict[str, Any]] = [] user_roles: list[dict[str, Any]] = [] for role_tail in sorted(user_role_tails): role = matched_roles_by_tail.get(role_tail) if not role: continue user_roles.append({"id": role.get("id"), "name": role.get("name")}) for permission in role.get("permissions") or []: if isinstance(permission, dict) and permission not in user_permissions: user_permissions.append(permission) matched_users.append( { "id": user.get("id") or user_id, "name": user.get("name") or user_id, "active": user.get("active"), "marked": user.get("marked"), "user_type": user.get("user_type"), "user": user, "groups": user_groups, "roles": user_roles, "rights": access_permission_rights(user_permissions), "rights_detail": access_permission_rights_detail(user_permissions), "permissions": user_permissions, "role_sources": role_sources, } ) matched_users = sorted(matched_users, key=lambda item: str(item.get("name") or item.get("id"))) scope_limit = int(scope_subject_limit) if scope_subject_limit is not None else max(len(matched_groups), len(matched_users)) return { "status": "ok" if matched_roles or matched_users else "not_found", "roles": matched_roles[: int(limit)], "profiles": matched_profiles[: int(limit)], "groups": matched_groups[: int(limit)], "users": matched_users[: int(limit)], "_scope_groups": matched_groups[:scope_limit], "_scope_users": matched_users[:scope_limit], "counts": {"roles": len(matched_roles), "profiles": len(matched_profiles), "groups": len(matched_groups), "users": len(matched_user_ids)}, "diagnostics": {"extractors": extractor_counts, "bsp": diagnostics}, } def access_subject_access_key_scope( base_id: str, *, groups: list[dict[str, Any]], users: list[dict[str, Any]], total_groups: int | None = None, total_users: int | None = None, timeout_seconds: int, limit: int = 20000, sample_limit: int = 20, ) -> dict[str, Any]: queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: return {"status": "error", "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} group_ids = sorted({access_ref_tail(group.get("id")) for group in groups if isinstance(group, dict) and access_ref_tail(group.get("id"))}) user_ids = sorted({access_ref_tail(user.get("id") or ((user.get("user") or {}).get("id") if isinstance(user.get("user"), dict) else None)) for user in users if isinstance(user, dict) and access_ref_tail(user.get("id") or ((user.get("user") or {}).get("id") if isinstance(user.get("user"), dict) else None))}) rows: dict[str, list[dict[str, Any]]] = {"access_group_keys": [], "access_user_keys": []} extractor_counts: dict[str, Any] = {} if group_ids and queries.get("access_group_keys"): group_literals = ", ".join(access_sql_string_literal(group_id) for group_id in group_ids) group_ref_filters = " OR ".join(f"q.[group_ref] LIKE {access_sql_string_literal('%' + group_id)}" for group_id in group_ids) group_query = ( f"SELECT * FROM ({queries['access_group_keys']}) q " f"WHERE q.[group] IN ({group_literals}) OR " + group_ref_filters ) group_rows, error, truncated = access_rows_from_query(base_id, group_query, limit=limit, timeout_seconds=timeout_seconds) if error: return {"status": "error", "diagnostics": error.get("diagnostics") or error} mapping = mappings.get("access_group_keys") if isinstance(mappings.get("access_group_keys"), dict) else None rows["access_group_keys"] = [access_map_row(row, mapping) for row in group_rows] extractor_counts["access_group_keys"] = {"rows": len(group_rows), "limit": limit, "truncated": bool(truncated)} if user_ids and queries.get("access_user_keys"): user_like = " OR ".join(f"q.[user] LIKE {access_sql_string_literal('%' + user_id)}" for user_id in user_ids) user_query = f"SELECT * FROM ({queries['access_user_keys']}) q WHERE {user_like}" user_rows, error, truncated = access_rows_from_query(base_id, user_query, limit=limit, timeout_seconds=timeout_seconds) if error: return {"status": "error", "diagnostics": error.get("diagnostics") or error} mapping = mappings.get("access_user_keys") if isinstance(mappings.get("access_user_keys"), dict) else None rows["access_user_keys"] = [access_map_row(row, mapping) for row in user_rows] extractor_counts["access_user_keys"] = {"rows": len(user_rows), "limit": limit, "truncated": bool(truncated)} access_key_ids = sorted( { access_ref_tail(item.get("access_key")) for item in [*rows["access_group_keys"], *rows["access_user_keys"]] if item.get("access_key") not in {None, ""} } ) total_groups = len(group_ids) if total_groups is None else int(total_groups) total_users = len(user_ids) if total_users is None else int(total_users) coverage = { "groups_checked": len(group_ids), "groups_total": total_groups, "users_checked": len(user_ids), "users_total": total_users, "complete": len(group_ids) >= total_groups and len(user_ids) >= total_users, } return { "status": "ok", "kind": "subject_access_keys", "note": "BSP access keys restrict data records/dimensions for subjects; they are not metadata-object role permissions.", "counts": { "groups_checked": len(group_ids), "users_checked": len(user_ids), "group_key_rows": len(rows["access_group_keys"]), "user_key_rows": len(rows["access_user_keys"]), "subject_access_keys": len(access_key_ids), }, "coverage": coverage, "samples": { "group_keys": rows["access_group_keys"][:sample_limit], "user_keys": rows["access_user_keys"][:sample_limit], "access_keys": access_key_ids[:sample_limit], }, "truncated": any(bool(item.get("truncated")) for item in extractor_counts.values() if isinstance(item, dict)), "diagnostics": {"extractors": extractor_counts, "bsp": diagnostics}, } def access_permission_matches_object( permission: dict[str, Any], *, selector: str, object_guid: str | None = None, object_payload: dict[str, Any] | None = None, ) -> bool: selector_text = str(selector or "").strip().casefold() if not selector_text and not object_guid: return False resolution = permission.get("object_resolution") if isinstance(permission.get("object_resolution"), dict) else {} if object_guid: if str(resolution.get("guid") or "").strip().lower() == object_guid: return True selector_tail = access_ref_tail(selector) object_match_candidates = access_object_match_candidates(selector, object_payload) object_match_normalized = {normalize(candidate) for candidate in object_match_candidates if normalize(candidate)} for candidate in access_object_permission_candidates(permission): candidate_normalized = normalize(candidate) if object_guid and candidate == object_guid: return True if selector_text and candidate == selector_text: return True if selector_text and "." in selector_text and candidate.endswith(f".{selector_text.split('.', 1)[1]}"): return True if selector_text and selector_text in candidate: return True if selector_tail and candidate == selector_tail.casefold(): return True for object_candidate in object_match_candidates: if object_candidate and (candidate == object_candidate or object_candidate in candidate or candidate in object_candidate): return True for object_candidate in object_match_normalized: if object_candidate and candidate_normalized and (candidate_normalized == object_candidate or object_candidate in candidate_normalized or candidate_normalized in object_candidate): return True return False def access_object_roles(payload: dict[str, Any]) -> dict[str, Any]: method = "access.object.roles" normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload base_id_or_error = require_base_id(normalized_payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if not has_object_selector(normalized_payload): return invalid_argument(method, "object", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) limit, limit_error = parse_int_argument(normalized_payload, "limit", method=method, default=200, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(normalized_payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error max_permissions, max_permissions_error = parse_int_argument(normalized_payload, "max_effective_permissions_per_user", method=method, default=0, minimum=0, maximum=200000) if max_permissions_error: return max_permissions_error action_filter = str(normalized_payload.get("action") or normalized_payload.get("right") or "").strip() action_filter_key = access_permission_action_key(action_filter) if action_filter else "" action_filter_keys = access_action_filter_keys(action_filter_key) object_guid, object_kind, object_card, object_error = resolve_object_guid( normalized_payload, base_id, timeout_seconds=int(timeout_seconds), method=method, ) if object_error: return object_error object_selector, object_payload = access_object_selector_from_card(normalized_payload, object_kind, object_card) object_payload["guid"] = object_guid fast_roles = access_object_roles_fast_permissions( base_id, object_payload=object_payload, object_selector=object_selector, object_guid=object_guid, action_filter_keys=action_filter_keys, timeout_seconds=int(timeout_seconds), limit=int(limit), ) if fast_roles.get("status") in {"ok", "not_found"} and int(max_permissions) <= 0: matched_roles = list(fast_roles.get("roles") or [])[: int(limit)] query_payload = {key: normalized_payload.get(key) for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "action") if normalized_payload.get(key) not in {None, ""}} summary_text = ( f"Object roles: {len(matched_roles)} roles matched" f"{' for action ' + action_filter_key if action_filter_key else ''}." ) return { "schema": "onec_access_object_roles.v1", "status": "ok" if matched_roles else "not_found", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp", "extraction": "role_permissions_filtered"}, "query": query_payload, "object": object_payload, "summary": {"text": summary_text}, "roles": matched_roles, "counts": { "roles": len(matched_roles), "permissions": sum(len(role.get("permissions") or []) for role in matched_roles), }, "extraction_counts": fast_roles.get("counts"), "diagnostics": {"fast_role_permissions": {key: fast_roles.get(key) for key in ("status", "terms", "truncated", "counts")}, "bsp": fast_roles.get("diagnostics")}, } extracted = access_snapshot_extract( { "base_id": base_id, "preset": "bsp", "limit": 20000, "timeout_seconds": int(timeout_seconds), "resolve_identifiers": True, "max_effective_permissions_per_user": int(max_permissions), } ) if extracted.get("status") != "ok": return {"schema": "onec_access_object_roles.v1", **extracted} access = extracted.get("access") if isinstance(extracted.get("access"), dict) else {} graph = extracted.get("graph") if isinstance(extracted.get("graph"), dict) else {} roles = [role for role in access.get("roles") or [] if isinstance(role, dict)] roles_by_tail: dict[str, dict[str, Any]] = { access_ref_tail(item.get("id")): item for item in roles if access_ref_tail(item.get("id")) } matched_roles_by_tail: dict[str, dict[str, Any]] = {} for role in roles: matched_permissions: list[dict[str, Any]] = [] for permission in access_list(role.get("permissions")): if not isinstance(permission, dict): continue if not access_permission_matches_object(permission, selector=object_selector, object_guid=object_guid, object_payload=object_payload): continue if not access_permission_matches_action(permission, action_filter_keys): continue matched_permissions.append(permission) if not matched_permissions: continue rights = access_permission_rights(matched_permissions) role_tail = access_ref_tail(role.get("id")) matched_roles_by_tail[role_tail] = { "id": role.get("id"), "name": role.get("name") or ((role.get("resolution") or {}).get("name") if isinstance(role.get("resolution"), dict) else None) or role.get("id"), **({"resolution": role.get("resolution")} if isinstance(role.get("resolution"), dict) else {}), "rights": rights, "rights_detail": access_permission_rights_detail(matched_permissions), "permissions": matched_permissions, } role_permissions_from_users: dict[str, list[dict[str, Any]]] = {} role_permission_keys_from_users: dict[str, set[str]] = {} for effective in graph.get("effective_users") or []: if not isinstance(effective, dict): continue for role in effective.get("roles") or []: if isinstance(role, dict) and access_ref_tail(role.get("id")): roles_by_tail.setdefault(access_ref_tail(role.get("id")), role) for permission in effective.get("permissions") or []: if not isinstance(permission, dict): continue if not access_permission_matches_object(permission, selector=object_selector, object_guid=object_guid, object_payload=object_payload): continue if not access_permission_matches_action(permission, action_filter_keys): continue for source in permission.get("sources") or []: if not isinstance(source, dict): continue role_tail = access_ref_tail(source.get("role")) if not role_tail: continue role_permissions = role_permissions_from_users.setdefault(role_tail, []) permission_key = json.dumps(permission, ensure_ascii=False, sort_keys=True, default=str) permission_keys = role_permission_keys_from_users.setdefault(role_tail, set()) if permission_key not in permission_keys: role_permissions.append(permission) permission_keys.add(permission_key) for role_tail, permissions in role_permissions_from_users.items(): if role_tail in matched_roles_by_tail or not permissions: continue role = roles_by_tail.get(role_tail) or {"id": role_tail, "name": role_tail} matched_roles_by_tail[role_tail] = { "id": role.get("id") or role_tail, "name": role.get("name") or ((role.get("resolution") or {}).get("name") if isinstance(role.get("resolution"), dict) else None) or role.get("id") or role_tail, **({"resolution": role.get("resolution")} if isinstance(role.get("resolution"), dict) else {}), "rights": access_permission_rights(permissions), "rights_detail": access_permission_rights_detail(permissions), "permissions": permissions, "source": "effective_permissions", } matched_roles = sorted(matched_roles_by_tail.values(), key=lambda item: (str(item.get("name") or item.get("id")), str(item.get("id") or "")))[: int(limit)] query_payload = {key: normalized_payload.get(key) for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "action") if normalized_payload.get(key) not in {None, ""}} summary_text = ( f"Object roles: {len(matched_roles)} roles matched" f"{' for action ' + action_filter_key if action_filter_key else ''}." ) return { "schema": "onec_access_object_roles.v1", "status": "ok" if matched_roles else "not_found", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp", "extraction": "access.snapshot.extract"}, "query": query_payload, "object": object_payload, "summary": {"text": summary_text}, "roles": matched_roles, "counts": { "roles": len(matched_roles), "permissions": sum(len(role.get("permissions") or []) for role in matched_roles), }, "extraction_counts": extracted.get("counts"), "diagnostics": extracted.get("diagnostics"), } def access_object_subjects(payload: dict[str, Any]) -> dict[str, Any]: method = "access.object.subjects" normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload base_id_or_error = require_base_id(normalized_payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if not has_object_selector(normalized_payload): return invalid_argument(method, "object", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) limit, limit_error = parse_int_argument(normalized_payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(normalized_payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error max_permissions, max_permissions_error = parse_int_argument(normalized_payload, "max_effective_permissions_per_user", method=method, default=0, minimum=0, maximum=200000) if max_permissions_error: return max_permissions_error include_access_key_scope = bool(normalized_payload.get("include_access_key_scope")) access_key_scope_limit, access_key_scope_limit_error = parse_int_argument(normalized_payload, "access_key_scope_limit", method=method, default=20000, minimum=1, maximum=200000) if access_key_scope_limit_error: return access_key_scope_limit_error access_key_scope_subject_limit, access_key_scope_subject_limit_error = parse_int_argument( normalized_payload, "access_key_scope_subject_limit", method=method, default=20000, minimum=1, maximum=200000, ) if access_key_scope_subject_limit_error: return access_key_scope_subject_limit_error action_filter = str(normalized_payload.get("action") or normalized_payload.get("right") or "").strip() action_filter_keys = access_action_filter_keys(action_filter) object_guid, object_kind, object_card, object_error = resolve_object_guid( normalized_payload, base_id, timeout_seconds=int(timeout_seconds), method=method, ) if object_error: return object_error object_selector, object_payload = access_object_selector_from_card(normalized_payload, object_kind, object_card) object_payload["guid"] = object_guid fast_roles = access_object_roles_fast_permissions( base_id, object_payload=object_payload, object_selector=object_selector, object_guid=object_guid, action_filter_keys=action_filter_keys, timeout_seconds=int(timeout_seconds), limit=20000, ) if fast_roles.get("status") in {"ok", "not_found"} and int(max_permissions) <= 0: fast_subjects = access_object_subjects_fast_chain( base_id, matched_roles=[role for role in fast_roles.get("roles") or [] if isinstance(role, dict)], timeout_seconds=int(timeout_seconds), limit=int(limit), scope_subject_limit=int(access_key_scope_subject_limit), ) query_payload = {key: normalized_payload.get(key) for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "action") if normalized_payload.get(key) not in {None, ""}} roles = [role for role in fast_subjects.get("roles") or [] if isinstance(role, dict)] profiles = [profile for profile in fast_subjects.get("profiles") or [] if isinstance(profile, dict)] groups = [group for group in fast_subjects.get("groups") or [] if isinstance(group, dict)] users = [user for user in fast_subjects.get("users") or [] if isinstance(user, dict)] scope_groups = [group for group in fast_subjects.get("_scope_groups") or groups if isinstance(group, dict)] scope_users = [user for user in fast_subjects.get("_scope_users") or users if isinstance(user, dict)] fast_subject_counts = fast_subjects.get("counts") if isinstance(fast_subjects.get("counts"), dict) else {} access_key_scope = ( access_subject_access_key_scope( base_id, groups=scope_groups, users=scope_users, total_groups=int(fast_subject_counts.get("groups") or len(groups)), total_users=int(fast_subject_counts.get("users") or len(users)), timeout_seconds=int(timeout_seconds), limit=int(access_key_scope_limit), ) if include_access_key_scope else None ) summary_text = ( f"Object subjects: {len(roles)} roles, {len(profiles)} profiles, " f"{len(groups)} groups, {len(users)} users" f"{' for action ' + access_permission_action_key(action_filter) if action_filter else ''}." ) return { "schema": "onec_access_object_subjects.v1", "status": "ok" if roles or users else "not_found", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp", "extraction": "role_profile_group_user_chain"}, "query": query_payload, "object": object_payload, "summary": {"text": summary_text}, "roles": roles, "profiles": profiles, "groups": groups, "users": users, **({"access_key_scope": access_key_scope} if access_key_scope is not None else {}), "counts": fast_subjects.get("counts") or {"roles": len(roles), "profiles": len(profiles), "groups": len(groups), "users": len(users)}, "extraction_counts": {"role_permissions": fast_roles.get("counts"), "subjects": fast_subjects.get("counts")}, "diagnostics": {"fast_role_permissions": {key: fast_roles.get(key) for key in ("status", "terms", "truncated", "counts")}, "fast_subjects": fast_subjects.get("diagnostics")}, } extracted = access_snapshot_extract( { "base_id": base_id, "preset": "bsp", "limit": 20000, "timeout_seconds": int(timeout_seconds), "resolve_identifiers": True, "max_effective_permissions_per_user": int(max_permissions), } ) if extracted.get("status") != "ok": return {"schema": "onec_access_object_subjects.v1", **extracted} access = extracted.get("access") if isinstance(extracted.get("access"), dict) else {} graph = extracted.get("graph") if isinstance(extracted.get("graph"), dict) else {} matched_roles: dict[str, dict[str, Any]] = {} roles_by_tail: dict[str, dict[str, Any]] = { access_ref_tail(item.get("id")): item for item in access.get("roles") or [] if isinstance(item, dict) and access_ref_tail(item.get("id")) } for role in [item for item in access.get("roles") or [] if isinstance(item, dict)]: matched_permissions: list[dict[str, Any]] = [] for permission in access_list(role.get("permissions")): if not isinstance(permission, dict): continue if not access_permission_matches_object(permission, selector=object_selector, object_guid=object_guid, object_payload=object_payload): continue if not access_permission_matches_action(permission, action_filter_keys): continue matched_permissions.append(permission) if matched_permissions: role_id = str(role.get("id") or "") matched_roles[access_ref_tail(role_id)] = { "id": role.get("id"), "name": role.get("name") or ((role.get("resolution") or {}).get("name") if isinstance(role.get("resolution"), dict) else None) or role.get("id"), **({"resolution": role.get("resolution")} if isinstance(role.get("resolution"), dict) else {}), "rights": access_permission_rights(matched_permissions), "rights_detail": access_permission_rights_detail(matched_permissions), "permissions": matched_permissions, } users: list[dict[str, Any]] = [] profile_ids: set[str] = set() group_ids: set[str] = set() role_ids_from_users: set[str] = set() role_permissions_from_users: dict[str, list[dict[str, Any]]] = {} role_permission_keys_from_users: dict[str, set[str]] = {} for effective in graph.get("effective_users") or []: if not isinstance(effective, dict): continue matched_permissions = [] user_role_ids: set[str] = set() for role in effective.get("roles") or []: if isinstance(role, dict) and access_ref_tail(role.get("id")): roles_by_tail.setdefault(access_ref_tail(role.get("id")), role) for permission in effective.get("permissions") or []: if not isinstance(permission, dict): continue if not access_permission_matches_object(permission, selector=object_selector, object_guid=object_guid, object_payload=object_payload): continue if not access_permission_matches_action(permission, action_filter_keys): continue matched_permissions.append(permission) for source in permission.get("sources") or []: if not isinstance(source, dict): continue role_tail = access_ref_tail(source.get("role")) if role_tail: role_ids_from_users.add(role_tail) user_role_ids.add(role_tail) role_permissions = role_permissions_from_users.setdefault(role_tail, []) permission_key = json.dumps(permission, ensure_ascii=False, sort_keys=True, default=str) permission_keys = role_permission_keys_from_users.setdefault(role_tail, set()) if permission_key not in permission_keys: role_permissions.append(permission) permission_keys.add(permission_key) for chain in source.get("chains") or []: if not isinstance(chain, dict): continue if chain.get("profile") not in {None, ""}: profile_ids.add(access_ref_tail(chain.get("profile"))) if chain.get("group") not in {None, ""}: group_ids.add(access_ref_tail(chain.get("group"))) if not matched_permissions: continue user = effective.get("user") if isinstance(effective.get("user"), dict) else {} users.append( { "id": user.get("id"), "name": user.get("name"), "active": user.get("active"), "marked": user.get("marked"), "user_type": user.get("user_type"), "user": user, "groups": effective.get("groups") or [], "roles": [ role for role in effective.get("roles") or [] if isinstance(role, dict) and access_ref_tail(role.get("id")) in user_role_ids ], "rights": access_permission_rights(matched_permissions), "rights_detail": access_permission_rights_detail(matched_permissions), "permissions": matched_permissions, "data_restrictions": effective.get("data_restrictions") or [], } ) if len(users) >= int(limit): break for role_tail, permissions in role_permissions_from_users.items(): if role_tail in matched_roles or not permissions: continue role = roles_by_tail.get(role_tail) or {"id": role_tail, "name": role_tail} matched_roles[role_tail] = { "id": role.get("id") or role_tail, "name": role.get("name") or ((role.get("resolution") or {}).get("name") if isinstance(role.get("resolution"), dict) else None) or role.get("id") or role_tail, **({"resolution": role.get("resolution")} if isinstance(role.get("resolution"), dict) else {}), "rights": access_permission_rights(permissions), "rights_detail": access_permission_rights_detail(permissions), "permissions": permissions, "source": "effective_permissions", } matched_role_tails = set(matched_roles) | role_ids_from_users profiles = [ profile for profile in access.get("profiles") or [] if isinstance(profile, dict) and ( access_ref_tail(profile.get("id")) in profile_ids or ({access_ref_tail(role_id) for role_id in profile.get("roles") or []} & matched_role_tails) ) ] profile_tails = {access_ref_tail(profile.get("id")) for profile in profiles} groups = [ group for group in access.get("groups") or [] if isinstance(group, dict) and ( access_ref_tail(group.get("id")) in group_ids or ({access_ref_tail(profile_id) for profile_id in group.get("profiles") or []} & profile_tails) ) ] roles = sorted(matched_roles.values(), key=lambda item: (str(item.get("name") or item.get("id")), str(item.get("id") or ""))) profiles = sorted(profiles, key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] groups = sorted(groups, key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] summary_text = ( f"Object subjects: {len(roles)} roles, {len(profiles)} profiles, " f"{len(groups)} groups, {len(users)} users" f"{' for action ' + access_permission_action_key(action_filter) if action_filter else ''}." ) query_payload = {key: normalized_payload.get(key) for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "action") if normalized_payload.get(key) not in {None, ""}} return { "schema": "onec_access_object_subjects.v1", "status": "ok" if roles or users else "not_found", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp", "extraction": "access.snapshot.extract"}, "query": query_payload, "object": object_payload, "summary": {"text": summary_text}, "roles": roles[: int(limit)], "profiles": profiles, "groups": groups, "users": users, "counts": { "roles": len(roles), "profiles": len(profiles), "groups": len(groups), "users": len(users), "permissions": sum(len(user.get("permissions") or []) for user in users), }, "extraction_counts": extracted.get("counts"), "diagnostics": extracted.get("diagnostics"), } ACCESS_RLS_DISCOVERY_TERMS = ("Огранич", "Доступ", "RLS", "Ключ") ACCESS_RLS_DISCOVERY_KINDS = ("InformationRegister", "Catalog") def access_rls_candidate_score(item: dict[str, Any]) -> tuple[int, list[str]]: text = " ".join(str(item.get(key) or "") for key in ("name", "synonym", "full_name", "kind", "kind_ru")).casefold() score = 0 reasons: list[str] = [] for term in ACCESS_RLS_DISCOVERY_TERMS: if term.casefold() in text: score += 10 reasons.append(f"name_contains:{term}") if "ключидоступа" in re.sub(r"[^0-9a-zа-яё]+", "", text): score += 20 reasons.append("known_bsp_access_keys") if "праваролей" in re.sub(r"[^0-9a-zа-яё]+", "", text): score += 15 reasons.append("known_bsp_role_rights") if "огранич" in text: score += 15 reasons.append("restriction_name") return score, reasons def access_rls_public_fields(attributes_result: dict[str, Any]) -> dict[str, Any]: fields: dict[str, Any] = {"dimensions": [], "resources": [], "attributes": [], "tabular_sections": []} for area in ("dimensions", "resources", "attributes"): for item in attributes_result.get(area) or []: if not isinstance(item, dict): continue fields[area].append( { "name": item.get("name"), "synonym": item.get("synonym"), "type": item.get("type"), **({"storage_routes": item.get("storage_routes")} if item.get("storage_routes") else {}), } ) for section in attributes_result.get("tabular_sections") or []: if not isinstance(section, dict): continue fields["tabular_sections"].append( { "name": section.get("name"), "synonym": section.get("synonym"), "columns": [ { "name": column.get("name"), "synonym": column.get("synonym"), "type": column.get("type"), **({"storage_routes": column.get("storage_routes")} if column.get("storage_routes") else {}), } for column in section.get("columns") or [] if isinstance(column, dict) ], **({"storage_routes": section.get("storage_routes")} if section.get("storage_routes") else {}), } ) return fields def access_rls_discover(payload: dict[str, Any]) -> dict[str, Any]: method = "access.rls.discover" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=500) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error terms_raw = payload.get("terms") if terms_raw is None or terms_raw == "": terms = list(ACCESS_RLS_DISCOVERY_TERMS) else: terms = [str(item).strip() for item in access_list(terms_raw) if str(item).strip()] if not terms: return invalid_argument(method, "terms", "terms must contain at least one non-empty string.") candidates_by_guid: dict[str, dict[str, Any]] = {} diagnostics: list[dict[str, Any]] = [] for kind in ACCESS_RLS_DISCOVERY_KINDS: for term in terms: listed = list_objects( kind, base_id=base_id, limit=int(limit), offset=0, include_storage=True, table="Config", name_filter=term, ) if listed.get("status") != "ok": diagnostics.append({"kind": kind, "term": term, "status": listed.get("status"), "diagnostics": listed.get("diagnostics")}) continue for item in listed.get("objects") or []: if not isinstance(item, dict) or item.get("guid") in {None, ""}: continue score, reasons = access_rls_candidate_score(item) if score <= 0: continue candidate = candidates_by_guid.setdefault(str(item.get("guid")), {**item, "score": 0, "reasons": []}) candidate["score"] = max(int(candidate.get("score") or 0), score) for reason in reasons: if reason not in candidate["reasons"]: candidate["reasons"].append(reason) candidates = sorted(candidates_by_guid.values(), key=lambda item: (-int(item.get("score") or 0), str(item.get("kind") or ""), str(item.get("name") or item.get("guid") or "")))[: int(limit)] detailed_candidates: list[dict[str, Any]] = [] for candidate in candidates: attributes = metadata_object_attributes( { "base_id": base_id, "kind": candidate.get("kind"), "name": candidate.get("name") or candidate.get("guid"), "guid": candidate.get("guid"), "include_storage": True, "only": "all", "limit": 200, "timeout_seconds": int(timeout_seconds), } ) item = { "guid": candidate.get("guid"), "kind": candidate.get("kind"), "kind_ru": candidate.get("kind_ru"), "name": candidate.get("name"), "synonym": candidate.get("synonym"), "score": candidate.get("score"), "reasons": candidate.get("reasons") or [], **({"storage": candidate.get("storage")} if candidate.get("storage") else {}), } if attributes.get("status") == "ok": item["fields"] = access_rls_public_fields(attributes) item["field_counts"] = {key: len(value) for key, value in item["fields"].items()} else: item["field_status"] = attributes.get("status") item["field_diagnostics"] = attributes.get("diagnostics") detailed_candidates.append(item) return { "schema": "onec_access_rls_discover.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"}, "query": {"terms": terms, "limit": int(limit)}, "candidates": detailed_candidates, "counts": {"candidates": len(detailed_candidates), "diagnostics": len(diagnostics)}, "diagnostics": diagnostics, } ACCESS_ROLE_QUERY_SYNONYMS = { "запись": ["добавление", "изменение"], "записи": ["добавление", "изменение"], "редактирование": ["изменение"], "изменить": ["изменение"], "создание": ["добавление"], "создать": ["добавление"], } def access_role_search_tokens(value: Any) -> list[str]: raw_tokens = re.findall(r"[\wА-Яа-яЁё]+", str(value or "").casefold()) tokens: list[str] = [] for token in raw_tokens: if len(token) < 3: continue candidates = [token, *ACCESS_ROLE_QUERY_SYNONYMS.get(token, [])] for candidate in list(candidates): stem = re.sub(r"(иями|ями|ами|ого|ему|ыми|ими|ией|иям|иях|иях|ий|ый|ой|ая|ое|ые|ых|ам|ям|ах|ях|ов|ев|ей|ом|ем|ою|ею|ою|у|ю|а|я|ы|и|е|о)$", "", candidate) if len(stem) >= 5 and stem not in candidates: candidates.append(stem) for candidate in candidates: if candidate not in tokens: tokens.append(candidate) return tokens def access_role_match_score(role: dict[str, Any], selector: str) -> int: role_id = str(role.get("id") or "") role_name = str(role.get("name") or "") selector_tail = access_ref_tail(selector) selector_text = selector.casefold() role_name_text = role_name.casefold() role_id_text = role_id.casefold() if access_ref_tail(role_id) == selector_tail or role_id_text == selector_text: return 10000 if role_name_text == selector_text: return 9000 if selector_text and selector_text in role_name_text: return 8000 + len(selector_text) tokens = access_role_search_tokens(selector) if not tokens: return 0 matched = [token for token in tokens if token in role_name_text] if not matched: return 0 score = len(matched) * 100 if len(matched) == len(tokens): score += 1000 score += min(50, sum(len(token) for token in matched)) return score def access_role_chain(base_id: str, role_selector: str, *, timeout_seconds: int = 120) -> dict[str, Any]: queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: return {"status": "error", "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} rows_by_area: dict[str, list[dict[str, Any]]] = {} extractor_counts: dict[str, Any] = {} for area in ("roles", "profiles", "profile_roles", "group_profiles", "groups", "group_users", "user_group_members", "users"): if area not in queries: rows_by_area[area] = [] extractor_counts[area] = {"rows": 0, "truncated": False, "skipped": True} continue rows, error, truncated = access_rows_from_query(base_id, queries[area], limit=20000, timeout_seconds=int(timeout_seconds)) if error: return error rows_by_area[area] = rows extractor_counts[area] = {"rows": len(rows), "truncated": bool(truncated)} access = access_snapshot_from_extractor_rows(rows_by_area, mappings) roles = [role for role in access.get("roles") or [] if isinstance(role, dict)] profiles = [profile for profile in access.get("profiles") or [] if isinstance(profile, dict)] groups = [group for group in access.get("groups") or [] if isinstance(group, dict)] users = [user for user in access.get("users") or [] if isinstance(user, dict)] scored_roles = [ {**role, "_match_score": access_role_match_score(role, role_selector)} for role in roles ] alternatives = sorted( [role for role in scored_roles if int(role.get("_match_score") or 0) > 0], key=lambda item: (-int(item.get("_match_score") or 0), str(item.get("name") or "")), )[:10] best_score = int(alternatives[0].get("_match_score") or 0) if alternatives else 0 matched_roles = [{key: value for key, value in role.items() if key != "_match_score"} for role in alternatives if int(role.get("_match_score") or 0) == best_score] if not matched_roles: return { "status": "not_found", "base_id": base_id, "query": {"role": role_selector}, "roles": [], "profiles": [], "groups": [], "users": [], "alternatives": [], "counts": {"roles": 0, "profiles": 0, "groups": 0, "users": 0}, "diagnostics": {"extractors": extractor_counts, "bsp": diagnostics}, } matched_role_ids = {access_ref_tail(role.get("id")) for role in matched_roles} matched_profiles = [ profile for profile in profiles if {access_ref_tail(role_id) for role_id in profile.get("roles") or []} & matched_role_ids ] matched_profile_ids = {access_ref_tail(profile.get("id")) for profile in matched_profiles} matched_groups = [ group for group in groups if {access_ref_tail(profile_id) for profile_id in group.get("profiles") or []} & matched_profile_ids ] graph = build_access_graph_from_snapshot(access, base_id=base_id, resolve_identifiers=False) profiles_by_id = {access_ref_tail(profile.get("id")): profile for profile in profiles} groups_by_id = {access_ref_tail(group.get("id")): group for group in groups} matched_profile_ids = set(matched_profile_ids) matched_group_ids = {access_ref_tail(group.get("id")) for group in matched_groups} users_by_id = {access_ref_tail(user.get("id")): user for user in users} matched_users_by_ref: dict[str, dict[str, Any]] = {} wanted_user_refs: set[str] = set() for effective in graph.get("effective_users") or []: if not isinstance(effective, dict): continue effective_user = effective.get("user") if isinstance(effective.get("user"), dict) else {} effective_user_tail = access_ref_tail(effective_user.get("id")) matched_role_sources: list[dict[str, Any]] = [] for role in effective.get("roles") or []: if not isinstance(role, dict) or access_ref_tail(role.get("id")) not in matched_role_ids: continue matched_role_sources.extend( [ {**source, "role": role.get("id"), "role_name": role.get("name")} for source in role.get("sources") or [] if isinstance(source, dict) ] ) if not matched_role_sources: continue for source in matched_role_sources: if source.get("profile") not in {None, ""}: matched_profile_ids.add(access_ref_tail(source.get("profile"))) if source.get("group") not in {None, ""}: matched_group_ids.add(access_ref_tail(source.get("group"))) for group_id in effective.get("groups") or []: group_tail = access_ref_tail(group_id) if group_tail: matched_group_ids.add(group_tail) if effective_user_tail: wanted_user_refs.add(effective_user_tail) user = users_by_id.get(effective_user_tail) or effective_user or {"id": effective_user_tail, "name": effective_user_tail} entry = matched_users_by_ref.setdefault( effective_user_tail, { "id": user.get("id") or effective_user.get("id"), "name": user.get("name") or effective_user.get("name") or user.get("id") or effective_user_tail, "active": user.get("active") if user.get("active") is not None else effective_user.get("active"), "groups": [], "role_sources": [], }, ) for source_key in ("marked", "user_type", "service", "administrator"): value = user.get(source_key) if value is None and source_key == "user_type": value = effective_user.get(source_key) if value is None and source_key in {"marked", "service", "administrator"} and effective_user.get(source_key) is True: value = True if value is not None: entry[source_key] = value for source in matched_role_sources: if source not in entry["role_sources"]: entry["role_sources"].append(source) for group_id in effective.get("groups") or []: group = groups_by_id.get(access_ref_tail(group_id)) or {"id": group_id, "name": group_id} group_item = {"id": group.get("id"), "name": group.get("name")} if group_item not in entry["groups"]: entry["groups"].append(group_item) user_names_by_ref = access_resolve_user_names( base_id, wanted_user_refs, diagnostics, timeout_seconds=int(timeout_seconds), ) for user_tail, item in matched_users_by_ref.items(): item["name"] = user_names_by_ref.get(user_tail) or item.get("name") or item.get("id") matched_profiles = [profiles_by_id[profile_id] for profile_id in sorted(matched_profile_ids) if profile_id in profiles_by_id] matched_groups = [groups_by_id[group_id] for group_id in sorted(matched_group_ids) if group_id in groups_by_id] matched_users = sorted(matched_users_by_ref.values(), key=lambda item: str(item.get("name") or item.get("id"))) return { "status": "ok", "base_id": base_id, "query": {"role": role_selector}, "roles": matched_roles, "profiles": matched_profiles, "groups": matched_groups, "users": matched_users, "alternatives": [{key: value for key, value in role.items() if key != "_match_score"} for role in alternatives], "counts": {"roles": len(matched_roles), "profiles": len(matched_profiles), "groups": len(matched_groups), "users": len(matched_users)}, "diagnostics": {"extractors": extractor_counts, "bsp": diagnostics, "role_match": {"best_score": best_score, "tokens": access_role_search_tokens(role_selector)}}, } def access_role_profiles(payload: dict[str, Any]) -> dict[str, Any]: method = "access.role.profiles" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error role_selector = str(payload.get("role") or payload.get("role_id") or payload.get("role_name") or payload.get("query") or "").strip() if not role_selector: return invalid_argument(method, "role", "Pass role, role_id, role_name, or query.") limit, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error chain = access_role_chain(base_id, role_selector, timeout_seconds=int(timeout_seconds)) if chain.get("status") == "error": return {"schema": "onec_access_role_profiles.v1", **chain} if chain.get("status") == "not_found": return {"schema": "onec_access_role_profiles.v1", **chain} matched_roles = chain.get("roles") if isinstance(chain.get("roles"), list) else [] matched_profiles = sorted(chain.get("profiles") or [], key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] matched_groups = sorted(chain.get("groups") or [], key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] matched_profiles = sorted(matched_profiles, key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] matched_groups = sorted(matched_groups, key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] summary = { "text": ( f"Role profiles: {len(matched_roles)} role matches, " f"{len(matched_profiles)} profiles, {len(matched_groups)} access groups." ) } return { "schema": "onec_access_role_profiles.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, "query": {"role": role_selector}, "summary": summary, "roles": matched_roles[: int(limit)], "profiles": matched_profiles, "groups": matched_groups, "alternatives": chain.get("alternatives") or [], "counts": {"roles": len(matched_roles), "profiles": len(matched_profiles), "groups": len(matched_groups)}, "diagnostics": chain.get("diagnostics") or {}, } def access_role_users(payload: dict[str, Any]) -> dict[str, Any]: method = "access.role.users" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error role_selector = str(payload.get("role") or payload.get("role_id") or payload.get("role_name") or payload.get("query") or "").strip() if not role_selector: return invalid_argument(method, "role", "Pass role, role_id, role_name, or query.") limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20000, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error chain = access_role_chain(base_id, role_selector, timeout_seconds=int(timeout_seconds)) if chain.get("status") == "error": return {"schema": "onec_access_role_users.v1", **chain} if chain.get("status") == "not_found": return {"schema": "onec_access_role_users.v1", **chain} roles = chain.get("roles") if isinstance(chain.get("roles"), list) else [] profiles = chain.get("profiles") if isinstance(chain.get("profiles"), list) else [] groups = chain.get("groups") if isinstance(chain.get("groups"), list) else [] users = sorted(chain.get("users") or [], key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)] summary = { "text": ( f"Role users: {len(roles)} role matches, {len(profiles)} profiles, " f"{len(groups)} access groups, {len(users)} users." ) } return { "schema": "onec_access_role_users.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, "query": {"role": role_selector}, "summary": summary, "roles": roles, "profiles": sorted(profiles, key=lambda item: str(item.get("name") or item.get("id"))), "groups": sorted(groups, key=lambda item: str(item.get("name") or item.get("id"))), "users": users, "alternatives": chain.get("alternatives") or [], "counts": {"roles": len(roles), "profiles": len(profiles), "groups": len(groups), "users": len(users)}, "diagnostics": chain.get("diagnostics") or {}, } ACCESS_ROLE_AUDIT_EXPORT_COLUMNS = [ "base_id", "generated_at", "query_role", "matched_role_id", "matched_role_name", "profile_id", "profile_name", "group_id", "group_name", "user_id", "user_name", "user_type", "user_active", "user_marked", "user_groups_count", "access_path", ] def infobase_user_public(row: dict[str, Any]) -> dict[str, Any]: """Return only safe, documented identity fields from dbo.v8users.""" role_set_id = str(row.get("role_set_id") or "").strip() or None administrator = bool(row.get("platform_administrator")) result = { "user_kind": "infobase_user", "visible_in": "Configurator > Administration > Users", "id": str(row.get("id") or "").upper() or None, "name": row.get("name"), "full_name": row.get("full_name"), "changed": jsonable(row.get("changed")), "visible_in_login_list": bool(row.get("visible_in_login_list")), "standard_authentication_enabled": bool(row.get("standard_authentication_enabled")), "os_authentication_configured": bool(row.get("os_authentication_configured")), "email_configured": bool(row.get("email_configured")), "platform_administrator": administrator, "role_set_id": role_set_id, "protected_data_bytes": int(row.get("protected_data_bytes") or 0), "role_assignment": { "source": "dbo.v8users", "authoritative": True, "role_set_id": role_set_id, "platform_administrator": administrator, "exact_role_names_status": "runtime_required", "exact_role_names": None, "message": ( "RolesID proves the platform role set assigned to this infobase user, but SQL does not expose " "a supported role-name mapping. Resolve exact role names through the 1C " "ПользователиИнформационнойБазы runtime API; never substitute BSP profiles or groups." ), }, } return {key: value for key, value in result.items() if value is not None} def infobase_user_match_score(user: dict[str, Any], selector: str) -> int: selector_text = str(selector or "").strip().casefold() selector_id = re.sub(r"[^0-9a-f]", "", selector_text) user_id = re.sub(r"[^0-9a-f]", "", str(user.get("id") or "").casefold()) name = str(user.get("name") or "").casefold() full_name = str(user.get("full_name") or "").casefold() if selector_id and selector_id == user_id: return 10000 if selector_text and selector_text in {name, full_name}: return 9000 if selector_text and (selector_text in name or selector_text in full_name or selector_text in user_id): return 8000 + len(selector_text) ratio = max( difflib.SequenceMatcher(None, selector_text, name).ratio() if selector_text and name else 0, difflib.SequenceMatcher(None, selector_text, full_name).ratio() if selector_text and full_name else 0, ) return int(ratio * 100) def infobase_users_read(base_id: str, *, scan_limit: int, timeout_seconds: int) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: conn, config, error = connect_live_sql(base_id, "infobase.users.search", timeout_seconds=timeout_seconds) if error: return [], error rows: list[dict[str, Any]] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( """ SELECT TOP (%d) CONVERT(varchar(32), ID, 2) AS id, Name AS name, Descr AS full_name, Changed AS changed, CONVERT(varchar(40), RolesID) AS role_set_id, CONVERT(int, Show) AS visible_in_login_list, CONVERT(int, EAuth) AS standard_authentication_enabled, CASE WHEN OSName IS NULL OR OSName = N'' THEN 0 ELSE 1 END AS os_authentication_configured, CASE WHEN Email IS NULL OR Email = N'' THEN 0 ELSE 1 END AS email_configured, CONVERT(int, AdmRole) AS platform_administrator, DATALENGTH(Data) AS protected_data_bytes FROM dbo.v8users WITH (READCOMMITTED) ORDER BY Name, ID """ % int(scan_limit) ) rows = [infobase_user_public({key: jsonable(value) for key, value in row.items()}) for row in cursor.fetchall()] except Exception as exc: return [], { "schema": "onec_infobase_users.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": "v8users"}, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass return rows, None def infobase_users_search(payload: dict[str, Any]) -> dict[str, Any]: method = "infobase.users.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error selector = str(payload.get("query") or payload.get("user") or payload.get("name") or "").strip() limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=500) if limit_error: return limit_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=5000, minimum=1, maximum=50000) if scan_limit_error: return scan_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error users, error = infobase_users_read(base_id, scan_limit=int(scan_limit), timeout_seconds=int(timeout_seconds)) if error: return error if selector: scored = [(infobase_user_match_score(user, selector), user) for user in users] scored.sort(key=lambda item: (-item[0], str(item[1].get("name") or item[1].get("id") or ""))) matches = [{**user, "match_score": score} for score, user in scored if score >= 100][: int(limit)] nearest = [{**user, "match_score": score} for score, user in scored if score > 0][: int(limit)] else: matches = users[: int(limit)] nearest = [] return { "schema": "onec_infobase_users.v1", "status": "ok" if matches else "not_found", "base_id": base_id, "terminology": { "default_user_meaning": "infobase_user", "bsp_user_is_separate": True, "message": "Unqualified 'user' means the infobase user visible in Configurator. Use access.users.search only for explicit BSP catalog/group/profile questions.", }, "source": {"kind": "live_sql", "table": "dbo.v8users", "authoritative_for_platform_identity": True}, "query": {"user": selector}, "users": matches, "nearest": nearest, "counts": {"users": len(matches), "nearest": len(nearest), "scanned": len(users), "truncated": len(users) >= int(scan_limit)}, "capabilities": { "can_read_identity": True, "can_read_authentication_flags": True, "can_read_platform_administrator": True, "can_read_role_set_id": True, "can_read_exact_role_names_from_sql": False, "exact_role_names_require": "1C runtime ПользователиИнформационнойБазы API", "password_hashes_exposed": False, "protected_data_exposed": False, }, } def infobase_user_get(payload: dict[str, Any]) -> dict[str, Any]: method = "infobase.user.get" selector = str(payload.get("user") or payload.get("name") or payload.get("id") or "").strip() if not selector: return invalid_argument(method, "user", "Pass user, name, or platform user id.") result = infobase_users_search({**payload, "query": selector, "limit": 20}) if result.get("status") == "error": return result exact = [user for user in result.get("users") or [] if int(user.get("match_score") or 0) >= 9000] if len(exact) == 1: return { "schema": "onec_infobase_user.v1", "status": "ok", "base_id": result.get("base_id"), "terminology": result.get("terminology"), "source": result.get("source"), "user": {key: value for key, value in exact[0].items() if key != "match_score"}, "capabilities": result.get("capabilities"), "bsp_correlation": { "status": "separate_layer_not_requested", "authoritative_for_platform_roles": False, "next_method": "access.users.search", }, } return { "schema": "onec_infobase_user.v1", "status": "ambiguous" if len(exact) > 1 else "not_found", "base_id": result.get("base_id"), "query": {"user": selector}, "candidates": exact or (result.get("nearest") or [])[:10], "terminology": result.get("terminology"), } def infobase_user_admin_config_for_base(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: raw_map = os.environ.get("ONEC_INFOBASE_USER_ADMIN_BASES_JSON") raw_map_file = os.environ.get("ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE") if not raw_map and raw_map_file: try: raw_map = Path(raw_map_file).read_text(encoding="utf-8-sig") except FileNotFoundError: return None, { "status": "not_configured", "message": f"Runtime user-admin config file is not present for base_id '{base_id}'.", } except Exception as exc: return None, {"status": "invalid_config", "message": f"Cannot read runtime user-admin config: {exc}"} if not raw_map: return None, { "status": "not_configured", "message": "Configure ONEC_INFOBASE_USER_ADMIN_BASES_JSON or ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE with a runtime bridge entry for this base_id.", } try: config_map = json.loads(raw_map) except json.JSONDecodeError as exc: return None, {"status": "invalid_config", "message": f"Runtime user-admin config is not valid JSON: {exc}"} if not isinstance(config_map, dict): return None, {"status": "invalid_config", "message": "Runtime user-admin config must be an object keyed by base_id."} item = config_map.get(base_id) if not isinstance(item, dict): return None, {"status": "not_configured", "message": f"No runtime user-admin bridge configured for base_id '{base_id}'."} url = str(item.get("url") or "").strip().rstrip("/") token_env = str(item.get("token_env") or "").strip() token = os.environ.get(token_env, "") if token_env else "" allow_insecure_http = item.get("allow_insecure_http") is True if not url: return None, {"status": "invalid_config", "message": f"Runtime user-admin bridge URL is empty for base_id '{base_id}'."} parsed = urllib.parse.urlparse(url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return None, {"status": "invalid_config", "message": "Runtime user-admin bridge URL must be an absolute HTTP(S) URL."} if parsed.scheme != "https" and not allow_insecure_http: return None, { "status": "blocked_insecure_transport", "message": "Password transport requires HTTPS. For an isolated test network only, set allow_insecure_http=true explicitly in the non-committed runtime config.", } unauthenticated_test_mode = truthy(os.environ.get("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED")) if (not token_env or not token) and not unauthenticated_test_mode: return None, { "status": "not_configured", "message": "Runtime user-admin bridge token must be supplied through the configured token_env; never store it in the JSON config.", "token_env": token_env or None, } return { "url": url, "token": token, "token_env": token_env, "allow_insecure_http": allow_insecure_http, "unauthenticated_test_mode": unauthenticated_test_mode, }, None def infobase_user_password_unauthenticated_test_mode() -> bool: # The isolated adapter test profile already has an explicit global gate. # Keep the narrower legacy flag for deployments that enable only user admin. return truthy(os.environ.get("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED")) or truthy(os.environ.get("ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN")) def infobase_user_password_capabilities(payload: dict[str, Any]) -> dict[str, Any]: method = "infobase.user.password.capabilities" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error sql_config, sql_config_error = sql_config_for_base(base_id) service_auth_configured = bool(adapter_service_token()) unauthenticated_test_mode = infobase_user_password_unauthenticated_test_mode() access_ready = service_auth_configured or unauthenticated_test_mode set_ready = bool(sql_config and access_ready) clear_ready = bool(sql_config and access_ready) ready = set_ready or clear_ready return { "schema": "onec_infobase_user_password_capabilities.v1", "status": "ready" if ready else "blocked", "base_id": base_id, "operations": {"status": bool(sql_config), "set": set_ready, "clear": clear_ready}, "requirements": { "adapter_service_authentication": not unauthenticated_test_mode, "adapter_service_authentication_configured": service_auth_configured, "unauthenticated_test_mode": unauthenticated_test_mode, "set_transport": "sql_dbo_v8users_data", "set_sql_configured": bool(sql_config), "clear_transport": "sql_dbo_v8users_data", "clear_sql_configured": bool(sql_config), "exact_user_id_confirmation": True, "explicit_allow_flag": True, "administrator_extra_confirmation": True, }, "security": { "direct_sql_password_write": clear_ready, "sql_clear_transactional_readback": True, "sql_clear_changes_only_current_hash_pair": True, "password_persisted_by_adapter": False, "password_echoed": False, "password_hashes_exposed": False, "password_operation_history_payload": False, "password_operation_result_audited": True, }, "operation_blockers": { **({"set": sql_config_error} if sql_config_error else {}), **({"clear": sql_config_error} if sql_config_error else {}), }, **( {"blocker": {"status": "service_auth_required", "message": "Set ONEC_ADAPTER_SERVICE_TOKEN, or explicitly enable the isolated unauthenticated test profile."}} if not access_ready else {} ), } INFOBASE_EMPTY_PASSWORD_SHA1_BASE64 = base64.b64encode(hashlib.sha1(b"").digest()).decode("ascii") INFOBASE_SHA1_BASE64_RE = re.compile(r"^[A-Za-z0-9+/]{27}=$") def infobase_user_password_data_decode(data: bytes) -> dict[str, Any]: if not isinstance(data, bytes) or len(data) < 4: raise ValueError("v8users.Data is empty or too short") key_size = int(data[0]) if key_size < 1 or len(data) <= key_size + 1: raise ValueError("v8users.Data has an invalid XOR key header") key = data[1 : key_size + 1] encrypted_payload = data[key_size + 1 :] payload = bytes(value ^ key[index % key_size] for index, value in enumerate(encrypted_payload)) trailing_nuls = len(payload) - len(payload.rstrip(b"\x00")) core = payload[:-trailing_nuls] if trailing_nuls else payload bom = b"\xef\xbb\xbf" if core.startswith(b"\xef\xbb\xbf") else b"" text = core[len(bom) :].decode("utf-8") from parser.payload import parse_brace_text, scalar tree = parse_brace_text(text) if not isinstance(tree, dict) or tree.get("type") != "list" or not isinstance(tree.get("items"), list): raise ValueError("v8users.Data does not contain the expected brace-list root") items = tree["items"] values = [scalar(item) for item in items] if len(values) < 14 or str(values[7]) not in {"0", "1"}: raise ValueError("v8users.Data authentication layout is not recognized") password_pair = None for index in range(10, min(len(values) - 1, 20)): first = values[index] second = values[index + 1] if ( isinstance(first, str) and isinstance(second, str) and INFOBASE_SHA1_BASE64_RE.fullmatch(first) and INFOBASE_SHA1_BASE64_RE.fullmatch(second) ): password_pair = (index, index + 1) break if password_pair is None: raise ValueError("current password hash pair was not found in v8users.Data") return { "key_size": key_size, "key": key, "bom": bom, "trailing_nuls": trailing_nuls, "text": text, "items": items, "values": values, "password_pair": password_pair, } def infobase_user_password_status(payload: dict[str, Any]) -> dict[str, Any]: method = "infobase.user.password.status" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error selector = str(payload.get("user") or payload.get("name") or payload.get("id") or "").strip() if not selector: return invalid_argument(method, "user", "Pass the exact Configurator user name or platform id.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error selected = infobase_user_get( { "base_id": base_id, "user": selector, "scan_limit": payload.get("scan_limit", 5000), "timeout_seconds": int(timeout_seconds), } ) if selected.get("status") != "ok": return { "schema": "onec_infobase_user_password_status.v1", "status": selected.get("status") or "not_found", "base_id": base_id, "query": {"user": selector}, "candidates": selected.get("candidates") or [], } user = selected.get("user") if isinstance(selected.get("user"), dict) else {} user_id = re.sub(r"[^0-9a-f]", "", str(user.get("id") or "").casefold()) conn, config, error = connect_live_sql(base_id, method, timeout_seconds=int(timeout_seconds)) if error: return error try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( "SELECT Name, CONVERT(int,EAuth) AS standard_authentication_enabled, Data FROM dbo.v8users WITH (READCOMMITTED) WHERE ID=%s", (bytes.fromhex(user_id),), ) row = cursor.fetchone() if not row or str(row.get("Name") or "") != str(user.get("name") or ""): return { "schema": "onec_infobase_user_password_status.v1", "status": "not_found", "base_id": base_id, "query": {"user": selector}, } standard_authentication_enabled = bool(row.get("standard_authentication_enabled")) decoded = infobase_user_password_data_decode(bytes(row.get("Data") or b"")) pair = tuple(decoded["password_pair"]) empty = all(decoded["values"][index] == INFOBASE_EMPTY_PASSWORD_SHA1_BASE64 for index in pair) password_state = "standard_authentication_disabled" if not standard_authentication_enabled else ("empty" if empty else "set") return { "schema": "onec_infobase_user_password_status.v1", "status": "ok", "base_id": base_id, "target": {"id": user.get("id"), "name": user.get("name")}, "password_state": password_state, "standard_authentication_enabled": standard_authentication_enabled, "can_login_with_empty_password": standard_authentication_enabled and empty, "platform_administrator": bool(user.get("platform_administrator")), "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": "dbo.v8users", "column": "Data"}, "security": {"password_hashes_exposed": False, "protected_data_exposed": False}, } except Exception as exc: return { "schema": "onec_infobase_user_password_status.v1", "status": "error", "base_id": base_id, "target": {"id": user.get("id"), "name": user.get("name")}, "error": "sql_password_status_failed", "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass def infobase_user_password_data_set(data: bytes, password: str) -> tuple[bytes, dict[str, Any]]: decoded = infobase_user_password_data_decode(data) pair = tuple(decoded["password_pair"]) values = decoded["values"] desired_hashes = infobase_user_password_hash_pair(password) already_applied = all(values[index] == desired_hashes[offset] for offset, index in enumerate(pair)) if already_applied: return data, {"already_applied": True, "layout_fields": len(values), "password_pair": list(pair)} text = str(decoded["text"]) for offset, index in sorted(enumerate(pair), key=lambda item: item[1], reverse=True): node = decoded["items"][index] if not isinstance(node, dict) or node.get("type") != "string": raise ValueError("password hash node is not a quoted string") start = int(node["pos"]) end = int(node["end"]) text = text[:start] + '"' + desired_hashes[offset] + '"' + text[end:] plain = decoded["bom"] + text.encode("utf-8") + (b"\x00" * int(decoded["trailing_nuls"])) key_size = int(decoded["key_size"]) key = bytes(decoded["key"]) new_data = bytes([key_size]) + key + bytes(value ^ key[index % key_size] for index, value in enumerate(plain)) verified = infobase_user_password_data_decode(new_data) changed = [index for index, (old, new) in enumerate(zip(values, verified["values"])) if old != new] if any(index not in pair for index in changed) or any( verified["values"][index] != desired_hashes[offset] for offset, index in enumerate(pair) ): raise ValueError("password update changed fields outside the current hash pair") if len(new_data) != len(data): raise ValueError("password update changed the v8users.Data byte length") return new_data, {"already_applied": False, "layout_fields": len(values), "password_pair": list(pair)} def infobase_user_password_hash_pair(password: str) -> tuple[str, str]: return ( base64.b64encode(hashlib.sha1(password.encode("utf-8")).digest()).decode("ascii"), base64.b64encode(hashlib.sha1(password.upper().encode("utf-8")).digest()).decode("ascii"), ) def infobase_user_password_data_clear(data: bytes) -> tuple[bytes, dict[str, Any]]: new_data, details = infobase_user_password_data_set(data, "") return new_data, {**details, "already_clear": details["already_applied"]} def infobase_user_password_write_sql( base_id: str, user: dict[str, Any], *, operation: str, new_password: str | None, request_id: str, timeout_seconds: int, ) -> dict[str, Any]: method = f"infobase.user.password.{operation}" user_id = re.sub(r"[^0-9a-f]", "", str(user.get("id") or "").casefold()) target = {"id": user.get("id"), "name": user.get("name")} conn, config, error = connect_live_sql(base_id, method, timeout_seconds=timeout_seconds) if error: return error try: cursor = conn.cursor(as_dict=True) cursor.execute( """ SELECT ID, Name, CONVERT(int, EAuth) AS standard_authentication_enabled, Data FROM dbo.v8users WITH (UPDLOCK, HOLDLOCK, ROWLOCK) WHERE ID = %s """, (bytes.fromhex(user_id),), ) row = cursor.fetchone() if not row or str(row.get("Name") or "") != str(user.get("name") or ""): conn.rollback() return { "schema": "onec_infobase_user_password_change.v1", "status": "blocked", "base_id": base_id, "operation": operation, "request_id": request_id, "target": target, "error": "target_changed_after_confirmation", } if not bool(row.get("standard_authentication_enabled")): conn.rollback() return { "schema": "onec_infobase_user_password_change.v1", "status": "blocked", "base_id": base_id, "operation": operation, "request_id": request_id, "target": target, "error": "standard_authentication_disabled", "diagnostics": {"message": "EAuth=0; changing stored hashes would not enable password login."}, } old_data = bytes(row.get("Data") or b"") new_data, details = infobase_user_password_data_set(old_data, new_password or "") if details["already_applied"]: conn.rollback() return { "schema": "onec_infobase_user_password_change.v1", "status": "ok", "base_id": base_id, "operation": operation, "request_id": request_id, "applied": False, "already_clear": operation == "clear", "already_set": operation == "set", "target": target, "source": {"kind": "live_sql", "table": "dbo.v8users", "column": "Data"}, } cursor.execute( "UPDATE dbo.v8users SET Data = %s WHERE ID = %s AND Data = %s", (new_data, bytes.fromhex(user_id), old_data), ) if cursor.rowcount != 1: conn.rollback() return { "schema": "onec_infobase_user_password_change.v1", "status": "conflict", "base_id": base_id, "operation": operation, "request_id": request_id, "target": target, "error": "concurrent_user_data_change", } cursor.execute("SELECT Data FROM dbo.v8users WITH (HOLDLOCK) WHERE ID = %s", (bytes.fromhex(user_id),)) readback = cursor.fetchone() readback_data = bytes((readback or {}).get("Data") or b"") verified = infobase_user_password_data_decode(readback_data) pair = tuple(verified["password_pair"]) desired_hashes = infobase_user_password_hash_pair(new_password or "") if readback_data != new_data or any( verified["values"][index] != desired_hashes[offset] for offset, index in enumerate(pair) ): conn.rollback() return { "schema": "onec_infobase_user_password_change.v1", "status": "verification_failed", "base_id": base_id, "operation": operation, "request_id": request_id, "target": target, "error": "sql_readback_mismatch", } conn.commit() return { "schema": "onec_infobase_user_password_change.v1", "status": "ok", "base_id": base_id, "operation": operation, "request_id": request_id, "applied": True, "target": target, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": "dbo.v8users", "column": "Data"}, "verification": { "transaction_committed": True, "readback_matches": True, "only_current_password_hash_pair_changed": True, "data_length_unchanged": len(old_data) == len(new_data), "layout_fields": details["layout_fields"], }, } except Exception as exc: try: conn.rollback() except Exception: pass return { "schema": "onec_infobase_user_password_change.v1", "status": "error", "base_id": base_id, "operation": operation, "request_id": request_id, "target": target, "error": "sql_password_update_failed", "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass def infobase_user_password_clear_sql( base_id: str, user: dict[str, Any], *, request_id: str, timeout_seconds: int, ) -> dict[str, Any]: return infobase_user_password_write_sql( base_id, user, operation="clear", new_password=None, request_id=request_id, timeout_seconds=timeout_seconds, ) def infobase_user_password_set_sql( base_id: str, user: dict[str, Any], *, new_password: str, request_id: str, timeout_seconds: int, ) -> dict[str, Any]: return infobase_user_password_write_sql( base_id, user, operation="set", new_password=new_password, request_id=request_id, timeout_seconds=timeout_seconds, ) def infobase_user_admin_runtime_call(config: dict[str, Any], request_payload: dict[str, Any], *, timeout_seconds: int) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: url = str(config["url"]).rstrip("/") + "/infobase-users/password" headers = { "Accept": "application/json", "Content-Type": "application/json; charset=utf-8", } if config.get("token"): headers["Authorization"] = f"Bearer {config['token']}" request = urllib.request.Request( url, data=json.dumps(request_payload, ensure_ascii=False).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=timeout_seconds) as response: raw = response.read().decode("utf-8-sig") decoded = json.loads(raw) if raw.strip() else {} except urllib.error.HTTPError as exc: return None, { "status": "runtime_error", "error": "runtime_http_error", "http_status": exc.code, "message": "The 1C runtime bridge rejected the password operation. Its response body is intentionally not returned.", } except (urllib.error.URLError, TimeoutError) as exc: return None, { "status": "runtime_result_unknown", "error": "runtime_unreachable_or_timeout", "message": f"The runtime acknowledgement was not received: {type(exc).__name__}. Check the operation by request_id before retrying.", } except (json.JSONDecodeError, ValueError): return None, {"status": "runtime_error", "error": "invalid_runtime_response", "message": "The runtime bridge returned a non-JSON response."} if not isinstance(decoded, dict): return None, {"status": "runtime_error", "error": "invalid_runtime_response", "message": "The runtime bridge response must be a JSON object."} return decoded, None def infobase_user_password_change(payload: dict[str, Any], *, operation: str) -> dict[str, Any]: method = f"infobase.user.password.{operation}" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error allow_argument = "allow_password_change" if operation == "set" else "allow_password_clear" allowed, allowed_error = strict_bool_argument(payload, allow_argument, method=method, default=False) if allowed_error: return allowed_error if not allowed: return invalid_argument(method, allow_argument, f"Pass {allow_argument}=true after reviewing the exact Configurator user target.") if not adapter_service_token() and not infobase_user_password_unauthenticated_test_mode(): return { "schema": "onec_infobase_user_password_change.v1", "status": "blocked", "base_id": base_id, "operation": operation, "error": "service_auth_required", "diagnostics": {"message": "Configure ONEC_ADAPTER_SERVICE_TOKEN, or explicitly enable the isolated unauthenticated test profile."}, } selector = str(payload.get("user") or payload.get("name") or payload.get("id") or "").strip() confirm_user_id = re.sub(r"[^0-9a-f]", "", str(payload.get("confirm_user_id") or "").casefold()) if not selector: return invalid_argument(method, "user", "Pass the exact Configurator user name or platform id.") if len(confirm_user_id) != 32: return invalid_argument(method, "confirm_user_id", "Pass the exact 32-hex platform user id returned by infobase.user.get.") new_password: str | None = None if operation == "set": if not isinstance(payload.get("new_password"), str): return invalid_argument(method, "new_password", "new_password must be a JSON string.") new_password = str(payload.get("new_password")) if not new_password: return invalid_argument(method, "new_password", "Use infobase.user.password.clear for an empty password.") if len(new_password) > 1024 or "\x00" in new_password: return invalid_argument(method, "new_password", "new_password must contain 1..1024 characters and no NUL characters.") elif "new_password" in payload: return invalid_argument(method, "new_password", "Do not pass new_password to the clear operation.") selected = infobase_user_get({"base_id": base_id, "user": selector, "scan_limit": payload.get("scan_limit", 5000), "timeout_seconds": payload.get("timeout_seconds", 30)}) if selected.get("status") != "ok": return { "schema": "onec_infobase_user_password_change.v1", "status": "blocked", "base_id": base_id, "operation": operation, "error": "exact_user_required", "selection": {key: selected.get(key) for key in ("status", "query", "candidates") if selected.get(key) is not None}, } user = selected.get("user") if isinstance(selected.get("user"), dict) else {} actual_user_id = re.sub(r"[^0-9a-f]", "", str(user.get("id") or "").casefold()) if actual_user_id != confirm_user_id: return { "schema": "onec_infobase_user_password_change.v1", "status": "blocked", "base_id": base_id, "operation": operation, "error": "user_confirmation_mismatch", "target": {"id": user.get("id"), "name": user.get("name")}, } if user.get("platform_administrator") is True: allow_administrator, administrator_error = strict_bool_argument(payload, "allow_administrator_password_change", method=method, default=False) if administrator_error: return administrator_error if not allow_administrator: return invalid_argument( method, "allow_administrator_password_change", "The selected user is a platform administrator. Pass allow_administrator_password_change=true after confirming an alternate administrator remains available.", ) timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error request_id = str(payload.get("request_id") or uuid.uuid4()) if operation == "clear": return infobase_user_password_clear_sql( base_id, user, request_id=request_id, timeout_seconds=int(timeout_seconds), ) return infobase_user_password_set_sql( base_id, user, new_password=str(new_password), request_id=request_id, timeout_seconds=int(timeout_seconds), ) def access_user_type_label(user_id: Any, diagnostics: dict[str, Any] | None = None) -> str: parts = access_identifier_parts(user_id) if not parts or not parts.get("type_code"): return "unknown" type_code = str(parts.get("type_code") or "").upper() sql_numbers = (((diagnostics or {}).get("bsp") or {}).get("metadata") or {}).get("sql_numbers") if isinstance(diagnostics, dict) else None if not isinstance(sql_numbers, dict): return "unknown" labels = { "users": "user", "external_users": "external_user", "user_groups": "user_group", } for key, label in labels.items(): try: if type_code == f"{int(sql_numbers.get(key)):08X}": return label except (TypeError, ValueError): continue return "unknown" def access_user_match_score(user: dict[str, Any], selector: str) -> int: selector_text = str(selector or "").strip().casefold() selector_tail = access_ref_tail(selector).casefold() user_id = str(user.get("id") or "") user_name = str(user.get("name") or "") user_id_text = user_id.casefold() user_name_text = user_name.casefold() if selector_tail and selector_tail == access_ref_tail(user_id).casefold(): return 10000 if selector_text and selector_text in {user_id_text, user_name_text}: return 9000 if selector_text and (selector_text in user_name_text or selector_text in user_id_text): return 8000 + len(selector_text) tokens = access_role_search_tokens(selector) if tokens: haystack = f"{user_name_text} {user_id_text}" matched = [token for token in tokens if token in haystack] if matched: return len(matched) * 100 + (500 if len(matched) == len(tokens) else 0) ratio = difflib.SequenceMatcher(None, selector_text, user_name_text).ratio() if selector_text and user_name_text else 0 return int(ratio * 100) def access_compact_user_candidate(user: dict[str, Any], *, score: int | None = None) -> dict[str, Any]: result = { "id": user.get("id"), "name": user.get("name"), "active": user.get("active"), "marked": user.get("marked"), "user_type": user.get("user_type"), } if score is not None: result["match_score"] = score return {key: value for key, value in result.items() if value not in {None, ""}} def access_nearest_users_from_graph(graph: dict[str, Any], selector: str, *, limit: int = 10) -> list[dict[str, Any]]: candidates: list[tuple[int, dict[str, Any]]] = [] seen: set[str] = set() for item in graph.get("effective_users") or []: if not isinstance(item, dict): continue user = item.get("user") if isinstance(item.get("user"), dict) else {} key = access_ref_tail(user.get("id")) or str(user.get("name") or "") if not key or key in seen: continue seen.add(key) score = access_user_match_score(user, selector) candidates.append((score, user)) candidates.sort(key=lambda item: (-item[0], str(item[1].get("name") or item[1].get("id") or ""))) return [access_compact_user_candidate(user, score=score) for score, user in candidates[:limit] if score > 0] def access_users_search(payload: dict[str, Any]) -> dict[str, Any]: method = "access.users.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error selector = str(payload.get("query") or payload.get("user") or payload.get("name") or "").strip() if not selector: return invalid_argument(method, "query", "Pass query, user, or name.") limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=20000, minimum=1, maximum=50000) if scan_limit_error: return scan_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings or not queries.get("users"): return {"schema": "onec_access_users_search.v1", "status": "error", "base_id": base_id, "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} rows, error, truncated = access_rows_from_query(base_id, queries["users"], limit=int(scan_limit), timeout_seconds=int(timeout_seconds)) if error: return {"schema": "onec_access_users_search.v1", **error} mapping = mappings.get("users") if isinstance(mappings.get("users"), dict) else None users = [access_map_row(row, mapping) for row in rows] scored = [(access_user_match_score(user, selector), user) for user in users if isinstance(user, dict)] scored.sort(key=lambda item: (-item[0], str(item[1].get("name") or item[1].get("id") or ""))) matches = [access_compact_user_candidate(user, score=score) for score, user in scored if score >= 100][: int(limit)] nearest = [access_compact_user_candidate(user, score=score) for score, user in scored if score > 0][: int(limit)] return { "schema": "onec_access_users_search.v1", "status": "ok" if matches else "not_found", "base_id": base_id, "user_kind": "bsp_catalog_user", "terminology": { "default_user_meaning": "infobase_user", "this_result_is": "bsp_catalog_user", "authoritative_for_platform_roles": False, "message": "This method searches BSP catalog users. It does not search dbo.v8users and must not be used as proof of Configurator authentication or direct platform role assignments.", "default_user_method": "infobase.users.search", }, "query": {"user": selector}, "users": matches, "nearest": nearest, "counts": {"users": len(matches), "nearest": len(nearest), "scanned": len(users), "truncated": bool(truncated)}, "diagnostics": {"bsp": diagnostics}, } def access_role_audit_csv(rows: list[dict[str, Any]]) -> str: output = io.StringIO() writer = csv.DictWriter(output, fieldnames=ACCESS_ROLE_AUDIT_EXPORT_COLUMNS, extrasaction="ignore", lineterminator="\n") writer.writeheader() for row in rows: writer.writerow(row) return output.getvalue() def access_role_audit_export(payload: dict[str, Any]) -> dict[str, Any]: method = "access.role.audit_export" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error role_selector = str(payload.get("role") or payload.get("role_id") or payload.get("role_name") or payload.get("query") or "").strip() if not role_selector: return invalid_argument(method, "role", "Pass role, role_id, role_name, or query.") export_format = str(payload.get("format") or "json").strip().casefold() if export_format not in {"json", "csv"}: return invalid_argument(method, "format", "format must be one of: json, csv.", allowed_values=["json", "csv"]) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20000, minimum=1, maximum=20000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error chain = access_role_chain(base_id, role_selector, timeout_seconds=int(timeout_seconds)) if chain.get("status") == "error": return {"schema": "onec_access_role_audit_export.v1", **chain} if chain.get("status") == "not_found": return {"schema": "onec_access_role_audit_export.v1", **chain, "format": export_format, "rows": []} generated_at = datetime.now(timezone.utc).isoformat() roles = chain.get("roles") if isinstance(chain.get("roles"), list) else [] profiles = chain.get("profiles") if isinstance(chain.get("profiles"), list) else [] groups = chain.get("groups") if isinstance(chain.get("groups"), list) else [] users = chain.get("users") if isinstance(chain.get("users"), list) else [] profiles_by_id = {access_ref_tail(profile.get("id")): profile for profile in profiles if isinstance(profile, dict)} groups_by_id = {access_ref_tail(group.get("id")): group for group in groups if isinstance(group, dict)} rows: list[dict[str, Any]] = [] for role in roles: role_id = role.get("id") role_tail = access_ref_tail(role_id) role_profiles = [ profile for profile in profiles_by_id.values() if role_tail in {access_ref_tail(item) for item in profile.get("roles") or []} ] or profiles for profile in role_profiles: profile_id = profile.get("id") profile_tail = access_ref_tail(profile_id) profile_groups = [ group for group in groups_by_id.values() if profile_tail in {access_ref_tail(item) for item in group.get("profiles") or []} ] or groups for group in profile_groups: group_tail = access_ref_tail(group.get("id")) group_users = [ user for user in users if any(access_ref_tail(user_group.get("id")) == group_tail for user_group in user.get("groups") or []) ] if not group_users: group_users = [{"id": "", "name": ""}] for user in group_users: access_path = " -> ".join( str(part or "") for part in (role.get("name"), profile.get("name"), group.get("name"), user.get("name")) if part not in {None, ""} ) rows.append( { "base_id": base_id, "generated_at": generated_at, "query_role": role_selector, "matched_role_id": role_id, "matched_role_name": role.get("name"), "profile_id": profile_id, "profile_name": profile.get("name"), "group_id": group.get("id"), "group_name": group.get("name"), "user_id": user.get("id"), "user_name": user.get("name"), "user_type": user.get("user_type") or access_user_type_label(user.get("id"), chain.get("diagnostics") if isinstance(chain.get("diagnostics"), dict) else None), "user_active": user.get("active") if user.get("active") is not None else True, "user_marked": bool(user.get("marked")) if user.get("marked") is not None else False, "user_groups_count": len(user.get("groups") or []), "access_path": access_path, } ) direct_source_types = {"direct_user_role", "group_role"} role_by_tail = {access_ref_tail(role.get("id")): role for role in roles if isinstance(role, dict)} for user in users: if not isinstance(user, dict): continue for source in user.get("role_sources") or []: if not isinstance(source, dict) or source.get("type") not in direct_source_types: continue role_tail = access_ref_tail(source.get("role")) role = role_by_tail.get(role_tail) or {"id": source.get("role"), "name": source.get("role_name")} group = groups_by_id.get(access_ref_tail(source.get("group"))) if source.get("group") not in {None, ""} else None access_path = " -> ".join( str(part or "") for part in (role.get("name"), (group or {}).get("name"), user.get("name")) if part not in {None, ""} ) rows.append( { "base_id": base_id, "generated_at": generated_at, "query_role": role_selector, "matched_role_id": role.get("id"), "matched_role_name": role.get("name"), "profile_id": "", "profile_name": "", "group_id": (group or {}).get("id") or source.get("group") or "", "group_name": (group or {}).get("name") or source.get("group") or "", "user_id": user.get("id"), "user_name": user.get("name"), "user_type": user.get("user_type") or access_user_type_label(user.get("id"), chain.get("diagnostics") if isinstance(chain.get("diagnostics"), dict) else None), "user_active": user.get("active") if user.get("active") is not None else True, "user_marked": bool(user.get("marked")) if user.get("marked") is not None else False, "user_groups_count": len(user.get("groups") or []), "access_path": access_path, } ) for row in rows: if row.get("access_path") in {None, ""}: row["access_path"] = " -> ".join( str(row.get(key) or "") for key in ("matched_role_name", "profile_name", "group_name", "user_name") if row.get(key) not in {None, ""} ) deduped_rows: list[dict[str, Any]] = [] seen_rows: set[tuple[str, str, str, str]] = set() for row in rows: key = ( str(row.get("matched_role_id") or ""), str(row.get("profile_id") or ""), str(row.get("group_id") or ""), str(row.get("user_id") or ""), ) if key in seen_rows: continue seen_rows.add(key) deduped_rows.append(row) rows = deduped_rows rows = sorted(rows, key=lambda row: (str(row.get("matched_role_name") or ""), str(row.get("profile_name") or ""), str(row.get("group_name") or ""), str(row.get("user_name") or "")))[: int(limit)] result = { "schema": "onec_access_role_audit_export.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, "format": export_format, "generated_at": generated_at, "query": {"role": role_selector}, "summary": { "text": ( f"Role audit export: {len(roles)} role matches, {len(profiles)} profiles, " f"{len(groups)} access groups, {len(users)} users, {len(rows)} rows." ) }, "columns": ACCESS_ROLE_AUDIT_EXPORT_COLUMNS, "rows": rows, "roles": roles, "profiles": sorted(profiles, key=lambda item: str(item.get("name") or item.get("id"))), "groups": sorted(groups, key=lambda item: str(item.get("name") or item.get("id"))), "users": sorted(users, key=lambda item: str(item.get("name") or item.get("id")))[: int(limit)], "alternatives": chain.get("alternatives") or [], "counts": {"roles": len(roles), "profiles": len(profiles), "groups": len(groups), "users": len(users), "rows": len(rows)}, "diagnostics": chain.get("diagnostics") or {}, } if export_format == "csv": result["content_type"] = "text/csv; charset=utf-8" result["csv"] = access_role_audit_csv(rows) return result def access_role_audit_risk_level(findings: list[dict[str, Any]]) -> str: severities = {str(item.get("severity") or "") for item in findings} if "high" in severities: return "high" if "medium" in severities: return "medium" return "low" def access_role_audit_analyze(payload: dict[str, Any]) -> dict[str, Any]: method = "access.role.audit_analyze" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error role_selector = str(payload.get("role") or payload.get("role_id") or payload.get("role_name") or payload.get("query") or "").strip() if not role_selector: return invalid_argument(method, "role", "Pass role, role_id, role_name, or query.") user_threshold, user_threshold_error = parse_int_argument(payload, "user_threshold", method=method, default=50, minimum=1, maximum=100000) if user_threshold_error: return user_threshold_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=120) if timeout_error: return timeout_error export = access_role_audit_export({**payload, "format": "json", "limit": 20000, "timeout_seconds": int(timeout_seconds)}) if export.get("status") != "ok": return {"schema": "onec_access_role_audit_analyze.v1", **export} rows = export.get("rows") if isinstance(export.get("rows"), list) else [] findings: list[dict[str, Any]] = [] users = export.get("users") if isinstance(export.get("users"), list) else [] groups = export.get("groups") if isinstance(export.get("groups"), list) else [] profiles = export.get("profiles") if isinstance(export.get("profiles"), list) else [] alternatives = export.get("alternatives") if isinstance(export.get("alternatives"), list) else [] if len(users) >= int(user_threshold): findings.append( { "severity": "medium", "code": "many_users", "message": f"Роль получают {len(users)} пользователей; порог аудита {int(user_threshold)}.", "details": {"users": len(users), "threshold": int(user_threshold)}, } ) if len(alternatives) > 1: findings.append( { "severity": "low", "code": "fuzzy_role_alternatives", "message": f"По фразе найдено {len(alternatives)} похожих ролей; проверьте выбранное совпадение.", "details": {"alternatives": alternatives[:5]}, } ) broad_group_terms = ("администратор", "полные права", "full", "admin") broad_groups = [group for group in groups if any(term in str(group.get("name") or "").casefold() for term in broad_group_terms)] if broad_groups: findings.append( { "severity": "high", "code": "broad_access_group", "message": "Роль назначена через широкую или административную группу доступа.", "details": {"groups": [{"id": group.get("id"), "name": group.get("name")} for group in broad_groups]}, } ) external_rows = [row for row in rows if row.get("user_type") == "external_user"] if external_rows: findings.append( { "severity": "medium", "code": "external_users", "message": f"Роль получают внешние пользователи: {len({row.get('user_id') for row in external_rows})}.", "details": {"users": sorted({str(row.get("user_name") or row.get("user_id")) for row in external_rows})[:20]}, } ) user_group_rows = [row for row in rows if row.get("user_type") == "user_group"] if user_group_rows: findings.append( { "severity": "medium", "code": "user_group_subjects", "message": f"В отчете есть группы пользователей как субъекты доступа: {len({row.get('user_id') for row in user_group_rows})}.", "details": {"subjects": sorted({str(row.get("user_name") or row.get("user_id")) for row in user_group_rows})[:20]}, } ) inactive_rows = [row for row in rows if row.get("user_active") is False or row.get("user_marked") is True] if inactive_rows: findings.append( { "severity": "high", "code": "inactive_or_marked_users", "message": "Неактивные или помеченные пользователи попали в цепочку роли.", "details": {"users": sorted({str(row.get("user_name") or row.get("user_id")) for row in inactive_rows})[:20]}, } ) paths_by_user: dict[str, set[str]] = {} for row in rows: user_id = str(row.get("user_id") or "") if not user_id: continue paths_by_user.setdefault(user_id, set()).add(str(row.get("access_path") or "")) multiple_path_users = [user_id for user_id, paths in paths_by_user.items() if len(paths) > 1] if multiple_path_users: findings.append( { "severity": "low", "code": "multiple_access_paths", "message": f"Некоторые пользователи получают роль несколькими путями: {len(multiple_path_users)}.", "details": {"user_ids": multiple_path_users[:20]}, } ) profile_group_counts: dict[str, set[str]] = {} for row in rows: profile_name = str(row.get("profile_name") or "") group_name = str(row.get("group_name") or "") if profile_name and group_name: profile_group_counts.setdefault(profile_name, set()).add(group_name) multi_group_profiles = {profile: sorted(groups_set) for profile, groups_set in profile_group_counts.items() if len(groups_set) > 1} if multi_group_profiles: findings.append( { "severity": "low", "code": "profile_used_by_multiple_groups", "message": "Один или несколько профилей используются несколькими группами доступа.", "details": {"profiles": multi_group_profiles}, } ) risk_level = access_role_audit_risk_level(findings) return { "schema": "onec_access_role_audit_analyze.v1", "status": "ok", "base_id": export.get("base_id"), "source": export.get("source"), "query": export.get("query"), "summary": { "text": ( f"Role audit analysis: risk={risk_level}, {len(findings)} findings, " f"{len(users)} users, {len(groups)} groups, {len(profiles)} profiles." ) }, "risk_level": risk_level, "findings": findings, "counts": {**(export.get("counts") if isinstance(export.get("counts"), dict) else {}), "findings": len(findings)}, "roles": export.get("roles") or [], "profiles": profiles, "groups": groups, "users": users, "alternatives": alternatives, "diagnostics": export.get("diagnostics") or {}, } def build_access_graph_from_snapshot( access: dict[str, Any], *, base_id: str | None = None, max_permissions_per_user: int | None = None, resolve_identifiers: bool = False, ) -> dict[str, Any]: users: dict[str, dict[str, Any]] = {} groups: dict[str, dict[str, Any]] = {} profiles: dict[str, dict[str, Any]] = {} roles: dict[str, dict[str, Any]] = {} restrictions: list[dict[str, Any]] = [] access_keys = access.get("access_keys") if isinstance(access.get("access_keys"), dict) else {} identifier_resolution: dict[str, Any] | None = None if resolve_identifiers and base_id: identifier_resolution = access_enrich_snapshot_identifiers(access, str(base_id)) access_group_keys = [item for item in access_list(access_keys.get("access_group_keys") if access_keys else None) if isinstance(item, dict)] access_user_keys = [item for item in access_list(access_keys.get("access_user_keys") if access_keys else None) if isinstance(item, dict)] access_object_keys = [item for item in access_list(access_keys.get("access_object_keys") if access_keys else None) if isinstance(item, dict)] access_object_keys_by_key: dict[str, list[dict[str, Any]]] = {} for item in access_object_keys: key = access_ref_tail(item.get("access_key")) if key: access_object_keys_by_key.setdefault(key, []).append(item) access_key_sample_limit = 20 def access_keys_for_user(user_id: str, seen_groups: set[str]) -> dict[str, Any]: if not access_keys: return {"counts": {"group_keys": 0, "user_keys": 0, "object_keys": 0, "total": 0}, "samples": {"group_keys": [], "user_keys": [], "object_keys": []}} seen_group_refs = {access_ref_tail(group_id) for group_id in seen_groups} user_ref = access_ref_tail(user_id) group_matches = [item for item in access_group_keys if access_ref_tail(item.get("group") or item.get("group_ref")) in seen_group_refs] user_matches = [item for item in access_user_keys if access_ref_tail(item.get("user")) == user_ref] subject_key_refs = {access_ref_tail(item.get("access_key")) for item in [*group_matches, *user_matches] if item.get("access_key") not in {None, ""}} object_matches: list[dict[str, Any]] = [] for key_ref in subject_key_refs: object_matches.extend(access_object_keys_by_key.get(key_ref, [])) object_type_codes = {str(item.get("object_type_code") or "").upper() for item in object_matches if item.get("object_type_code") not in {None, ""}} object_sql_numbers = {int(item.get("object_sql_number")) for item in object_matches if isinstance(item.get("object_sql_number"), int)} return { "counts": { "group_keys": len(group_matches), "user_keys": len(user_matches), "object_keys": len(object_matches), "object_key_types": len(object_type_codes or {str(number) for number in object_sql_numbers}), "total": len(group_matches) + len(user_matches), }, "samples": { "group_keys": group_matches[:access_key_sample_limit], "user_keys": user_matches[:access_key_sample_limit], "object_keys": object_matches[:access_key_sample_limit], }, } def ensure_user(raw: Any) -> dict[str, Any]: raw_item = raw if isinstance(raw, dict) else {"id": access_item_id(raw), "name": access_item_id(raw)} user_id = access_item_id(raw_item) item = users.setdefault( user_id, { "id": user_id, "name": access_item_name(raw_item, user_id), "active": access_bool(raw_item.get("active"), True) and not access_bool(raw_item.get("disabled") or raw_item.get("blocked"), False), "marked": access_bool(raw_item.get("marked"), False), "user_type": str(raw_item.get("user_type") or raw_item.get("type") or "").strip() or None, "service": access_bool(raw_item.get("service") or raw_item.get("is_service"), False), "administrator": access_bool(raw_item.get("administrator") or raw_item.get("admin") or raw_item.get("full_access"), False), "groups": [], "roles": [], }, ) candidate_name = access_item_name(raw_item, user_id) if candidate_name and access_name_is_placeholder(item.get("name"), user_id) and not access_name_is_placeholder(candidate_name, user_id): item["name"] = candidate_name if raw_item.get("marked") not in {None, ""}: item["marked"] = access_bool(raw_item.get("marked"), False) if raw_item.get("user_type") not in {None, ""}: item["user_type"] = str(raw_item.get("user_type") or "").strip() for source_key, target_key in (("service", "service"), ("is_service", "service"), ("administrator", "administrator"), ("admin", "administrator"), ("full_access", "administrator")): if raw_item.get(source_key) not in {None, ""}: item[target_key] = access_bool(raw_item.get(source_key), False) for group_ref in access_pick_list(raw_item, "groups", "access_groups", "group_refs"): group_id = access_item_id(group_ref) if group_id and group_id not in item["groups"]: item["groups"].append(group_id) for role_ref in access_pick_list(raw_item, "roles", "direct_roles"): role_id = access_item_id(role_ref) if role_id and role_id not in item["roles"]: item["roles"].append(role_id) return item def ensure_group(raw: Any) -> dict[str, Any]: raw_item = raw if isinstance(raw, dict) else {"id": access_item_id(raw), "name": access_item_id(raw)} group_id = access_item_id(raw_item) item = groups.setdefault( group_id, { "id": group_id, "name": access_item_name(raw_item, group_id), "active": access_bool(raw_item.get("active"), True) and not access_bool(raw_item.get("disabled") or raw_item.get("inactive"), False), "users": [], "profiles": [], "roles": [], "parent_groups": [], }, ) for user_ref in access_pick_list(raw_item, "users", "members", "user_refs"): user_id = access_item_id(user_ref) if user_id and user_id not in item["users"]: item["users"].append(user_id) ensure_user(user_ref if isinstance(user_ref, dict) else {"id": user_id, "name": user_id}) if group_id not in users[user_id]["groups"]: users[user_id]["groups"].append(group_id) for profile_ref in access_pick_list(raw_item, "profiles", "access_profiles", "profile_refs"): profile_id = access_item_id(profile_ref) if profile_id and profile_id not in item["profiles"]: item["profiles"].append(profile_id) for role_ref in access_pick_list(raw_item, "roles", "direct_roles"): role_id = access_item_id(role_ref) if role_id and role_id not in item["roles"]: item["roles"].append(role_id) for parent_ref in access_pick_list(raw_item, "parent_groups", "parents", "groups"): parent_id = access_item_id(parent_ref) if parent_id and parent_id != group_id and parent_id not in item["parent_groups"]: item["parent_groups"].append(parent_id) return item def ensure_profile(raw: Any) -> dict[str, Any]: raw_item = raw if isinstance(raw, dict) else {"id": access_item_id(raw), "name": access_item_id(raw)} profile_id = access_item_id(raw_item) item = profiles.setdefault(profile_id, {"id": profile_id, "name": access_item_name(raw_item, profile_id), "roles": []}) for role_ref in access_pick_list(raw_item, "roles", "role_refs"): role_id = access_item_id(role_ref) if role_id and role_id not in item["roles"]: item["roles"].append(role_id) return item def ensure_role(raw: Any) -> dict[str, Any]: raw_item = raw if isinstance(raw, dict) else {"id": access_item_id(raw), "name": access_item_id(raw)} role_id = access_item_id(raw_item) item = roles.setdefault( role_id, { "id": role_id, "name": access_item_name(raw_item, role_id), "elevated": access_bool(raw_item.get("elevated") or raw_item.get("administrator") or raw_item.get("full_access"), False), "permissions": [], }, ) for raw_permission in access_pick_list(raw_item, "permissions", "rights", "object_permissions"): for permission in access_normalize_permission(raw_permission): if permission not in item["permissions"]: item["permissions"].append(permission) return item for raw_user in access_list(access.get("users")): ensure_user(raw_user) for key_row in access_user_keys: user_ref = str(key_row.get("user") or "").strip() user_name = str(key_row.get("user_set_name") or "").strip() if user_ref and user_name: ensure_user({"id": user_ref, "name": user_name}) for raw_group in access_list(access.get("groups") or access.get("access_groups")): ensure_group(raw_group) for raw_profile in access_list(access.get("profiles") or access.get("access_profiles")): ensure_profile(raw_profile) for raw_role in access_list(access.get("roles")): ensure_role(raw_role) for assignment in access_list(access.get("assignments")): if not isinstance(assignment, dict): continue for subject in access_subject_refs(assignment.get("subjects") or assignment.get("subject"), assignment.get("subject_type")): target = users.get(subject["id"]) if subject["type"] in {"", "user", "User", "пользователь"} else groups.get(subject["id"]) if target is None and subject["type"] in {"", "user", "User", "пользователь"}: target = ensure_user({"id": subject["id"], "name": subject["id"]}) elif target is None: target = ensure_group({"id": subject["id"], "name": subject["id"]}) for group_ref in access_pick_list(assignment, "groups", "access_groups"): group_id = access_item_id(group_ref) if group_id and subject["type"] in {"", "user", "User", "пользователь"} and group_id not in target["groups"]: target["groups"].append(group_id) for profile_ref in access_pick_list(assignment, "profiles", "access_profiles"): profile_id = access_item_id(profile_ref) if profile_id and "profiles" in target and profile_id not in target["profiles"]: target["profiles"].append(profile_id) for role_ref in access_pick_list(assignment, "roles"): role_id = access_item_id(role_ref) if role_id and role_id not in target["roles"]: target["roles"].append(role_id) for raw_restriction in access_list(access.get("data_restrictions") or access.get("restrictions") or access.get("rls")): if not isinstance(raw_restriction, dict): continue subject_type = str(raw_restriction.get("subject_type") or raw_restriction.get("type") or "").strip() subject_id = str(raw_restriction.get("subject_id") or raw_restriction.get("id") or raw_restriction.get("subject") or "").strip() restrictions.append( { **raw_restriction, "subject_type": subject_type, "subject_id": subject_id, "dimension": str(raw_restriction.get("dimension") or raw_restriction.get("kind") or raw_restriction.get("object") or ""), "values": access_list(raw_restriction.get("values") or raw_restriction.get("value")), } ) effective_users: list[dict[str, Any]] = [] for user_id, user in users.items(): role_sources: dict[str, list[dict[str, Any]]] = {} def add_role(role_id: str, source: dict[str, Any]) -> None: if not role_id: return if role_id not in roles: ensure_role({"id": role_id, "name": role_id}) role_sources.setdefault(role_id, []).append(source) for role_id in user.get("roles") or []: add_role(str(role_id), {"type": "direct_user_role", "user": user_id}) group_queue = list(user.get("groups") or []) seen_groups: set[str] = set() while group_queue: group_id = str(group_queue.pop(0)) if group_id in seen_groups: continue seen_groups.add(group_id) group = groups.get(group_id) if not group: group = ensure_group({"id": group_id, "name": group_id}) for role_id in group.get("roles") or []: add_role(str(role_id), {"type": "group_role", "group": group_id}) for profile_id in group.get("profiles") or []: profile = profiles.get(str(profile_id)) or ensure_profile({"id": profile_id, "name": profile_id}) for role_id in profile.get("roles") or []: add_role(str(role_id), {"type": "group_profile_role", "group": group_id, "profile": str(profile_id)}) for parent_id in group.get("parent_groups") or []: if parent_id not in seen_groups: group_queue.append(str(parent_id)) permission_map: dict[tuple[str, str, str], dict[str, Any]] = {} for role_id, sources in role_sources.items(): role = roles.get(role_id) or {} for permission in role.get("permissions") or []: key = access_permission_key(permission, str(permission.get("action") or "*")) entry = permission_map.setdefault( key, { "object": permission.get("object") or "*", "action": permission.get("action") or "*", **({"scope": permission.get("scope")} if permission.get("scope") else {}), **({"object_name": permission.get("object_name")} if permission.get("object_name") else {}), **({"object_kind": permission.get("object_kind")} if permission.get("object_kind") else {}), **({"object_full_name": permission.get("object_full_name")} if permission.get("object_full_name") else {}), **({"object_resolution": permission.get("object_resolution")} if permission.get("object_resolution") else {}), "sources": [], }, ) entry["sources"].append({"role": role_id, "role_name": role.get("name"), "chains": sources}) user_restrictions = [ item for item in restrictions if (item.get("subject_type") in {"", "user", "User", "пользователь"} and item.get("subject_id") == user_id) or (item.get("subject_type") in {"group", "Group", "access_group", "группа"} and item.get("subject_id") in seen_groups) ] permissions = sorted(permission_map.values(), key=lambda item: (str(item.get("object")), str(item.get("action")))) permissions_total = len(permissions) permissions_truncated = False if max_permissions_per_user is not None and max_permissions_per_user >= 0 and len(permissions) > max_permissions_per_user: permissions = permissions[:max_permissions_per_user] permissions_truncated = True user_access_keys = access_keys_for_user(user_id, seen_groups) effective_users.append( { "user": {key: user.get(key) for key in ("id", "name", "active", "marked", "user_type", "service", "administrator")}, "groups": sorted(seen_groups), "roles": [{"id": role_id, "name": (roles.get(role_id) or {}).get("name"), "sources": sources} for role_id, sources in sorted(role_sources.items())], "permissions": permissions, "permission_counts": {"total": permissions_total, "returned": len(permissions), "truncated": permissions_truncated}, "access_keys": user_access_keys, "data_restrictions": user_restrictions, } ) return { "schema": "onec_access_graph.v1", "status": "ok", "base_id": base_id, "source": {"kind": "access_snapshot"}, "users": sorted(users.values(), key=lambda item: item.get("name") or item.get("id")), "groups": sorted(groups.values(), key=lambda item: item.get("name") or item.get("id")), "profiles": sorted(profiles.values(), key=lambda item: item.get("name") or item.get("id")), "roles": sorted(roles.values(), key=lambda item: item.get("name") or item.get("id")), **({"access_keys": access_keys} if access_keys else {}), "effective_users": sorted(effective_users, key=lambda item: (item.get("user") or {}).get("name") or (item.get("user") or {}).get("id")), "counts": { "users": len(users), "groups": len(groups), "profiles": len(profiles), "roles": len(roles), "data_restrictions": len(restrictions), **( { "access_group_keys": len(access_list(access_keys.get("access_group_keys"))), "access_user_keys": len(access_list(access_keys.get("access_user_keys"))), "access_object_keys": len(access_list(access_keys.get("access_object_keys"))), "access_set_keys": len(access_list(access_keys.get("access_set_keys"))), } if access_keys else {} ), "effective_users": len(effective_users), "effective_permissions_total": sum((item.get("permission_counts") or {}).get("total") or 0 for item in effective_users), "effective_permissions_returned": sum((item.get("permission_counts") or {}).get("returned") or 0 for item in effective_users), "effective_users_permissions_truncated": sum(1 for item in effective_users if (item.get("permission_counts") or {}).get("truncated")), }, "diagnostics": { "note": "Access graph is computed from provided normalized access snapshot data. Live extraction from configuration-specific registers can feed this same schema.", **({"identifier_resolution": identifier_resolution} if identifier_resolution else {}), }, } def access_graph_build(payload: dict[str, Any]) -> dict[str, Any]: access, error = access_snapshot_from_payload(payload, "access.graph.build") if error: return error max_permissions, max_permissions_error = parse_int_argument(payload, "max_effective_permissions_per_user", method="access.graph.build", default=None, minimum=0, maximum=200000) if max_permissions_error: return max_permissions_error resolve_identifiers, resolve_identifiers_error = strict_bool_argument(payload, "resolve_identifiers", method="access.graph.build", default=True) if resolve_identifiers_error: return resolve_identifiers_error return build_access_graph_from_snapshot( access or {}, base_id=payload.get("base_id"), max_permissions_per_user=max_permissions, resolve_identifiers=bool(resolve_identifiers), ) def access_user_explain_from_graph(payload: dict[str, Any], graph: dict[str, Any], user_selector: str) -> dict[str, Any]: method = "access.user.explain" object_filter = str(payload.get("object") or payload.get("object_ref") or "").strip() object_filter_cf = object_filter.casefold() object_filter_selector = str(payload.get("_object_filter_selector") or object_filter).strip() object_filter_guid = str(payload.get("_object_filter_guid") or "").strip().lower() or None object_filter_payload = payload.get("_object_filter_payload") if isinstance(payload.get("_object_filter_payload"), dict) else None action_filter_keys = access_action_filter_keys(payload.get("action") or payload.get("right")) selected = None selector_cf = user_selector.casefold() selector_tail = access_ref_tail(user_selector).casefold() for item in graph.get("effective_users") or []: user = item.get("user") or {} user_id = str(user.get("id") or "") user_name = str(user.get("name") or "") if selector_cf in {user_id.casefold(), user_name.casefold()} or selector_tail == access_ref_tail(user_id).casefold(): selected = item break if not selected: nearest_users = access_nearest_users_from_graph(graph, user_selector, limit=10) return { "schema": "onec_access_user_explain.v1", "status": "not_found", "error": "not_found", "base_id": payload.get("base_id"), "query": {"user": user_selector}, "nearest_users": nearest_users, "diagnostics": { "message": "User was not found in the provided access snapshot or live extraction.", "hint": "Use access.users.search/access_users_search to find the exact user name or ref.", }, } permissions = [] object_permissions_all_actions: list[dict[str, Any]] = [] for permission in selected.get("permissions") or []: if object_filter: if object_filter_payload: if not access_permission_matches_object(permission, selector=object_filter_selector, object_guid=object_filter_guid, object_payload=object_filter_payload): continue else: object_haystack = " ".join(str(permission.get(key) or "") for key in ("object", "object_name", "object_full_name")).casefold() if object_filter_cf not in object_haystack: continue object_permissions_all_actions.append(permission) if not access_permission_matches_action(permission, action_filter_keys): continue permissions.append(permission) access_keys = selected.get("access_keys") if isinstance(selected.get("access_keys"), dict) else {} resolve_records = payload.get("resolve_records") is True if resolve_records and payload.get("base_id") and access_keys: samples = access_keys.get("samples") if isinstance(access_keys.get("samples"), dict) else {} object_samples = samples.get("object_keys") if isinstance(samples.get("object_keys"), list) else [] resolution = access_resolve_object_key_records( str(payload.get("base_id")), object_samples, timeout_seconds=int(payload.get("timeout_seconds") if isinstance(payload.get("timeout_seconds"), int) else 60), max_records=int(payload.get("max_resolved_records") if isinstance(payload.get("max_resolved_records"), int) else 200), ) access_keys = { **access_keys, "samples": {**samples, "object_keys": resolution.get("rows") or object_samples}, "record_resolution": resolution.get("diagnostics"), } permission_object_names = sorted({str(item.get("object_name") or item.get("object") or "") for item in permissions if item.get("object_name") or item.get("object")}) summary = { "text": ( f"{(selected.get('user') or {}).get('name') or user_selector}: " f"{len(selected.get('groups') or [])} groups, {len(selected.get('roles') or [])} roles, " f"{len(permissions)} returned permissions" + (f", {(access_keys.get('counts') or {}).get('total', 0)} subject access keys" if access_keys else "") + (f", {(access_keys.get('counts') or {}).get('object_keys', 0)} matching object keys" if access_keys else "") + "." ), "permission_objects": permission_object_names[:20], } available_actions_for_object = access_permission_rights(object_permissions_all_actions) if object_filter else None if object_filter and action_filter_keys and not permissions and object_permissions_all_actions: summary["hint"] = "No permissions matched requested action, but the user has other rights for this object." return { "schema": "onec_access_user_explain.v1", "status": "ok", "base_id": payload.get("base_id"), "source": graph.get("source"), "query": {"user": user_selector, "object": payload.get("object") or payload.get("object_ref"), "action": payload.get("action") or payload.get("right")}, "user": selected.get("user"), "groups": selected.get("groups"), "roles": selected.get("roles"), "permissions": permissions, "access_keys": access_keys, "data_restrictions": selected.get("data_restrictions"), "summary": summary, **({"available_actions_for_object": available_actions_for_object} if available_actions_for_object is not None else {}), "counts": { "roles": len(selected.get("roles") or []), "permissions": len(permissions), "data_restrictions": len(selected.get("data_restrictions") or []), **({"access_keys": (access_keys.get("counts") or {}).get("total", 0)} if access_keys else {}), }, **({"diagnostics": graph.get("diagnostics")} if isinstance(graph.get("diagnostics"), dict) else {}), } def access_user_explain(payload: dict[str, Any]) -> dict[str, Any]: method = "access.user.explain" user_selector = payload.get("user") or payload.get("user_id") or payload.get("name") if user_selector in {None, ""}: return invalid_argument(method, "user", "Pass user, user_id, or name.") if not isinstance(user_selector, str): return invalid_argument(method, "user", "user must be a JSON string.") has_snapshot = payload.get("access") is not None or isinstance(payload.get("snapshot"), dict) or payload.get("data") is not None max_permissions, max_permissions_error = parse_int_argument(payload, "max_effective_permissions_per_user", method=method, default=None, minimum=0, maximum=200000) if max_permissions_error: return max_permissions_error resolve_identifiers, resolve_identifiers_error = strict_bool_argument(payload, "resolve_identifiers", method=method, default=True) if resolve_identifiers_error: return resolve_identifiers_error normalized_payload = dict(payload) has_object_filter = any(payload.get(key) not in {None, ""} for key in ("object", "object_ref", "ref", "kind", "guid", "object_type", "object_name", "object_guid")) if payload.get("base_id") not in {None, ""} and has_object_filter: object_selector_payload = dict(payload) if object_selector_payload.get("object") not in {None, ""} and not has_object_selector(object_selector_payload): object_selector_payload["ref"] = object_selector_payload.get("object") normalized_selector = normalize_object_selector_aliases(object_selector_payload, "access.object.roles") if not (isinstance(normalized_selector, dict) and normalized_selector.get("status") == "invalid_argument") and has_object_selector(normalized_selector): object_guid, object_kind, object_card, object_error = resolve_object_guid( normalized_selector, str(payload.get("base_id")), timeout_seconds=int(payload.get("timeout_seconds") if isinstance(payload.get("timeout_seconds"), int) else 120), method=method, ) if not object_error: object_selector, object_payload = access_object_selector_from_card(normalized_selector, object_kind, object_card) object_payload["guid"] = object_guid normalized_payload["_object_filter_selector"] = object_selector normalized_payload["_object_filter_guid"] = object_guid normalized_payload["_object_filter_payload"] = object_payload payload = normalized_payload if has_snapshot: access, error = access_snapshot_from_payload(payload, method) if error: return error graph = build_access_graph_from_snapshot( access or {}, base_id=payload.get("base_id"), max_permissions_per_user=max_permissions, resolve_identifiers=bool(resolve_identifiers), ) return access_user_explain_from_graph(payload, graph, user_selector) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return invalid_argument(method, "access", "Pass access snapshot data, or pass base_id for live BSP explanation.") preset = str(payload.get("preset") or payload.get("profile") or "bsp").strip().casefold() if preset not in {"bsp", "бсп"}: return invalid_argument(method, "preset", "Only preset='bsp' is supported for live explanation.") live_payload = { **payload, "preset": "bsp", "limit": int(payload.get("limit") if isinstance(payload.get("limit"), int) else 20000), "timeout_seconds": int(payload.get("timeout_seconds") if isinstance(payload.get("timeout_seconds"), int) else 120), "max_effective_permissions_per_user": int(max_permissions if max_permissions is not None else (20000 if has_object_filter else 5000)), "resolve_identifiers": bool(resolve_identifiers), } extracted = access_snapshot_extract(live_payload) if extracted.get("status") != "ok": return extracted graph = extracted.get("graph") if isinstance(extracted.get("graph"), dict) else {} result = access_user_explain_from_graph(payload, graph, user_selector) if result.get("status") == "ok": result["source"] = {"kind": "live_sql", "preset": "bsp", "extraction": "access.snapshot.extract"} result["extraction_counts"] = extracted.get("counts") result["extraction_diagnostics"] = extracted.get("diagnostics") return result def manifest_name(path: Any) -> str | None: if not path: return None name = Path(str(path).replace("\\", "/")).name match = re.match(r"^\d+_(.+?)-[0-9a-f]{8}\.json$", name, re.IGNORECASE) return match.group(1) if match else None def sql_config_for_base(base_id: str) -> tuple[dict[str, str] | None, dict[str, Any] | None]: raw_map = os.environ.get("ONEC_SQL_BASES_JSON") raw_map_file = os.environ.get("ONEC_SQL_BASES_JSON_FILE") if not raw_map and raw_map_file: try: raw_map = Path(raw_map_file).read_text(encoding="utf-8-sig") except Exception as exc: return None, {"status": "invalid_config", "message": f"Cannot read ONEC_SQL_BASES_JSON_FILE: {exc}"} if raw_map: try: config_map = json.loads(raw_map) except json.JSONDecodeError as exc: return None, {"status": "invalid_config", "message": f"ONEC_SQL_BASES_JSON is not valid JSON: {exc}"} if not isinstance(config_map, dict): return None, {"status": "invalid_config", "message": "ONEC_SQL_BASES_JSON must be an object keyed by base_id."} item = config_map.get(base_id) if not item: return None, {"status": "not_configured", "message": f"No SQL connection configured for base_id '{base_id}'."} if not isinstance(item, dict): return None, {"status": "invalid_config", "message": f"SQL connection config for base_id '{base_id}' must be an object."} password = str(item.get("password") or "") password_env = str(item.get("password_env") or "") if password_env: password = os.environ.get(password_env, "") config = { "server": str(item.get("server") or ""), "database": str(item.get("database") or ""), "user": str(item.get("user") or ""), "password": password, } missing = [key for key, value in config.items() if not value] if missing: return None, { "status": "not_configured", "message": f"Missing SQL connection fields for base_id '{base_id}': {', '.join(missing)}.", "password_env": password_env or None, } return config, None return None, { "status": "not_configured", "message": "Set ONEC_SQL_BASES_JSON or ONEC_SQL_BASES_JSON_FILE with an explicit entry for this base_id.", } def sql_configured_base_ids() -> list[str]: raw_map = os.environ.get("ONEC_SQL_BASES_JSON") raw_map_file = os.environ.get("ONEC_SQL_BASES_JSON_FILE") if not raw_map and raw_map_file: try: raw_map = Path(raw_map_file).read_text(encoding="utf-8-sig") except Exception: raw_map = "" if not raw_map: return [] try: config_map = json.loads(raw_map) except Exception: return [] if not isinstance(config_map, dict): return [] return sorted(str(key) for key, value in config_map.items() if isinstance(value, dict)) def cache_db_path() -> Path: return Path(os.environ.get("ONEC_ADAPTER_CACHE_DB") or "/data/adapter-cache.sqlite") def cache_server_key(config: dict[str, str]) -> str: return str(config.get("server") or "").strip().casefold() def cache_database_name(config: dict[str, str]) -> str: return str(config.get("database") or "").strip() def cache_connection() -> sqlite3.Connection: path = cache_db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA busy_timeout=30000") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_identity_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, kind TEXT NOT NULL, kind_ru TEXT, public_kind TEXT, name TEXT, synonym TEXT, normalized_name TEXT, normalized_synonym TEXT, full_name TEXT, normalized_full_name TEXT, guid TEXT NOT NULL, source TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, kind, guid) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_metadata_cache_name ON metadata_identity_cache(server_key, database_name, kind, normalized_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_metadata_cache_synonym ON metadata_identity_cache(server_key, database_name, kind, normalized_synonym)") conn.execute("CREATE INDEX IF NOT EXISTS idx_metadata_cache_full ON metadata_identity_cache(server_key, database_name, normalized_full_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_type_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, type_guid TEXT NOT NULL, status TEXT NOT NULL, presentation TEXT, kind TEXT, kind_ru TEXT, name TEXT, owner_guid TEXT, generated_category TEXT, payload_json TEXT NOT NULL, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, type_guid) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_metadata_type_cache_kind ON metadata_type_cache(server_key, database_name, kind, name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_metadata_type_cache_presentation ON metadata_type_cache(server_key, database_name, presentation)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_guid_index ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, guid TEXT NOT NULL, guid_role TEXT NOT NULL, kind TEXT, kind_ru TEXT, public_kind TEXT, name TEXT, synonym TEXT, normalized_name TEXT, normalized_synonym TEXT, full_name TEXT, normalized_full_name TEXT, presentation TEXT, normalized_presentation TEXT, owner_guid TEXT, owner_kind TEXT, owner_name TEXT, type_guid TEXT, value_guid TEXT, value_type_guid TEXT, value_presentation TEXT, source TEXT, source_file TEXT, payload_json TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, guid, guid_role) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_guid ON metadata_guid_index(server_key, database_name, guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_type_guid ON metadata_guid_index(server_key, database_name, type_guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_name ON metadata_guid_index(server_key, database_name, kind, normalized_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_owner ON metadata_guid_index(server_key, database_name, owner_guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_presentation ON metadata_guid_index(server_key, database_name, normalized_presentation)") conn.execute("CREATE INDEX IF NOT EXISTS idx_guid_index_full_name ON metadata_guid_index(server_key, database_name, normalized_full_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_module_owner_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, module_ref TEXT NOT NULL, module_table TEXT, file_name TEXT, stream_index INTEGER, owner_guid TEXT NOT NULL, owner_kind TEXT, owner_name TEXT, owner_synonym TEXT, module_payload_json TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, module_ref) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_module_owner_cache_owner ON metadata_module_owner_cache(server_key, database_name, owner_guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_module_owner_cache_owner_ref ON metadata_module_owner_cache(server_key, database_name, owner_guid, module_ref)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_form_owner_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, form_key TEXT NOT NULL, extension_guid TEXT, extension_name TEXT, owner_kind TEXT, owner_name TEXT, owner_guid TEXT, form_name TEXT, form_guid TEXT, table_name TEXT, file_name TEXT, module_ref TEXT, bsl_offset INTEGER, payload_json TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, form_key) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_form_owner_cache_name ON metadata_form_owner_cache(server_key, database_name, owner_kind, form_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_form_owner_cache_module ON metadata_form_owner_cache(server_key, database_name, module_ref)") conn.execute("CREATE INDEX IF NOT EXISTS idx_form_owner_cache_file ON metadata_form_owner_cache(server_key, database_name, table_name, file_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_form_owner_cache_extension ON metadata_form_owner_cache(server_key, database_name, extension_guid, extension_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS extension_route_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, extension_guid TEXT, extension_name TEXT, root_cas_key TEXT, object_key TEXT NOT NULL, object_base_id TEXT, descriptor_cas_key TEXT NOT NULL, object_kind TEXT, kind_ru TEXT, name TEXT, synonym TEXT, normalized_name TEXT, normalized_synonym TEXT, normalized_full_name TEXT, guid TEXT, route_json TEXT NOT NULL, manifest_entries_json TEXT NOT NULL, descriptor_payload_sha1 TEXT, freshness_status TEXT NOT NULL, updated_at REAL NOT NULL, validated_at REAL, last_seen_at REAL NOT NULL, stale_reason TEXT, PRIMARY KEY (server_key, database_name, descriptor_cas_key) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_extension_route_cache_name ON extension_route_cache(server_key, database_name, object_kind, normalized_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_extension_route_cache_synonym ON extension_route_cache(server_key, database_name, object_kind, normalized_synonym)") conn.execute("CREATE INDEX IF NOT EXISTS idx_extension_route_cache_guid ON extension_route_cache(server_key, database_name, guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_extension_route_cache_extension ON extension_route_cache(server_key, database_name, extension_guid, object_kind)") conn.execute( """ CREATE TABLE IF NOT EXISTS semantic_document_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, document_id TEXT NOT NULL, object_kind TEXT, object_guid TEXT, object_name TEXT, extension_guid TEXT, source_route_json TEXT, content_sha1 TEXT NOT NULL, text_preview TEXT, embedding_model TEXT, embedding_json TEXT, vector_status TEXT NOT NULL, authoritative_source TEXT NOT NULL, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, document_id) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_semantic_document_cache_object ON semantic_document_cache(server_key, database_name, object_kind, object_guid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_semantic_document_cache_source ON semantic_document_cache(server_key, database_name, authoritative_source, vector_status)") conn.execute( """ CREATE TABLE IF NOT EXISTS decoded_artifact_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, artifact_kind TEXT NOT NULL, content_sha1 TEXT NOT NULL, source_table TEXT, source_file TEXT, payload_bytes INTEGER, artifact_json TEXT NOT NULL, semantic_text TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, artifact_kind, content_sha1) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_decoded_artifact_source ON decoded_artifact_cache(server_key, database_name, source_table, source_file)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_index_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, module_ref TEXT NOT NULL, source_table TEXT NOT NULL, file_name TEXT NOT NULL, owner_kind TEXT, owner_name TEXT, owner_guid TEXT, form_name TEXT, extension_guid TEXT, extension_name TEXT, bsl_offset INTEGER, stream_index INTEGER, payload_sha1 TEXT NOT NULL, text_sha1 TEXT NOT NULL, text TEXT NOT NULL, routines_json TEXT NOT NULL, routine_count INTEGER NOT NULL, source_bytes INTEGER, updated_at REAL NOT NULL, last_verified_at REAL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, module_ref) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_text ON metadata_code_index_cache(server_key, database_name, source_table, text_sha1)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_file ON metadata_code_index_cache(server_key, database_name, source_table, file_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_owner ON metadata_code_index_cache(server_key, database_name, owner_kind, owner_name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_verified ON metadata_code_index_cache(server_key, database_name, last_verified_at)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_vector_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, chunk_id TEXT NOT NULL, module_ref TEXT NOT NULL, routine_name TEXT, chunk_kind TEXT NOT NULL, chunk_index INTEGER NOT NULL, text_sha1 TEXT NOT NULL, payload_sha1 TEXT NOT NULL, embedding_model TEXT NOT NULL, embedding_json TEXT NOT NULL, text_preview TEXT, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, chunk_id) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_vector_module ON metadata_code_vector_cache(server_key, database_name, module_ref)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_vector_model ON metadata_code_vector_cache(server_key, database_name, embedding_model)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_embedding_cache ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, chunk_id TEXT NOT NULL, module_ref TEXT NOT NULL, text_sha1 TEXT NOT NULL, payload_sha1 TEXT NOT NULL, embedding_model TEXT NOT NULL, dimensions INTEGER NOT NULL, embedding_json TEXT NOT NULL, updated_at REAL NOT NULL, last_seen_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, chunk_id, embedding_model) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_embedding_model ON metadata_code_embedding_cache(server_key, database_name, embedding_model, dimensions)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_embedding_module ON metadata_code_embedding_cache(server_key, database_name, module_ref)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_index_state ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, source_revision INTEGER NOT NULL, dirty INTEGER NOT NULL, dirty_reason TEXT, dirty_since REAL, last_indexed_at REAL, updated_at REAL NOT NULL, PRIMARY KEY (server_key, database_name) ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_index_outbox ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, event_id TEXT NOT NULL, source_revision INTEGER NOT NULL, event_kind TEXT NOT NULL, source_table TEXT, file_name TEXT, reason TEXT, status TEXT NOT NULL, created_at REAL NOT NULL, processed_at REAL, PRIMARY KEY (server_key, database_name, event_id) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_outbox_status ON metadata_code_index_outbox(server_key, database_name, status, source_table, file_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_code_overlay_tombstone ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, source_table TEXT NOT NULL, file_name TEXT NOT NULL, reason TEXT, source_revision INTEGER, created_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, source_table, file_name) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_overlay_tombstone_file ON metadata_code_overlay_tombstone(server_key, database_name, file_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_write_history ( server_key TEXT NOT NULL, database_name TEXT NOT NULL, operation_id TEXT NOT NULL, method TEXT NOT NULL, routed_method TEXT, status TEXT, base_id TEXT, target_kind TEXT, target_summary_json TEXT, backup_ids_json TEXT, result_json TEXT NOT NULL, created_at REAL NOT NULL, PRIMARY KEY (server_key, database_name, operation_id) ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_write_history_created ON metadata_write_history(server_key, database_name, created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_write_history_status ON metadata_write_history(server_key, database_name, status)") return conn def cache_identity_row(config: dict[str, str], row: dict[str, Any]) -> dict[str, Any]: kind = str(row.get("kind") or "") name = str(row.get("name") or "") synonym = str(row.get("synonym") or "") kind_ru = str(row.get("kind_ru") or RU_KIND.get(kind, kind)) full_name = ".".join(part for part in [kind_ru, name] if part) return { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "kind": kind, "kind_ru": kind_ru, "public_kind": row.get("public_kind") or PUBLIC_KIND.get(kind, "other"), "name": name or None, "synonym": synonym or None, "normalized_name": normalize(name), "normalized_synonym": normalize(synonym), "full_name": full_name, "normalized_full_name": normalize(full_name), "guid": str(row.get("guid") or "").lower(), "source": row.get("source") or "base", } def metadata_cache_upsert(config: dict[str, str], row: dict[str, Any]) -> None: item = cache_identity_row(config, row) if not item["kind"] or not item["guid"]: return metadata_guid_index_upsert( config, { **item, "guid_role": "metadata_object", "payload": metadata_cache_public_row(item), "source_file": item.get("guid"), }, ) now = time.time() with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_identity_cache ( server_key, database_name, kind, kind_ru, public_kind, name, synonym, normalized_name, normalized_synonym, full_name, normalized_full_name, guid, source, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :kind, :kind_ru, :public_kind, :name, :synonym, :normalized_name, :normalized_synonym, :full_name, :normalized_full_name, :guid, :source, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, kind, guid) DO UPDATE SET kind_ru=excluded.kind_ru, public_kind=excluded.public_kind, name=excluded.name, synonym=excluded.synonym, normalized_name=excluded.normalized_name, normalized_synonym=excluded.normalized_synonym, full_name=excluded.full_name, normalized_full_name=excluded.normalized_full_name, source=excluded.source, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, {**item, "updated_at": now, "last_seen_at": now}, ) def metadata_cache_lookup_row(base_id: str, kind: str | None, name: str) -> dict[str, Any] | None: config, _ = sql_config_for_base(base_id) if not config: return None wanted_kind, wanted_name = parse_object_query(kind, name) normalized = normalize(wanted_name) if not wanted_kind or not normalized: return None with cache_connection() as conn: row = conn.execute( """ SELECT * FROM metadata_identity_cache WHERE server_key = ? AND database_name = ? AND kind = ? AND (normalized_name = ? OR normalized_synonym = ? OR normalized_full_name = ?) ORDER BY CASE WHEN normalized_name = ? THEN 0 WHEN normalized_full_name = ? THEN 1 ELSE 2 END LIMIT 1 """, (cache_server_key(config), cache_database_name(config), wanted_kind, normalized, normalized, normalized, normalized, normalized), ).fetchone() return dict(row) if row else None def metadata_cache_lookup_guid(base_id: str, guid: str) -> dict[str, Any] | None: config, _ = sql_config_for_base(base_id) normalized_guid = str(guid or "").strip().lower() if not config or not is_guid_text(normalized_guid): return None with cache_connection() as conn: row = conn.execute( """ SELECT * FROM metadata_identity_cache WHERE server_key = ? AND database_name = ? AND guid = ? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), normalized_guid), ).fetchone() if row: return metadata_cache_public_row(dict(row)) payload = metadata_guid_index_lookup_payload(config, normalized_guid, "metadata_object") if isinstance(payload, dict) and payload.get("guid"): result = dict(payload) result.setdefault("status", "ok") result.setdefault("match_by", "cache_guid") return result return None def metadata_cache_list_rows( base_id: str, kind: str | None, *, limit: int, offset: int, ) -> tuple[list[dict[str, Any]], int] | None: config, _ = sql_config_for_base(base_id) if not config: return None wanted, requested_public = parse_kind_request(kind) where = ["server_key = ?", "database_name = ?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if wanted: where.append("kind = ?") params.append(wanted) elif requested_public: where.append("public_kind = ?") params.append(requested_public) where.append("name IS NOT NULL") where.append("name <> ''") where_sql = " AND ".join(where) with cache_connection() as conn: count = int( conn.execute( f"SELECT COUNT(*) AS count FROM metadata_identity_cache WHERE {where_sql}", params, ).fetchone()["count"] ) if count <= 0: return None rows = conn.execute( f""" SELECT * FROM metadata_identity_cache WHERE {where_sql} ORDER BY normalized_name, guid LIMIT ? OFFSET ? """, [*params, limit, offset], ).fetchall() return [dict(row) for row in rows], count def metadata_cache_public_row(row: dict[str, Any]) -> dict[str, Any]: name = best_text_variant(row.get("name")) synonym = best_text_variant(row.get("synonym")) if row.get("synonym") else None result = { "guid": row.get("guid"), "kind": row.get("kind"), "kind_ru": row.get("kind_ru"), "public_kind": row.get("public_kind"), "name": name, "synonym": synonym, "source": row.get("source") or "base", "status": "ok", "identity": { "guid": row.get("guid"), "name": name, **({"synonyms": {"ru": synonym}} if synonym else {}), }, "score": 1.0, "match_by": "cache", } public_ref = object_selector_ref(result.get("kind"), result.get("name")) if public_ref: result["ref"] = public_ref return result def metadata_guid_index_upsert(config: dict[str, str], item: dict[str, Any]) -> None: guid = str(item.get("guid") or item.get("type_guid") or "").lower() guid_role = str(item.get("guid_role") or "").strip() if not is_guid_text(guid) or not guid_role: return kind = str(item.get("kind") or "") name = str(item.get("name") or "") synonym = str(item.get("synonym") or "") kind_ru = str(item.get("kind_ru") or RU_KIND.get(kind, kind)) presentation = str(item.get("presentation") or "") full_name = str(item.get("full_name") or ".".join(part for part in [kind_ru, name] if part)) payload = item.get("payload") payload_json = json.dumps(payload, ensure_ascii=False, sort_keys=True) if isinstance(payload, dict) else item.get("payload_json") now = time.time() with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_guid_index ( server_key, database_name, guid, guid_role, kind, kind_ru, public_kind, name, synonym, normalized_name, normalized_synonym, full_name, normalized_full_name, presentation, normalized_presentation, owner_guid, owner_kind, owner_name, type_guid, value_guid, value_type_guid, value_presentation, source, source_file, payload_json, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :guid, :guid_role, :kind, :kind_ru, :public_kind, :name, :synonym, :normalized_name, :normalized_synonym, :full_name, :normalized_full_name, :presentation, :normalized_presentation, :owner_guid, :owner_kind, :owner_name, :type_guid, :value_guid, :value_type_guid, :value_presentation, :source, :source_file, :payload_json, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, guid, guid_role) DO UPDATE SET kind=excluded.kind, kind_ru=excluded.kind_ru, public_kind=excluded.public_kind, name=excluded.name, synonym=excluded.synonym, normalized_name=excluded.normalized_name, normalized_synonym=excluded.normalized_synonym, full_name=excluded.full_name, normalized_full_name=excluded.normalized_full_name, presentation=excluded.presentation, normalized_presentation=excluded.normalized_presentation, owner_guid=excluded.owner_guid, owner_kind=excluded.owner_kind, owner_name=excluded.owner_name, type_guid=excluded.type_guid, value_guid=excluded.value_guid, value_type_guid=excluded.value_type_guid, value_presentation=excluded.value_presentation, source=excluded.source, source_file=excluded.source_file, payload_json=excluded.payload_json, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "guid": guid, "guid_role": guid_role, "kind": kind or None, "kind_ru": kind_ru or None, "public_kind": item.get("public_kind") or (PUBLIC_KIND.get(kind, "other") if kind else None), "name": name or None, "synonym": synonym or None, "normalized_name": normalize(name), "normalized_synonym": normalize(synonym), "full_name": full_name or None, "normalized_full_name": normalize(full_name), "presentation": presentation or None, "normalized_presentation": normalize(presentation), "owner_guid": str(item.get("owner_guid") or "").lower() or None, "owner_kind": item.get("owner_kind"), "owner_name": item.get("owner_name"), "type_guid": str(item.get("type_guid") or "").lower() or None, "value_guid": str(item.get("value_guid") or "").lower() or None, "value_type_guid": str(item.get("value_type_guid") or "").lower() or None, "value_presentation": item.get("value_presentation"), "source": item.get("source") or "base", "source_file": item.get("source_file"), "payload_json": payload_json, "updated_at": now, "last_seen_at": now, }, ) def metadata_guid_index_lookup_types(config: dict[str, str], type_guids: set[str]) -> dict[str, dict[str, Any]]: wanted = sorted({str(guid or "").lower() for guid in type_guids if is_guid_text(str(guid or ""))}) if not wanted: return {} result: dict[str, dict[str, Any]] = {} with cache_connection() as conn: for start in range(0, len(wanted), 500): chunk = wanted[start : start + 500] placeholders = ",".join(["?"] * len(chunk)) rows = conn.execute( f""" SELECT guid, payload_json FROM metadata_guid_index WHERE server_key = ? AND database_name = ? AND guid_role IN ('generated_type', 'builtin_type', 'metadata_type', 'metadata_object') AND guid IN ({placeholders}) """, (cache_server_key(config), cache_database_name(config), *chunk), ).fetchall() for row in rows: try: payload = json.loads(row["payload_json"] or "{}") except Exception: continue if isinstance(payload, dict): result[str(row["guid"]).lower()] = payload return result def metadata_guid_index_lookup_payload(config: dict[str, str], guid: str, guid_role: str) -> dict[str, Any] | None: normalized_guid = str(guid or "").lower() normalized_role = str(guid_role or "").strip() if not is_guid_text(normalized_guid) or not normalized_role: return None with cache_connection() as conn: row = conn.execute( """ SELECT payload_json FROM metadata_guid_index WHERE server_key = ? AND database_name = ? AND guid = ? AND guid_role = ? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), normalized_guid, normalized_role), ).fetchone() if not row: return None try: payload = json.loads(row["payload_json"] or "{}") except Exception: return None return payload if isinstance(payload, dict) else None EXTENSION_DEFINITION_CACHE_ROLE = "extension_definition_v1" EXTENSION_DEFINITION_CACHE_MARKER_ROLE = "extension_definition_cache_status_v1" EXTENSION_DEFINITION_CACHE_MARKER_GUID = "00000000-0000-0000-0000-000000000001" def metadata_guid_index_lookup_by_name( config: dict[str, str] | None, *, guid_role: str, query: str, limit: int, ) -> list[dict[str, Any]]: if not config or not str(guid_role or "").strip() or limit <= 0: return [] normalized = normalize(query) normalized_exact = normalize_exact(query) if not normalized and not normalized_exact: return [] like_value = f"%{normalized}%" result: list[dict[str, Any]] = [] with cache_connection() as conn: rows = conn.execute( """ SELECT payload_json FROM metadata_guid_index WHERE server_key = ? AND database_name = ? AND guid_role = ? AND ( normalized_name = ? OR normalized_synonym = ? OR normalized_full_name = ? OR normalized_presentation = ? OR normalized_name LIKE ? OR normalized_synonym LIKE ? OR normalized_full_name LIKE ? OR normalized_presentation LIKE ? ) ORDER BY CASE WHEN normalized_name = ? THEN 0 WHEN normalized_synonym = ? THEN 1 WHEN normalized_full_name = ? THEN 2 WHEN normalized_presentation = ? THEN 3 ELSE 4 END, normalized_name, guid LIMIT ? """, ( cache_server_key(config), cache_database_name(config), guid_role, normalized, normalized, normalized, normalized, like_value, like_value, like_value, like_value, normalized, normalized, normalized, normalized, int(limit), ), ).fetchall() for row in rows: try: payload = json.loads(row["payload_json"] or "{}") except Exception: continue if isinstance(payload, dict): result.append(payload) return result def extension_route_cache_upsert(config: dict[str, str] | None, match: dict[str, Any], *, descriptor_payload_sha1: str | None = None, freshness_status: str = "fresh") -> None: if not config: return route = match.get("route") if isinstance(match.get("route"), dict) else {} descriptor_cas_key = str(route.get("file_name") or match.get("guid") or "").strip().lower() if not descriptor_cas_key: return manifest_entry = route.get("manifest_entry") if isinstance(route.get("manifest_entry"), dict) else {} manifest_entries = match.get("manifest_entries") if isinstance(match.get("manifest_entries"), list) else [] extension = (match.get("origin") or {}).get("extension") if isinstance(match.get("origin"), dict) else None if not isinstance(extension, dict): extension = manifest_entry.get("extension") if isinstance(manifest_entry.get("extension"), dict) else {} kind = str(match.get("kind") or "") kind_ru = str(match.get("kind_ru") or RU_KIND.get(kind, kind)) name = str(match.get("name") or "") synonym = str(match.get("synonym") or "") full_name = ".".join(part for part in [kind_ru, name] if part) now = time.time() with cache_connection() as conn: conn.execute( """ INSERT INTO extension_route_cache ( server_key, database_name, extension_guid, extension_name, root_cas_key, object_key, object_base_id, descriptor_cas_key, object_kind, kind_ru, name, synonym, normalized_name, normalized_synonym, normalized_full_name, guid, route_json, manifest_entries_json, descriptor_payload_sha1, freshness_status, updated_at, validated_at, last_seen_at, stale_reason ) VALUES ( :server_key, :database_name, :extension_guid, :extension_name, :root_cas_key, :object_key, :object_base_id, :descriptor_cas_key, :object_kind, :kind_ru, :name, :synonym, :normalized_name, :normalized_synonym, :normalized_full_name, :guid, :route_json, :manifest_entries_json, :descriptor_payload_sha1, :freshness_status, :updated_at, :validated_at, :last_seen_at, NULL ) ON CONFLICT(server_key, database_name, descriptor_cas_key) DO UPDATE SET extension_guid=excluded.extension_guid, extension_name=excluded.extension_name, root_cas_key=excluded.root_cas_key, object_key=excluded.object_key, object_base_id=excluded.object_base_id, object_kind=excluded.object_kind, kind_ru=excluded.kind_ru, name=excluded.name, synonym=excluded.synonym, normalized_name=excluded.normalized_name, normalized_synonym=excluded.normalized_synonym, normalized_full_name=excluded.normalized_full_name, guid=excluded.guid, route_json=excluded.route_json, manifest_entries_json=excluded.manifest_entries_json, descriptor_payload_sha1=excluded.descriptor_payload_sha1, freshness_status=excluded.freshness_status, updated_at=excluded.updated_at, validated_at=excluded.validated_at, last_seen_at=excluded.last_seen_at, stale_reason=NULL """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "extension_guid": str((extension or {}).get("guid") or "").lower() or None, "extension_name": (extension or {}).get("name"), "root_cas_key": str(route.get("root_cas_key") or manifest_entry.get("root_cas_key") or "").lower() or None, "object_key": str(manifest_entry.get("object_id") or descriptor_cas_key), "object_base_id": str(manifest_entry.get("object_base_id") or "").lower() or None, "descriptor_cas_key": descriptor_cas_key, "object_kind": kind or None, "kind_ru": kind_ru or None, "name": name or None, "synonym": synonym or None, "normalized_name": normalize(name), "normalized_synonym": normalize(synonym), "normalized_full_name": normalize(full_name), "guid": str(match.get("guid") or "").lower() or None, "route_json": json.dumps(route, ensure_ascii=False, sort_keys=True), "manifest_entries_json": json.dumps(manifest_entries, ensure_ascii=False, sort_keys=True), "descriptor_payload_sha1": descriptor_payload_sha1, "freshness_status": freshness_status, "updated_at": now, "validated_at": now if freshness_status == "fresh" else None, "last_seen_at": now, }, ) def extension_route_cache_mark_stale(config: dict[str, str] | None, descriptor_cas_key: str, reason: str) -> None: if not config: return key = str(descriptor_cas_key or "").strip().lower() if not key: return now = time.time() with cache_connection() as conn: conn.execute( """ UPDATE extension_route_cache SET freshness_status='stale', stale_reason=?, validated_at=?, last_seen_at=? WHERE server_key=? AND database_name=? AND descriptor_cas_key=? """, (reason, now, now, cache_server_key(config), cache_database_name(config), key), ) def extension_route_cache_lookup( config: dict[str, str] | None, *, query: str, kind_filter: str | None, guid_filter: str, extension_guid: str | None, limit: int, ) -> list[dict[str, Any]]: if not config or limit <= 0: return [] clauses = ["server_key=?", "database_name=?", "freshness_status!='stale'"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if kind_filter: clauses.append("object_kind=?") params.append(kind_filter) if extension_guid: clauses.append("extension_guid=?") params.append(extension_guid.lower()) normalized_query = normalize(query) if guid_filter: clauses.append("(guid=? OR descriptor_cas_key=? OR object_base_id=?)") params.extend([guid_filter, guid_filter, guid_filter]) elif normalized_query: like_value = f"%{normalized_query}%" clauses.append("(normalized_name=? OR normalized_synonym=? OR normalized_full_name=? OR normalized_name LIKE ? OR normalized_synonym LIKE ? OR normalized_full_name LIKE ?)") params.extend([normalized_query, normalized_query, normalized_query, like_value, like_value, like_value]) with cache_connection() as conn: rows = conn.execute( f""" SELECT * FROM extension_route_cache WHERE {' AND '.join(clauses)} ORDER BY CASE WHEN normalized_name = ? THEN 0 WHEN normalized_synonym = ? THEN 1 WHEN normalized_full_name = ? THEN 2 ELSE 3 END, normalized_name, descriptor_cas_key LIMIT ? """, (*params, normalized_query, normalized_query, normalized_query, limit), ).fetchall() return [dict(row) for row in rows] def extension_route_cache_row_to_match(base_id: str, row: dict[str, Any], *, include_storage: bool, freshness: dict[str, Any]) -> dict[str, Any]: try: route = json.loads(row.get("route_json") or "{}") except Exception: route = {} try: manifest_entries = json.loads(row.get("manifest_entries_json") or "[]") except Exception: manifest_entries = [] if not isinstance(route, dict): route = {} if not isinstance(manifest_entries, list): manifest_entries = [] extension = {"guid": row.get("extension_guid"), "name": row.get("extension_name")} if row.get("extension_guid") or row.get("extension_name") else None kind = row.get("object_kind") identity = {"name": row.get("name"), "guid": row.get("guid") or row.get("descriptor_cas_key")} match = { "kind": kind, "kind_ru": row.get("kind_ru") or RU_KIND.get(str(kind or ""), kind), "name": row.get("name"), "synonym": row.get("synonym"), "guid": row.get("guid") or row.get("descriptor_cas_key"), "match_by": "source_cache", "origin": { "source": "extension", "presentation": "Расширение", "extension": extension, "status": "ok" if extension else "extension_unresolved", }, "route": route if include_storage else {key: route.get(key) for key in ("route_type", "table", "file_name", "manifest_entries")}, "read_selectors": extension_object_read_selectors(base_id, str(kind or ""), identity, route), "freshness": freshness, } if include_storage: match["manifest_entries"] = manifest_entries match["cache"] = { "source": "extension_route_cache", "descriptor_payload_sha1": row.get("descriptor_payload_sha1"), "updated_at": row.get("updated_at"), "validated_at": row.get("validated_at"), } return match def extension_route_cache_recent_freshness(row: dict[str, Any], *, ttl_seconds: int) -> dict[str, Any] | None: if ttl_seconds <= 0: return None if str(row.get("freshness_status") or "").strip() == "stale": return None try: validated_at = float(row.get("validated_at") or 0) except Exception: validated_at = 0 if validated_at <= 0: return None age_seconds = max(0.0, time.time() - validated_at) if age_seconds > ttl_seconds: return None return { "status": "fresh", "validated_by": "recent_source_cache", "validation_required_after_seconds": ttl_seconds, "age_seconds": round(age_seconds, 3), "validated_at": validated_at, } def validate_extension_route_cache_row(base_id: str, config: dict[str, str] | None, row: dict[str, Any], *, timeout_seconds: int) -> tuple[dict[str, Any] | None, dict[str, Any]]: descriptor_key = str(row.get("descriptor_cas_key") or "").strip().lower() extension_guid = str(row.get("extension_guid") or "").strip().lower() or None manifests, diagnostics = live_extension_manifests(base_id, extension_guid=extension_guid, timeout_seconds=timeout_seconds) for manifest in manifests: entries = [entry for entry in manifest.get("entries") or [] if isinstance(entry, dict)] current = next((entry for entry in entries if str(entry.get("cas_key") or "").lower() == descriptor_key), None) if not current: continue descriptor_data, _descriptor_config, descriptor_error = read_storage_file_bytes( base_id, "ConfigCAS", descriptor_key, timeout_seconds=timeout_seconds, ) if descriptor_data is not None and not descriptor_error: try: from parser.cas_payload import classify_payload descriptor_identity = config_identity_from_bytes(descriptor_data) or {} detected_kind = extension_metadata_payload_kind( descriptor_data, descriptor_identity, classify_payload(descriptor_data, include_text=False), ) except Exception: detected_kind = None cached_kind = str(row.get("object_kind") or "").strip() if detected_kind and cached_kind and detected_kind != cached_kind: reason = f"semantic_kind_mismatch:{cached_kind}->{detected_kind}" extension_route_cache_mark_stale(config, descriptor_key, reason) return None, { "status": "stale", "validated_by": "live_manifest_and_descriptor", "reason": reason, } root_cas_key = str(manifest.get("root_cas_key") or "").lower() now = time.time() if config: with cache_connection() as conn: conn.execute( """ UPDATE extension_route_cache SET freshness_status='fresh', validated_at=?, last_seen_at=?, root_cas_key=?, stale_reason=NULL WHERE server_key=? AND database_name=? AND descriptor_cas_key=? """, (now, now, root_cas_key or row.get("root_cas_key"), cache_server_key(config), cache_database_name(config), descriptor_key), ) return dict(row), { "status": "fresh", "validated_by": "live_manifest", "root_cas_key": root_cas_key, "root_changed": bool(row.get("root_cas_key") and root_cas_key and str(row.get("root_cas_key")).lower() != root_cas_key), } reason = "descriptor_not_present_in_current_manifest" extension_route_cache_mark_stale(config, descriptor_key, reason) return None, {"status": "stale", "validated_by": "live_manifest", "reason": reason, "diagnostics": diagnostics} def decoded_artifact_cache_lookup(config: dict[str, str] | None, *, artifact_kind: str, content_sha1: str) -> dict[str, Any] | None: if not config: return None kind = str(artifact_kind or "").strip() sha1 = str(content_sha1 or "").strip().lower() if not kind or not re.fullmatch(r"[0-9a-f]{40}", sha1): return None with cache_connection() as conn: row = conn.execute( """ SELECT artifact_json, semantic_text FROM decoded_artifact_cache WHERE server_key=? AND database_name=? AND artifact_kind=? AND content_sha1=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), kind, sha1), ).fetchone() if not row: return None conn.execute( """ UPDATE decoded_artifact_cache SET last_seen_at=? WHERE server_key=? AND database_name=? AND artifact_kind=? AND content_sha1=? """, (time.time(), cache_server_key(config), cache_database_name(config), kind, sha1), ) try: artifact = json.loads(row["artifact_json"] or "{}") except Exception: return None if isinstance(artifact, dict): artifact.setdefault("artifact_cache", {"status": "hit", "content_sha1": sha1}) if row["semantic_text"]: artifact.setdefault("semantic_text", row["semantic_text"]) return artifact def decoded_artifact_cache_lookup_source_prefix( config: dict[str, str] | None, *, artifact_kind: str, source_table: str, source_file_prefix: str, ) -> dict[str, Any] | None: """Find a prior decoded candidate route; callers must still live-SHA verify it.""" if not config or not source_file_prefix: return None with cache_connection() as conn: row = conn.execute( """ SELECT source_file, content_sha1, artifact_json FROM decoded_artifact_cache WHERE server_key=? AND database_name=? AND artifact_kind=? AND source_table=? AND source_file LIKE ? ORDER BY last_seen_at DESC LIMIT 1 """, (cache_server_key(config), cache_database_name(config), artifact_kind, source_table, f"{source_file_prefix}%"), ).fetchone() if not row: return None try: artifact = json.loads(str(row["artifact_json"] or "{}")) except (TypeError, ValueError): return None if not isinstance(artifact, dict): return None return {"source_file": str(row["source_file"] or ""), "content_sha1": str(row["content_sha1"] or ""), "artifact": artifact} return None def decoded_artifact_cache_upsert( config: dict[str, str] | None, *, artifact_kind: str, content_sha1: str, source_table: str, source_file: str, payload_bytes: int, artifact: dict[str, Any], semantic_text: str | None = None, ) -> None: if not config or not isinstance(artifact, dict): return kind = str(artifact_kind or "").strip() sha1 = str(content_sha1 or "").strip().lower() if not kind or not re.fullmatch(r"[0-9a-f]{40}", sha1): return now = time.time() with cache_connection() as conn: conn.execute( """ INSERT INTO decoded_artifact_cache ( server_key, database_name, artifact_kind, content_sha1, source_table, source_file, payload_bytes, artifact_json, semantic_text, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :artifact_kind, :content_sha1, :source_table, :source_file, :payload_bytes, :artifact_json, :semantic_text, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, artifact_kind, content_sha1) DO UPDATE SET source_table=excluded.source_table, source_file=excluded.source_file, payload_bytes=excluded.payload_bytes, artifact_json=excluded.artifact_json, semantic_text=excluded.semantic_text, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "artifact_kind": kind, "content_sha1": sha1, "source_table": source_table, "source_file": source_file, "payload_bytes": int(payload_bytes or 0), "artifact_json": json.dumps(artifact, ensure_ascii=False, sort_keys=True), "semantic_text": semantic_text, "updated_at": now, "last_seen_at": now, }, ) def template_structure_semantic_text(structure: dict[str, Any]) -> str: if not isinstance(structure, dict): return "" lines: list[str] = [] if structure.get("format"): lines.append(f"format: {structure.get('format')}") dimensions = structure.get("dimensions") if isinstance(structure.get("dimensions"), dict) else {} if dimensions: lines.append(f"dimensions: rows={dimensions.get('rows')} columns={dimensions.get('columns')}") capacity_dimensions = structure.get("capacity_dimensions") if isinstance(structure.get("capacity_dimensions"), dict) else {} if capacity_dimensions and capacity_dimensions != dimensions: lines.append(f"capacity_dimensions: rows={capacity_dimensions.get('rows')} columns={capacity_dimensions.get('columns')}") used_dimensions = structure.get("used_dimensions") if isinstance(structure.get("used_dimensions"), dict) else {} if used_dimensions: lines.append(f"used_dimensions: rows={used_dimensions.get('rows')} columns={used_dimensions.get('columns')}") format_dimensions = structure.get("format_dimensions") if isinstance(structure.get("format_dimensions"), dict) else {} if format_dimensions: lines.append(f"format_dimensions: rows={format_dimensions.get('rows')} columns={format_dimensions.get('columns')}") for area in (structure.get("named_areas") or [])[:200]: if isinstance(area, dict) and area.get("name"): range_info = area.get("range") if isinstance(area.get("range"), dict) else {} one_based = range_info.get("one_based") if isinstance(range_info.get("one_based"), dict) else {} lines.append( "area: " + str(area.get("name")) + ( f" R{one_based.get('top')}C{one_based.get('left')}:R{one_based.get('bottom')}C{one_based.get('right')}" if one_based else "" ) ) for parameter in (structure.get("parameters") or [])[:200]: if isinstance(parameter, dict) and parameter.get("name"): lines.append(f"parameter: {parameter.get('name')}") for cell in (structure.get("cells") or [])[:200]: if isinstance(cell, dict): text = str(cell.get("text") or cell.get("parameter") or "").strip() if text: lines.append(f"cell R{cell.get('row')}C{cell.get('column')}: {text[:200]}") for hint in (structure.get("cell_coordinate_hints") or [])[:200]: if isinstance(hint, dict): text = str(hint.get("text") or "").strip() one_based = hint.get("one_based") if isinstance(hint.get("one_based"), dict) else {} row = one_based.get("row") column = one_based.get("column") if text and (row is not None or column is not None): lines.append(f"cell_hint R{row}C{column}: {text[:200]}") return "\n".join(lines)[:20000] def semantic_document_cache_upsert( config: dict[str, str] | None, *, document_id: str, object_kind: str | None, object_guid: str | None, object_name: str | None, extension_guid: str | None, source_route: dict[str, Any], content_sha1: str, text: str, authoritative_source: str = "decoded_artifact_cache", ) -> None: if not config or not document_id or not text: return now = time.time() with cache_connection() as conn: conn.execute( """ INSERT INTO semantic_document_cache ( server_key, database_name, document_id, object_kind, object_guid, object_name, extension_guid, source_route_json, content_sha1, text_preview, embedding_model, embedding_json, vector_status, authoritative_source, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :document_id, :object_kind, :object_guid, :object_name, :extension_guid, :source_route_json, :content_sha1, :text_preview, NULL, NULL, 'pending_embedding', :authoritative_source, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, document_id) DO UPDATE SET object_kind=excluded.object_kind, object_guid=excluded.object_guid, object_name=excluded.object_name, extension_guid=excluded.extension_guid, source_route_json=excluded.source_route_json, content_sha1=excluded.content_sha1, text_preview=excluded.text_preview, vector_status=CASE WHEN semantic_document_cache.content_sha1 = excluded.content_sha1 THEN semantic_document_cache.vector_status ELSE 'pending_embedding' END, authoritative_source=excluded.authoritative_source, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "document_id": document_id, "object_kind": object_kind, "object_guid": str(object_guid or "").lower() or None, "object_name": object_name, "extension_guid": str(extension_guid or "").lower() or None, "source_route_json": json.dumps(source_route, ensure_ascii=False, sort_keys=True), "content_sha1": content_sha1, "text_preview": text[:4000], "authoritative_source": authoritative_source, "updated_at": now, "last_seen_at": now, }, ) def semantic_document_cache_lookup(config: dict[str, str] | None, document_id: str) -> dict[str, Any] | None: if not config or not document_id: return None with cache_connection() as conn: row = conn.execute( """ SELECT document_id, object_kind, object_guid, object_name, extension_guid, source_route_json, content_sha1, text_preview, embedding_model, embedding_json, vector_status, authoritative_source, updated_at, last_seen_at FROM semantic_document_cache WHERE server_key=? AND database_name=? AND document_id=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), str(document_id or "")), ).fetchone() return dict(row) if row else None def semantic_document_cache_mark_seen(config: dict[str, str] | None, document_id: str) -> None: if not config or not document_id: return with cache_connection() as conn: conn.execute( """ UPDATE semantic_document_cache SET last_seen_at=? WHERE server_key=? AND database_name=? AND document_id=? """, (time.time(), cache_server_key(config), cache_database_name(config), str(document_id or "")), ) def semantic_document_cache_mark_changed(config: dict[str, str] | None, document_id: str) -> None: if not config or not document_id: return now = time.time() with cache_connection() as conn: conn.execute( """ UPDATE semantic_document_cache SET vector_status='error', embedding_model=NULL, embedding_json=NULL, updated_at=?, last_seen_at=? WHERE server_key=? AND database_name=? AND document_id=? """, (now, now, cache_server_key(config), cache_database_name(config), str(document_id or "")), ) def semantic_document_cache_delete(config: dict[str, str] | None, document_id: str) -> None: if not config or not document_id: return with cache_connection() as conn: conn.execute( """ DELETE FROM semantic_document_cache WHERE server_key=? AND database_name=? AND document_id=? """, (cache_server_key(config), cache_database_name(config), str(document_id or "")), ) def refreshed_semantic_document_id(old_document_id: str, source_route: dict[str, Any], content_sha1: str) -> str: old_text = str(old_document_id or "").strip() table = str(source_route.get("table") or "ConfigCAS").strip() or "ConfigCAS" part_id = str(source_route.get("part_id") or source_route.get("file_name") or "").strip() if old_text.startswith("template_part:") and part_id and re.fullmatch(r"[0-9a-f]{40}", str(content_sha1 or "")): return f"template_part:{table}:{part_id}:{str(content_sha1).lower()}" return old_text def numeric_vector(value: Any) -> list[float] | None: if not isinstance(value, list) or not value: return None vector: list[float] = [] for item in value: if not isinstance(item, (int, float)) or isinstance(item, bool): return None vector.append(float(item)) return vector def cosine_similarity(left: list[float], right: list[float]) -> float | None: if not left or len(left) != len(right): return None dot = sum(a * b for a, b in zip(left, right)) left_norm = math.sqrt(sum(a * a for a in left)) right_norm = math.sqrt(sum(b * b for b in right)) if not left_norm or not right_norm: return None return dot / (left_norm * right_norm) CODE_INDEX_VECTOR_MODEL = "local-code-hashing-v1" CODE_INDEX_VECTOR_DIMENSIONS = 64 CODE_INDEX_METHODS = { "metadata.code_index.build", "metadata.code_index.status", "metadata.code_index.search", "metadata.code_index.verify", "metadata.code_index.refresh_changed", "metadata.code_index.sync_pending", "metadata.code_index.poll_changes", "metadata.code_vector.pending", "metadata.code_vector.embedding.upsert", "metadata.code_vector.search", } CODE_INDEX_SAVED_OVERLAYS = { "Config": "ConfigSave", "ConfigCAS": "ConfigCASSave", } CODE_INDEX_ACTIVE_TABLES = {saved: active for active, saved in CODE_INDEX_SAVED_OVERLAYS.items()} def code_index_state_status(config: dict[str, str]) -> dict[str, Any]: server_key = cache_server_key(config) database_name = cache_database_name(config) now = time.time() with cache_connection() as conn: conn.execute( """ INSERT OR IGNORE INTO metadata_code_index_state ( server_key, database_name, source_revision, dirty, updated_at ) VALUES (?, ?, 0, 0, ?) """, (server_key, database_name, now), ) state = conn.execute( """ SELECT source_revision, dirty, dirty_reason, dirty_since, last_indexed_at, updated_at FROM metadata_code_index_state WHERE server_key=? AND database_name=? """, (server_key, database_name), ).fetchone() pending = conn.execute( """ SELECT COUNT(*) AS count FROM metadata_code_index_outbox WHERE server_key=? AND database_name=? AND status='pending' """, (server_key, database_name), ).fetchone() index_stats = conn.execute( """ SELECT COUNT(*) AS count, MAX(updated_at) AS newest_updated_at FROM metadata_code_index_cache WHERE server_key=? AND database_name=? """, (server_key, database_name), ).fetchone() source_revision = int((state or {})["source_revision"] or 0) dirty = bool((state or {})["dirty"]) or int((pending or {})["count"] or 0) > 0 token_payload = "|".join( [ server_key, database_name, str(source_revision), "dirty" if dirty else "clean", str(int((index_stats or {})["count"] or 0)), str((index_stats or {})["newest_updated_at"] or ""), ] ) revision_token = f"code-index:{hashlib.sha256(token_payload.encode('utf-8')).hexdigest()}" return { "status": "dirty" if dirty else "clean", "source_revision": source_revision, "revision_token": revision_token, "snapshot_token": None if dirty else revision_token, "pending_events": int((pending or {})["count"] or 0), "dirty_reason": (state or {})["dirty_reason"], "dirty_since": (state or {})["dirty_since"], "last_indexed_at": (state or {})["last_indexed_at"], } def code_index_mark_dirty( config: dict[str, str], *, reason: str, source_table: str | None = None, file_names: list[str] | None = None, event_kind: str = "saved_state_changed", ) -> dict[str, Any]: server_key = cache_server_key(config) database_name = cache_database_name(config) now = time.time() normalized_files = sorted({str(item or "").strip() for item in (file_names or []) if str(item or "").strip()}) event_files: list[str | None] = normalized_files or [None] with cache_connection() as conn: conn.execute( """ INSERT OR IGNORE INTO metadata_code_index_state ( server_key, database_name, source_revision, dirty, updated_at ) VALUES (?, ?, 0, 0, ?) """, (server_key, database_name, now), ) conn.execute( """ UPDATE metadata_code_index_state SET source_revision=source_revision+1, dirty=1, dirty_reason=?, dirty_since=COALESCE(dirty_since, ?), updated_at=? WHERE server_key=? AND database_name=? """, (reason, now, now, server_key, database_name), ) revision_row = conn.execute( """ SELECT source_revision FROM metadata_code_index_state WHERE server_key=? AND database_name=? """, (server_key, database_name), ).fetchone() source_revision = int((revision_row or {})["source_revision"] or 0) for file_name in event_files: conn.execute( """ INSERT INTO metadata_code_index_outbox ( server_key, database_name, event_id, source_revision, event_kind, source_table, file_name, reason, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?) """, ( server_key, database_name, uuid.uuid4().hex, source_revision, event_kind, source_table, file_name, reason, now, ), ) return code_index_state_status(config) def code_index_mark_indexed( config: dict[str, str], *, source_table: str | None = None, file_names: list[str] | None = None, complete_table: bool = False, ) -> dict[str, Any]: server_key = cache_server_key(config) database_name = cache_database_name(config) now = time.time() normalized_files = sorted({str(item or "").strip() for item in (file_names or []) if str(item or "").strip()}) with cache_connection() as conn: conditions = ["server_key=?", "database_name=?", "status='pending'"] params: list[Any] = [server_key, database_name] if source_table: conditions.append("source_table=?") params.append(source_table) if normalized_files and not complete_table: placeholders = ",".join("?" for _ in normalized_files) conditions.append(f"file_name IN ({placeholders})") params.extend(normalized_files) elif not complete_table and not normalized_files: conditions.append("1=0") conn.execute( f""" UPDATE metadata_code_index_outbox SET status='processed', processed_at=? WHERE {' AND '.join(conditions)} """, (now, *params), ) pending = conn.execute( """ SELECT COUNT(*) AS count FROM metadata_code_index_outbox WHERE server_key=? AND database_name=? AND status='pending' """, (server_key, database_name), ).fetchone() dirty = int((pending or {})["count"] or 0) > 0 conn.execute( """ INSERT OR IGNORE INTO metadata_code_index_state ( server_key, database_name, source_revision, dirty, updated_at ) VALUES (?, ?, 0, 0, ?) """, (server_key, database_name, now), ) conn.execute( """ UPDATE metadata_code_index_state SET dirty=?, dirty_reason=CASE WHEN ?=0 THEN NULL ELSE dirty_reason END, dirty_since=CASE WHEN ?=0 THEN NULL ELSE dirty_since END, last_indexed_at=?, updated_at=? WHERE server_key=? AND database_name=? """, (1 if dirty else 0, 1 if dirty else 0, 1 if dirty else 0, now, now, server_key, database_name), ) return code_index_state_status(config) def code_index_overlay_tombstone_set( config: dict[str, str], *, source_table: str, file_name: str, reason: str, ) -> None: if source_table not in CODE_INDEX_ACTIVE_TABLES or not file_name: return state = code_index_state_status(config) with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_code_overlay_tombstone ( server_key, database_name, source_table, file_name, reason, source_revision, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(server_key, database_name, source_table, file_name) DO UPDATE SET reason=excluded.reason, source_revision=excluded.source_revision, created_at=excluded.created_at """, ( cache_server_key(config), cache_database_name(config), source_table, file_name, reason, state.get("source_revision"), time.time(), ), ) def code_index_overlay_tombstone_clear(config: dict[str, str], *, source_table: str, file_name: str) -> None: if source_table not in CODE_INDEX_ACTIVE_TABLES or not file_name: return with cache_connection() as conn: conn.execute( """ DELETE FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? """, (cache_server_key(config), cache_database_name(config), source_table, file_name), ) def code_text_sha1(text: str) -> str: return hashlib.sha1(str(text or "").replace("\r\n", "\n").replace("\r", "\n").encode("utf-8")).hexdigest() def code_hashing_embedding(text: str, *, dimensions: int = CODE_INDEX_VECTOR_DIMENSIONS) -> list[float]: vector = [0.0] * dimensions for token in re.findall(r"[\wА-Яа-яЁё]+", str(text or "").casefold()): digest = hashlib.sha1(token.encode("utf-8")).digest() index = int.from_bytes(digest[:4], "little") % dimensions sign = 1.0 if digest[4] % 2 == 0 else -1.0 vector[index] += sign norm = math.sqrt(sum(value * value for value in vector)) return [value / norm for value in vector] if norm else vector def public_routine_blocks(text: str) -> list[dict[str, Any]]: try: from parser.bsl_validation import routine_blocks routines = list(routine_blocks(str(text or ""))) except Exception: routines = [] return [ { "kind": routine.get("kind"), "name": routine.get("name"), "line_start": routine.get("line_start"), "line_end": routine.get("line_end"), } for routine in routines ] def code_module_owner_from_cache(config: dict[str, str] | None, module_ref: str) -> dict[str, Any]: form_owner = metadata_form_owner_cache_lookup(config, module_ref=module_ref) if form_owner: owner = form_owner.get("owner") if isinstance(form_owner.get("owner"), dict) else {} form = form_owner.get("form") if isinstance(form_owner.get("form"), dict) else {} extension = form_owner.get("extension") if isinstance(form_owner.get("extension"), dict) else {} return { "owner_kind": owner.get("kind") or form.get("kind"), "owner_name": owner.get("name") or form.get("name"), "owner_guid": owner.get("guid") or form.get("guid"), "form_name": form.get("name"), "extension_guid": extension.get("guid"), "extension_name": extension.get("name"), "bsl_offset": form_owner.get("bsl_offset"), } module_owner = metadata_module_owner_cache_lookup(config or {}, module_ref) if config else None if not module_owner and config: base_module_ref = normalize_module_ref_for_form_owner(module_ref) if base_module_ref != module_ref: module_owner = metadata_module_owner_cache_lookup(config, base_module_ref) if module_owner: owner = module_owner.get("owner") or {} module_payload = module_owner.get("module_payload") if isinstance(module_owner.get("module_payload"), dict) else {} extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} module_table, module_file_name, _ = parse_module_id(module_ref) saved_extension_guid = "" if module_table == "ConfigCASSave" and "__" in str(module_file_name or ""): saved_extension_guid = str(module_file_name or "").split("__", 1)[0].strip().lower() return { "owner_kind": owner.get("kind"), "owner_name": owner.get("name"), "owner_guid": owner.get("guid"), "form_name": None, "extension_guid": extension.get("guid") or (saved_extension_guid if is_guid_text(saved_extension_guid) else None), "extension_name": extension.get("name"), "bsl_offset": None, } return {} def extract_code_index_text_from_payload(data: bytes, *, module_ref: str, bsl_offset: int | None = None) -> tuple[str, dict[str, Any]]: table, file_name, stream_index = parse_module_id(module_ref) if stream_index is not None: try: from parser.cas_payload import classify_payload classified = classify_payload(data, include_text=True) except Exception as exc: return "", {"status": "error", "message": str(exc)} streams = classified.get("stream_blocks") or [] if stream_index < 0 or stream_index >= len(streams): return "", {"status": "not_found", "message": "Stream index not found."} return str((streams[stream_index] or {}).get("text") or ""), {"status": "ok", "source": "stream", "stream_index": stream_index} decoded = payload_text_from_bytes(data) container_text = str(decoded.get("text") or "") text, extraction = extract_bsl_text_from_container(container_text, bsl_offset=bsl_offset) return str(text or ""), {"status": extraction.get("status"), "source": "bsl_container", **extraction} def code_index_owner_payload( config: dict[str, str] | None, module_ref: str, item: dict[str, Any], ) -> dict[str, Any]: owner = { "kind": item.get("owner_kind"), "name": item.get("owner_name"), "guid": item.get("owner_guid"), "form": item.get("form_name"), "extension": {"guid": item.get("extension_guid"), "name": item.get("extension_name")}, } if not config or all(owner.get(key) for key in ("kind", "name", "guid")): return owner cached_owner = code_module_owner_from_cache(config, module_ref) if not cached_owner: return owner for public_key, cache_key in (("kind", "owner_kind"), ("name", "owner_name"), ("guid", "owner_guid"), ("form", "form_name")): if not owner.get(public_key) and cached_owner.get(cache_key): owner[public_key] = cached_owner.get(cache_key) cached_extension_guid = cached_owner.get("extension_guid") cached_extension_name = cached_owner.get("extension_name") if cached_extension_guid and not owner["extension"].get("guid"): owner["extension"]["guid"] = cached_extension_guid if cached_extension_name and not owner["extension"].get("name"): owner["extension"]["name"] = cached_extension_name if any(owner.get(key) for key in ("kind", "name", "guid")): with cache_connection() as conn: conn.execute( """ UPDATE metadata_code_index_cache SET owner_kind=COALESCE(owner_kind, ?), owner_name=COALESCE(owner_name, ?), owner_guid=COALESCE(owner_guid, ?), form_name=COALESCE(form_name, ?), extension_guid=COALESCE(extension_guid, ?), extension_name=COALESCE(extension_name, ?) WHERE server_key=? AND database_name=? AND module_ref=? """, ( owner.get("kind"), owner.get("name"), owner.get("guid"), owner.get("form"), owner["extension"].get("guid"), owner["extension"].get("name"), cache_server_key(config), cache_database_name(config), module_ref, ), ) return owner def code_index_row_payload(row: sqlite3.Row | dict[str, Any], config: dict[str, str] | None = None) -> dict[str, Any]: item = dict(row) try: routines = json.loads(item.get("routines_json") or "[]") except Exception: routines = [] owner = code_index_owner_payload(config, str(item.get("module_ref") or ""), item) return { "module_ref": item.get("module_ref"), "table": item.get("source_table"), "file_name": item.get("file_name"), "owner": owner, "bsl_offset": item.get("bsl_offset"), "stream_index": item.get("stream_index"), "payload_sha1": item.get("payload_sha1"), "text_sha1": item.get("text_sha1"), "text": item.get("text"), "routines": routines if isinstance(routines, list) else [], "routine_count": item.get("routine_count"), "source_bytes": item.get("source_bytes"), "updated_at": item.get("updated_at"), "last_verified_at": item.get("last_verified_at"), "last_seen_at": item.get("last_seen_at"), } def collect_backup_ids(value: Any) -> list[str]: found: list[str] = [] def walk(item: Any) -> None: if isinstance(item, dict): backup_id = item.get("backup_id") if isinstance(backup_id, str) and re.fullmatch(r"[0-9a-f]{32}", backup_id) and backup_id not in found: found.append(backup_id) backup_ids = item.get("backup_ids") if isinstance(backup_ids, list): for candidate in backup_ids: if isinstance(candidate, str) and re.fullmatch(r"[0-9a-f]{32}", candidate) and candidate not in found: found.append(candidate) for key, child in item.items(): if key not in {"backup_id", "backup_ids"}: walk(child) elif isinstance(item, list): for child in item: walk(child) walk(value) return found def write_history_target_summary(result: dict[str, Any]) -> dict[str, Any]: summary: dict[str, Any] = {} for key in ("target_kind", "routed_method", "execution_mode"): if result.get(key) not in {None, ""}: summary[key] = result.get(key) routed_method = write_history_routed_method(result) if routed_method: summary["routed_method"] = routed_method target = result.get("target") if isinstance(result.get("target"), dict) else None if target: target_keys = ("id", "name") if result.get("schema") == "onec_infobase_user_password_change.v1" else ("kind", "form", "object", "extension", "table") summary["target"] = {key: target.get(key) for key in target_keys if target.get(key) not in {None, ""}} if result.get("schema") == "onec_infobase_user_password_change.v1": summary["operation"] = result.get("operation") summary["transport"] = "sql_dbo_v8users_data" path_resolution = result.get("path_resolution") if isinstance(result.get("path_resolution"), dict) else None if path_resolution: summary["canonical_path"] = path_resolution.get("canonical_path") summary["path_kind"] = path_resolution.get("path_kind") return summary def write_history_routed_method(result: dict[str, Any]) -> str: routed = str(result.get("routed_method") or "").strip() if routed: return routed route = result.get("route") if isinstance(result.get("route"), dict) else {} return str(route.get("method") or "").strip() def metadata_write_history_record(base_id: str, method: str, result: dict[str, Any]) -> str | None: config, _error = sql_config_for_base(base_id) if not config: return None operation_id = uuid.uuid4().hex now = time.time() target_summary = write_history_target_summary(result) backup_ids = collect_backup_ids(result) routed_method = write_history_routed_method(result) try: with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_write_history ( server_key, database_name, operation_id, method, routed_method, status, base_id, target_kind, target_summary_json, backup_ids_json, result_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( cache_server_key(config), cache_database_name(config), operation_id, method, routed_method, str(result.get("status") or ""), base_id, str(result.get("target_kind") or ""), json.dumps(target_summary, ensure_ascii=False, sort_keys=True), json.dumps(backup_ids, ensure_ascii=False), json.dumps(result, ensure_ascii=False, sort_keys=True, default=str), now, ), ) except Exception: return None return operation_id def metadata_write_history_referenced_backup_ids(base_id: str) -> set[str] | None: config, _error = sql_config_for_base(base_id) if not config: return None referenced: set[str] = set() try: with cache_connection() as conn: rows = conn.execute( """ SELECT backup_ids_json FROM metadata_write_history WHERE server_key=? AND database_name=? AND base_id=? """, (cache_server_key(config), cache_database_name(config), base_id), ).fetchall() except Exception: return None for row in rows: try: backup_ids = json.loads(str(row["backup_ids_json"] or "[]")) except (TypeError, ValueError, json.JSONDecodeError): continue if not isinstance(backup_ids, list): continue for backup_id in backup_ids: if isinstance(backup_id, str) and re.fullmatch(r"[0-9a-f]{32}", backup_id): referenced.add(backup_id) return referenced def attach_write_history_operation(payload: dict[str, Any], method: str, result: Any) -> Any: if method not in WRITE_HISTORY_RECORDED_METHODS or not isinstance(result, dict): return result operation_id = metadata_write_history_record(str(payload.get("base_id") or result.get("base_id") or ""), method, result) if operation_id: result.setdefault("operation_id", operation_id) return result def metadata_write_history(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.write.history" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error include_summary, include_summary_error = strict_bool_argument(payload, "include_summary", method=method, default=False) if include_summary_error: return include_summary_error operation_id = str(payload.get("operation_id") or "").strip() operation_method = str(payload.get("operation_method") or payload.get("write_method") or "").strip() status_filter = str(payload.get("status") or "").strip() routed_method_filter = str(payload.get("routed_method") or "").strip() backup_id = str(payload.get("backup_id") or "").strip() config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) with cache_connection() as conn: if operation_id: rows = conn.execute( """ SELECT * FROM metadata_write_history WHERE server_key=? AND database_name=? AND operation_id=? """, (cache_server_key(config), cache_database_name(config), operation_id), ).fetchall() else: conditions = ["server_key=?", "database_name=?"] parameters: list[Any] = [cache_server_key(config), cache_database_name(config)] if operation_method: conditions.append("method=?") parameters.append(operation_method) if status_filter: conditions.append("status=?") parameters.append(status_filter) if routed_method_filter: conditions.append("routed_method=?") parameters.append(routed_method_filter) if backup_id: conditions.append("backup_ids_json LIKE ?") parameters.append(f"%{backup_id}%") parameters.append(int(limit or 20)) rows = conn.execute( f""" SELECT * FROM metadata_write_history WHERE {" AND ".join(conditions)} ORDER BY created_at DESC LIMIT ? """, tuple(parameters), ).fetchall() operations = [] summary = { "by_method": {}, "by_status": {}, "by_routed_method": {}, "with_backups": 0, } for row in rows: data = dict(row) row_backup_ids = json.loads(data.get("backup_ids_json") or "[]") if backup_id and backup_id not in row_backup_ids: continue method_key = str(data.get("method") or "") status_key = str(data.get("status") or "") routed_key = str(data.get("routed_method") or "") if method_key: summary["by_method"][method_key] = int(summary["by_method"].get(method_key) or 0) + 1 if status_key: summary["by_status"][status_key] = int(summary["by_status"].get(status_key) or 0) + 1 if routed_key: summary["by_routed_method"][routed_key] = int(summary["by_routed_method"].get(routed_key) or 0) + 1 if row_backup_ids: summary["with_backups"] = int(summary["with_backups"] or 0) + 1 operations.append( { "operation_id": data.get("operation_id"), "method": data.get("method"), "routed_method": data.get("routed_method") or None, "status": data.get("status"), "base_id": data.get("base_id"), "target_kind": data.get("target_kind") or None, "target_summary": json.loads(data.get("target_summary_json") or "{}"), "backup_ids": row_backup_ids, "created_at": datetime.fromtimestamp(float(data.get("created_at") or 0), tz=timezone.utc).isoformat(), **({"result": json.loads(data.get("result_json") or "{}")} if operation_id else {}), } ) return { "schema": "onec_metadata_write_history.v1", "method": method, "status": "ok", "base_id": base_id, "query": { "operation_id": operation_id or None, "operation_method": operation_method or None, "status": status_filter or None, "routed_method": routed_method_filter or None, "backup_id": backup_id or None, "limit": int(limit or 20), "include_summary": bool(include_summary), }, "operations": operations, "counts": {"operations": len(operations)}, **({"summary": summary} if include_summary else {}), } def metadata_write_rollback(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_ROLLBACK_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error allow_rollback, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_error: return allow_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "Saved-state rollback by operation is opt-in; pass allow_sql_saved_state_rollback=true.") repository_error = repository_apply_gate(payload, method, "apply") if repository_error: return repository_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error operation_id = str(payload.get("operation_id") or "").strip() explicit_backup_id = str(payload.get("backup_id") or "").strip() if not operation_id and not explicit_backup_id: return invalid_argument(method, "operation_id", "Pass operation_id or backup_id.") history_payload = {"base_id": base_id, "limit": 20} if operation_id: history_payload["operation_id"] = operation_id elif explicit_backup_id: history_payload["backup_id"] = explicit_backup_id history = metadata_write_history(history_payload) operations = [item for item in history.get("operations") or [] if isinstance(item, dict)] if not operations: return { "schema": "onec_metadata_write_rollback.v1", "method": method, "status": "not_found", "base_id": base_id, "query": {"operation_id": operation_id or None, "backup_id": explicit_backup_id or None}, "diagnostics": {"message": "No write history operation with rollback backup evidence was found."}, } operation = operations[0] backup_ids = [str(item) for item in operation.get("backup_ids") or [] if item] if explicit_backup_id: if explicit_backup_id not in backup_ids and operation_id: return invalid_argument(method, "backup_id", "backup_id does not belong to the selected operation.") backup_id = explicit_backup_id elif len(backup_ids) == 1: backup_id = backup_ids[0] elif not backup_ids: return { "schema": "onec_metadata_write_rollback.v1", "method": method, "status": "not_found", "base_id": base_id, "operation": {key: operation.get(key) for key in ("operation_id", "method", "status", "routed_method")}, "diagnostics": {"message": "Selected operation does not contain backup ids."}, } else: return { "schema": "onec_metadata_write_rollback.v1", "method": method, "status": "ambiguous", "base_id": base_id, "operation": {key: operation.get(key) for key in ("operation_id", "method", "status", "routed_method")}, "backup_ids": backup_ids, "diagnostics": {"message": "Selected operation contains multiple backups. Pass backup_id explicitly."}, } rollback_result = storage_saved_state_rollback( { **repository_write_context(payload), "base_id": base_id, "backup_id": backup_id, "allow_sql_saved_state_rollback": True, "timeout_seconds": int(timeout_seconds or 30), } ) return { "schema": "onec_metadata_write_rollback.v1", "method": method, "status": rollback_result.get("status"), "applied": bool(rollback_result.get("applied")), "base_id": base_id, "operation": {key: operation.get(key) for key in ("operation_id", "method", "status", "routed_method")}, "backup_id": backup_id, "rollback_result": rollback_result, } def code_index_prune_file_modules( config: dict[str, str], *, table: str, file_name: str, keep_module_refs: list[str], ) -> int: keep = {str(item or "").strip() for item in keep_module_refs if str(item or "").strip()} with cache_connection() as conn: rows = conn.execute( """ SELECT module_ref FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? """, (cache_server_key(config), cache_database_name(config), table, file_name), ).fetchall() stale = [str(row["module_ref"] or "") for row in rows if str(row["module_ref"] or "") not in keep] for module_ref in stale: conn.execute( "DELETE FROM metadata_code_vector_cache WHERE server_key=? AND database_name=? AND module_ref=?", (cache_server_key(config), cache_database_name(config), module_ref), ) conn.execute( "DELETE FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? AND module_ref=?", (cache_server_key(config), cache_database_name(config), module_ref), ) conn.execute( "DELETE FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND module_ref=?", (cache_server_key(config), cache_database_name(config), module_ref), ) if table in CODE_INDEX_ACTIVE_TABLES: if keep: code_index_overlay_tombstone_clear(config, source_table=table, file_name=file_name) else: code_index_overlay_tombstone_set( config, source_table=table, file_name=file_name, reason="saved_overlay_contains_no_indexable_bsl", ) return len(stale) def code_index_upsert( config: dict[str, str], *, base_id: str, table: str, file_name: str, module_ref: str, data: bytes, text: str, owner: dict[str, Any] | None = None, bsl_offset: int | None = None, stream_index: int | None = None, verified: bool = True, ) -> dict[str, Any]: now = time.time() payload_sha1 = hashlib.sha1(data).hexdigest() text_sha1 = code_text_sha1(text) routines = public_routine_blocks(text) owner = owner or {} if table in CODE_INDEX_ACTIVE_TABLES and not any( owner.get(key) for key in ("owner_kind", "owner_name", "owner_guid", "form_name") ): active_table = CODE_INDEX_ACTIVE_TABLES[table] with cache_connection() as conn: active_owner = conn.execute( """ SELECT owner_kind, owner_name, owner_guid, form_name, extension_guid, extension_name FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? ORDER BY updated_at DESC LIMIT 1 """, (cache_server_key(config), cache_database_name(config), active_table, file_name), ).fetchone() if active_owner: owner = { "owner_kind": active_owner["owner_kind"], "owner_name": active_owner["owner_name"], "owner_guid": active_owner["owner_guid"], "form_name": active_owner["form_name"], "extension_guid": active_owner["extension_guid"], "extension_name": active_owner["extension_name"], } with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_code_index_cache ( server_key, database_name, module_ref, source_table, file_name, owner_kind, owner_name, owner_guid, form_name, extension_guid, extension_name, bsl_offset, stream_index, payload_sha1, text_sha1, text, routines_json, routine_count, source_bytes, updated_at, last_verified_at, last_seen_at ) VALUES ( :server_key, :database_name, :module_ref, :source_table, :file_name, :owner_kind, :owner_name, :owner_guid, :form_name, :extension_guid, :extension_name, :bsl_offset, :stream_index, :payload_sha1, :text_sha1, :text, :routines_json, :routine_count, :source_bytes, :updated_at, :last_verified_at, :last_seen_at ) ON CONFLICT(server_key, database_name, module_ref) DO UPDATE SET source_table=excluded.source_table, file_name=excluded.file_name, owner_kind=COALESCE(excluded.owner_kind, metadata_code_index_cache.owner_kind), owner_name=COALESCE(excluded.owner_name, metadata_code_index_cache.owner_name), owner_guid=COALESCE(excluded.owner_guid, metadata_code_index_cache.owner_guid), form_name=COALESCE(excluded.form_name, metadata_code_index_cache.form_name), extension_guid=COALESCE(excluded.extension_guid, metadata_code_index_cache.extension_guid), extension_name=COALESCE(excluded.extension_name, metadata_code_index_cache.extension_name), bsl_offset=excluded.bsl_offset, stream_index=excluded.stream_index, payload_sha1=excluded.payload_sha1, text_sha1=excluded.text_sha1, text=excluded.text, routines_json=excluded.routines_json, routine_count=excluded.routine_count, source_bytes=excluded.source_bytes, updated_at=excluded.updated_at, last_verified_at=excluded.last_verified_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "module_ref": module_ref, "source_table": table, "file_name": file_name, "owner_kind": owner.get("owner_kind"), "owner_name": owner.get("owner_name"), "owner_guid": owner.get("owner_guid"), "form_name": owner.get("form_name"), "extension_guid": owner.get("extension_guid"), "extension_name": owner.get("extension_name"), "bsl_offset": bsl_offset, "stream_index": stream_index, "payload_sha1": payload_sha1, "text_sha1": text_sha1, "text": text, "routines_json": json.dumps(routines, ensure_ascii=False, sort_keys=True), "routine_count": len(routines), "source_bytes": len(data), "updated_at": now, "last_verified_at": now if verified else None, "last_seen_at": now, }, ) if table in CODE_INDEX_ACTIVE_TABLES: code_index_overlay_tombstone_clear(config, source_table=table, file_name=file_name) return { "module_ref": module_ref, "payload_sha1": payload_sha1, "text_sha1": text_sha1, "routines": routines, "routine_count": len(routines), } def code_index_chunk_texts(text: str, routines: list[dict[str, Any]]) -> list[dict[str, Any]]: normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n") lines = normalized.split("\n") chunks = [{"chunk_kind": "module", "routine_name": None, "chunk_index": 0, "text": normalized[:8000]}] for index, routine in enumerate(routines, start=1): try: start = int(routine.get("line_start") or 0) end = int(routine.get("line_end") or 0) except Exception: continue if start < 1 or end < start or start > len(lines): continue routine_text = "\n".join(lines[start - 1 : min(end, len(lines))]) chunks.append({"chunk_kind": "routine", "routine_name": routine.get("name"), "chunk_index": index, "text": routine_text}) return chunks def code_vector_upsert_chunks(config: dict[str, str], row: dict[str, Any]) -> int: module_ref = str(row.get("module_ref") or "") if not module_ref: return 0 chunks = code_index_chunk_texts(str(row.get("text") or ""), row.get("routines") or []) now = time.time() count = 0 with cache_connection() as conn: conn.execute( """ DELETE FROM metadata_code_vector_cache WHERE server_key=? AND database_name=? AND module_ref=? """, (cache_server_key(config), cache_database_name(config), module_ref), ) conn.execute( """ UPDATE metadata_code_embedding_cache SET payload_sha1=?, last_seen_at=? WHERE server_key=? AND database_name=? AND module_ref=? AND text_sha1=? """, ( row.get("payload_sha1"), now, cache_server_key(config), cache_database_name(config), module_ref, row.get("text_sha1"), ), ) conn.execute( """ DELETE FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? AND module_ref=? AND text_sha1<>? """, (cache_server_key(config), cache_database_name(config), module_ref, row.get("text_sha1")), ) for chunk in chunks: chunk_text = str(chunk.get("text") or "") if not chunk_text.strip(): continue chunk_id = hashlib.sha1( f"{module_ref}|{chunk.get('chunk_kind')}|{chunk.get('routine_name') or ''}|{chunk.get('chunk_index')}|{row.get('text_sha1')}".encode("utf-8") ).hexdigest() embedding = code_hashing_embedding(chunk_text) conn.execute( """ INSERT INTO metadata_code_vector_cache ( server_key, database_name, chunk_id, module_ref, routine_name, chunk_kind, chunk_index, text_sha1, payload_sha1, embedding_model, embedding_json, text_preview, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :chunk_id, :module_ref, :routine_name, :chunk_kind, :chunk_index, :text_sha1, :payload_sha1, :embedding_model, :embedding_json, :text_preview, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, chunk_id) DO UPDATE SET module_ref=excluded.module_ref, routine_name=excluded.routine_name, chunk_kind=excluded.chunk_kind, chunk_index=excluded.chunk_index, text_sha1=excluded.text_sha1, payload_sha1=excluded.payload_sha1, embedding_model=excluded.embedding_model, embedding_json=excluded.embedding_json, text_preview=excluded.text_preview, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "chunk_id": chunk_id, "module_ref": module_ref, "routine_name": chunk.get("routine_name"), "chunk_kind": chunk.get("chunk_kind") or "module", "chunk_index": int(chunk.get("chunk_index") or 0), "text_sha1": row.get("text_sha1"), "payload_sha1": row.get("payload_sha1"), "embedding_model": CODE_INDEX_VECTOR_MODEL, "embedding_json": json.dumps(embedding, ensure_ascii=False, separators=(",", ":")), "text_preview": chunk_text[:1000], "updated_at": now, "last_seen_at": now, }, ) count += 1 return count def semantic_lexical_score(query: str, text: str, object_name: str | None = None) -> float: normalized_query = normalize(query) if not normalized_query: return 0.0 haystack = normalize("\n".join(part for part in [object_name or "", text or ""] if part)) if not haystack: return 0.0 score = 0.0 if normalized_query == haystack: score += 10.0 elif normalized_query in haystack: score += 5.0 terms = [normalize(term) for term in re.split(r"\s+", query) if normalize(term)] if terms: score += sum(1.0 for term in terms if term in haystack) / max(1, len(terms)) return score def semantic_cache_read_selector(base_id: str, object_kind: str | None, source_route: dict[str, Any]) -> dict[str, Any]: table = source_route.get("table") file_name = source_route.get("file_name") or source_route.get("part_id") if object_kind == "Template" or table or file_name: return { "method": "templates.read", "base_id": base_id, "kind": "Template", "table": table or "ConfigCAS", "file_name": file_name, } return {"method": "metadata.route.resolve", "base_id": base_id, **({"guid": source_route.get("guid")} if source_route.get("guid") else {})} def semantic_cache_search(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) query = str(payload.get("query") or "").strip() query_embedding = numeric_vector(payload.get("query_embedding")) if payload.get("query_embedding") is not None and query_embedding is None: return invalid_argument(method, "query_embedding", "query_embedding must be a non-empty JSON array of numbers.") if not query and not query_embedding: return invalid_argument(method, "query", "Pass query text or query_embedding.") object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or "")) if (payload.get("kind") or payload.get("object_kind")) else None limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=10000) if scan_limit_error: return scan_limit_error include_vectors, include_vectors_error = strict_bool_argument(payload, "include_vectors", method=method, default=False) if include_vectors_error: return include_vectors_error validate_candidates, validate_candidates_error = strict_bool_argument(payload, "validate_candidates", method=method, default=False) if validate_candidates_error: return validate_candidates_error validation_limit, validation_limit_error = parse_int_argument(payload, "validation_limit", method=method, default=limit, minimum=1, maximum=200) if validation_limit_error: return validation_limit_error validation_timeout_seconds, validation_timeout_error = parse_int_argument(payload, "validation_timeout_seconds", method=method, default=30, minimum=1, maximum=300) if validation_timeout_error: return validation_timeout_error clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if object_kind: clauses.append("object_kind=?") params.append(object_kind) with cache_connection() as conn: rows = conn.execute( f""" SELECT document_id, object_kind, object_guid, object_name, extension_guid, source_route_json, content_sha1, text_preview, embedding_model, embedding_json, vector_status, authoritative_source, updated_at, last_seen_at FROM semantic_document_cache WHERE {' AND '.join(clauses)} ORDER BY updated_at DESC LIMIT ? """, (*params, int(scan_limit or 1000)), ).fetchall() candidates: list[dict[str, Any]] = [] for row in rows: text_preview = str(row["text_preview"] or "") lexical = semantic_lexical_score(query, text_preview, row["object_name"]) if query else 0.0 vector_score = None if query_embedding and row["embedding_json"]: try: stored_embedding = numeric_vector(json.loads(row["embedding_json"] or "[]")) except Exception: stored_embedding = None if stored_embedding: vector_score = cosine_similarity(query_embedding, stored_embedding) score = float(vector_score if vector_score is not None else lexical) if score <= 0: continue try: source_route = json.loads(row["source_route_json"] or "{}") except Exception: source_route = {} if not isinstance(source_route, dict): source_route = {} match_by = "vector_embedding" if vector_score is not None else "lexical_cache" item = { "document_id": row["document_id"], "object": { "kind": row["object_kind"], "name": row["object_name"], "guid": row["object_guid"], "extension_guid": row["extension_guid"], }, "score": score, "match_by": match_by, "text_preview": text_preview, "source_route": source_route, "read_selector": semantic_cache_read_selector(base_id, row["object_kind"], source_route), "freshness": { "status": "candidate_only", "validation_required": True, "message": "Semantic cache results are retrieval candidates. Read through read_selector/source route before using for programming changes.", }, "cache": { "content_sha1": row["content_sha1"], "vector_status": row["vector_status"], "embedding_model": row["embedding_model"], "authoritative_source": row["authoritative_source"], "updated_at": row["updated_at"], "last_seen_at": row["last_seen_at"], }, } if include_vectors and row["embedding_json"]: item["embedding"] = json.loads(row["embedding_json"] or "[]") candidates.append(item) candidates.sort(key=lambda item: (-float(item.get("score") or 0), str(item.get("document_id") or ""))) matches = candidates[: int(limit or 20)] validation_counts = {"checked": 0, "fresh": 0, "stale": 0, "source_missing": 0, "not_found": 0, "other": 0} if validate_candidates and matches: for item in matches[: int(validation_limit or limit or 20)]: validation = semantic_cache_validate( { "base_id": base_id, "document_id": item.get("document_id"), "timeout_seconds": int(validation_timeout_seconds or 30), } ) item["validation"] = { "status": validation.get("status"), **({"error": validation.get("error")} if validation.get("error") else {}), **({"cache": validation.get("cache")} if validation.get("cache") else {}), **({"source": validation.get("source")} if validation.get("source") else {}), } validation_counts["checked"] += 1 status = str(validation.get("status") or "") if status == "ok": validation_counts["fresh"] += 1 item["freshness"] = validation.get("freshness") or item["freshness"] if validation.get("read_selector"): item["read_selector"] = validation["read_selector"] elif status == "stale": validation_counts["stale"] += 1 item["freshness"] = validation.get("freshness") or { "status": "stale", "validation_required": True, } elif status == "source_missing": validation_counts["source_missing"] += 1 item["freshness"] = validation.get("freshness") or { "status": "stale", "validation_required": True, } elif status == "not_found": validation_counts["not_found"] += 1 else: validation_counts["other"] += 1 return { "schema": "onec_semantic_cache_search.v1", "status": "ok" if matches else "not_found", **({"error": "not_found"} if not matches else {}), "base_id": base_id, "source": {"kind": "semantic_cache", "authoritative": False}, "query": { "query": query or None, "query_embedding": {"dimensions": len(query_embedding)} if query_embedding else None, "kind": object_kind, "limit": int(limit or 20), "scan_limit": int(scan_limit or 1000), "validate_candidates": bool(validate_candidates), "validation_limit": int(validation_limit or limit or 20), }, "matches": matches, "counts": { "matches": len(matches), "scanned_documents": len(rows), "candidate_matches": len(candidates), **({"validation": validation_counts} if validate_candidates else {}), }, "diagnostics": [ { "message": "Semantic cache search is not a source of truth. Use returned read_selector/source_route for live/source-cache validation before programming changes.", } ], } def metadata_code_index_status(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.status" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error config, config_error = sql_config_for_base(base_id_or_error) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) with cache_connection() as conn: total = conn.execute( "SELECT COUNT(*) AS count FROM metadata_code_index_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone() vectors = conn.execute( "SELECT COUNT(*) AS count FROM metadata_code_vector_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone() by_table = conn.execute( """ SELECT source_table, COUNT(*) AS count, MIN(last_verified_at) AS oldest_verified_at, MAX(last_verified_at) AS newest_verified_at FROM metadata_code_index_cache WHERE server_key=? AND database_name=? GROUP BY source_table ORDER BY source_table """, (cache_server_key(config), cache_database_name(config)), ).fetchall() tombstones = conn.execute( """ SELECT COUNT(*) AS count FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? """, (cache_server_key(config), cache_database_name(config)), ).fetchone() embedding_models = conn.execute( """ SELECT embedding_model, dimensions, COUNT(*) AS count, MIN(updated_at) AS oldest_updated_at, MAX(updated_at) AS newest_updated_at FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? GROUP BY embedding_model, dimensions ORDER BY embedding_model """, (cache_server_key(config), cache_database_name(config)), ).fetchall() index_state = code_index_state_status(config) return { "schema": "onec_code_index_status.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "source": {"kind": "code_index_cache", "authoritative": False}, "counts": { "modules": int((total or {})["count"] or 0), "vector_chunks": int((vectors or {})["count"] or 0), "overlay_tombstones": int((tombstones or {})["count"] or 0), "pending_events": int(index_state.get("pending_events") or 0), }, "tables": [ { "table": row["source_table"], "modules": int(row["count"] or 0), "oldest_verified_at": row["oldest_verified_at"], "newest_verified_at": row["newest_verified_at"], } for row in by_table ], "embedding_models": [ { "model": row["embedding_model"], "dimensions": int(row["dimensions"] or 0), "chunks": int(row["count"] or 0), "oldest_updated_at": row["oldest_updated_at"], "newest_updated_at": row["newest_updated_at"], } for row in embedding_models ], "freshness": { "status": "dirty" if index_state.get("status") == "dirty" else "cache_status_only", "validation_required": True, "message": "SQL remains authoritative; cache status does not prove individual modules are current.", "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, } def current_code_index_text_from_sql( *, base_id: str, table: str, file_name: str, module_ref: str, data: bytes, bsl_offset: int | None = None, timeout_seconds: int = 30, ) -> tuple[str, dict[str, Any]]: _parsed_table, _parsed_file_name, stream_index = parse_module_id(module_ref) if stream_index is None and table in {"ConfigCAS", "ConfigCASSave", "Config", "ConfigSave"}: try: decoded = metadata_form_decode( { "base_id": base_id, "table": table, "file_name": file_name, "include_module": True, "include_module_text": True, "include_storage": False, "evidence_mode": "none", "max_items": 1, "timeout_seconds": int(timeout_seconds or 30), } ) profile = decoded.get("profile") if isinstance(decoded.get("profile"), dict) else {} module = profile.get("module") if isinstance(profile.get("module"), dict) else {} module_text = str(module.get("text") or "") if decoded.get("status") == "ok" and module_text.strip(): return module_text, {"status": "ok", "source": "form_embedded_module", "module_path": module.get("path")} except Exception: pass return extract_code_index_text_from_payload(data, module_ref=module_ref, bsl_offset=bsl_offset) def metadata_code_index_build(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.build" normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") has_object_selector = bool(payload.get("guid") or payload.get("name")) table = str( payload.get("table") or ( "ConfigCASSave" if extension_guid else "Config" if has_object_selector else "ConfigCAS" ) ) if table not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) prefix = str(payload.get("prefix") or "") max_items, max_items_error = parse_int_argument(payload, "max_items", method=method, default=500, minimum=1, maximum=20000) if max_items_error: return max_items_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=int(max_items or 500), minimum=1, maximum=50000) if scan_limit_error: return scan_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1) if timeout_error: return timeout_error include_vectors, include_vectors_error = strict_bool_argument(payload, "include_vectors", method=method, default=True) if include_vectors_error: return include_vectors_error object_card: dict[str, Any] = {} discovered_module_refs: list[str] = [] object_form_module_jobs: dict[str, list[tuple[str, str, dict[str, Any], int | None, int | None]]] = {} forms_scanned = 0 form_modules_discovered = 0 empty_form_modules = 0 form_module_errors = 0 if has_object_selector: object_result = get_object( payload.get("kind"), str(payload.get("name") or payload.get("guid") or ""), base_id=base_id, table=table, extension_guid=extension_guid or None, include_storage=False, include_semantic=False, timeout_seconds=int(timeout_seconds or 120), ) if object_result.get("status") != "ok": result = dict(object_result) result["method"] = method return result object_card = object_result.get("object") or {} object_selector = { **payload, "base_id": base_id, "kind": object_card.get("kind") or payload.get("kind"), "guid": object_card.get("guid") or payload.get("guid"), "name": None, "table": table, **({"extension_guid": extension_guid} if extension_guid else {}), "timeout_seconds": int(timeout_seconds or 120), } modules_result = metadata_object_modules({**object_selector, "include_storage": True}) if modules_result.get("status") == "ok": discovered_module_refs.extend( str(module.get("module_id") or "") for module in modules_result.get("modules") or [] if isinstance(module, dict) and str(module.get("module_id") or "") ) commands_result = metadata_object_commands({**object_selector, "include_form_commands": False, "include_storage": False}) if commands_result.get("status") == "ok": discovered_module_refs.extend( str((command.get("read_selector") or {}).get("module_ref") or "") for command in commands_result.get("object_commands") or [] if isinstance(command, dict) and isinstance(command.get("read_selector"), dict) ) forms_result = metadata_object_forms({**object_selector, "include_storage": True}) if forms_result.get("status") == "ok": for form in forms_result.get("forms") or []: forms_scanned += 1 form_source = form.get("source") if isinstance(form, dict) and isinstance(form.get("source"), dict) else {} form_table = str(form_source.get("table") or table) form_file_name = str(form_source.get("file_name") or "") if form_table != table or not form_file_name: continue decoded_form = metadata_form_decode( { "base_id": base_id, "table": form_table, "file_name": form_file_name, "include_module": True, "include_module_text": True, "include_storage": True, "evidence_mode": "none", "max_items": 1, "timeout_seconds": int(timeout_seconds or 120), } ) profile = decoded_form.get("profile") if isinstance(decoded_form.get("profile"), dict) else {} form_module = profile.get("module") if isinstance(profile.get("module"), dict) else {} form_module_text = str(form_module.get("text") or "") if decoded_form.get("status") != "ok": form_module_errors += 1 continue if not form_module_text.strip() or not is_bsl_like_text(form_module_text): empty_form_modules += 1 continue form_module_ref = str((form_module.get("read_selector") or {}).get("module_ref") or f"{form_table}:{form_file_name}#form_module") bsl_offset_value = form_module.get("bsl_offset") bsl_offset_int = int(bsl_offset_value) if bsl_offset_value not in {None, ""} else None object_form_module_jobs.setdefault(form_file_name, []).append( ( form_module_ref, form_module_text, {"status": "ok", "source": "form_embedded_module", "bsl_offset": bsl_offset_int}, bsl_offset_int, None, ) ) form_modules_discovered += 1 discovered_module_refs.append(form_module_ref) file_names = [] for module_ref in discovered_module_refs: module_table, module_file_name, _ = parse_module_id(module_ref) if module_table == table and module_file_name and module_file_name not in file_names: file_names.append(module_file_name) else: files = storage_files_list( { "base_id": base_id, "table": table, "prefix": prefix, "limit": int(scan_limit or max_items or 500), "timeout_seconds": int(timeout_seconds or 120), "_internal": True, } ) if files.get("status") != "ok": return public_error_result(files, include_storage=False, method=method) file_names = [str(row.get("FileName") or "") for row in files.get("files") or [] if str(row.get("FileName") or "")] indexed = 0 skipped = 0 vectors = 0 pruned = 0 errors: list[dict[str, Any]] = [] for chunk_start in range(0, min(len(file_names), int(max_items or 500)), 100): chunk = file_names[chunk_start : chunk_start + 100] payloads, _read_config, read_error = read_storage_files_bytes(base_id, table, chunk, timeout_seconds=min(int(timeout_seconds or 120), 60)) if read_error: errors.append(read_error) continue for file_name in chunk: data = (payloads or {}).get(file_name) if not data: skipped += 1 continue base_module_ref = f"{table}:{file_name}" indexed_this_file = 0 indexed_module_refs: list[str] = [] try: from parser.cas_payload import classify_payload classified = classify_payload(data, include_text=True) except Exception: classified = {} streams = classified.get("stream_blocks") if isinstance(classified, dict) else [] module_jobs: list[tuple[str, str, dict[str, Any], int | None, int | None]] = [] module_jobs.extend(object_form_module_jobs.get(file_name) or []) if streams: for stream_index, stream in enumerate(streams): text = repair_bsl_mojibake_text(str((stream or {}).get("text") or "")) if text.strip() and (bool((stream or {}).get("has_bsl_marker")) or is_bsl_like_text(text)): module_jobs.append((f"{base_module_ref}#stream:{stream_index}", text, {"status": "ok", "source": "stream", "stream_index": stream_index}, None, stream_index)) if not module_jobs: owner = code_module_owner_from_cache(config, base_module_ref) bsl_offset = owner.get("bsl_offset") text, extraction = extract_code_index_text_from_payload(data, module_ref=base_module_ref, bsl_offset=int(bsl_offset) if bsl_offset not in {None, ""} else None) if text.strip() and extraction.get("status") == "ok": module_jobs.append((base_module_ref, text, extraction, int(bsl_offset) if bsl_offset not in {None, ""} else extraction.get("bsl_offset"), None)) for module_ref, text, extraction, bsl_offset_value, stream_index in module_jobs: owner = code_module_owner_from_cache(config, module_ref) upserted = code_index_upsert( config, base_id=base_id, table=table, file_name=file_name, module_ref=module_ref, data=data, text=text, owner=owner, bsl_offset=bsl_offset_value, stream_index=stream_index, verified=True, ) row = { **upserted, "module_ref": module_ref, "text": text, "routines": upserted.get("routines") or [], } if include_vectors: vectors += code_vector_upsert_chunks(config, row) indexed += 1 indexed_this_file += 1 indexed_module_refs.append(module_ref) pruned += code_index_prune_file_modules(config, table=table, file_name=file_name, keep_module_refs=indexed_module_refs) if not indexed_this_file: skipped += 1 processed_file_names = file_names[: int(max_items or 500)] complete_table = bool( not has_object_selector and not prefix and len(file_names) < int(scan_limit or max_items or 500) and len(processed_file_names) == len(file_names) ) index_state = code_index_mark_indexed( config, source_table=table, file_names=processed_file_names, complete_table=complete_table, ) return { "schema": "onec_code_index_build.v1", "method": method, "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "table": table}, "query": {"table": table, "prefix": prefix or None, "kind": payload.get("kind"), "name": payload.get("name"), "guid": payload.get("guid"), "extension_guid": extension_guid or None, "max_items": int(max_items or 500), "scan_limit": int(scan_limit or 500), "include_vectors": bool(include_vectors)}, **({"object": object_card} if object_card else {}), "counts": {"indexed": indexed, "skipped": skipped, "pruned": pruned, "vector_chunks": vectors, "errors": len(errors), "scanned_files": min(len(file_names), int(max_items or 500)), "discovered_module_refs": len(set(discovered_module_refs)), "forms_scanned": forms_scanned, "form_modules_discovered": form_modules_discovered, "empty_form_modules": empty_form_modules, "form_module_errors": form_module_errors}, "freshness": { "status": "live_sql_verified", "verified_against_sql": True, "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, "diagnostics": errors[:10], } def metadata_code_index_verify(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.verify" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() if not module_ref: return invalid_argument(method, "module_ref", "module_ref is required.") refresh_stale, refresh_stale_error = strict_bool_argument(payload, "refresh_stale", method=method, default=False) if refresh_stale_error: return refresh_stale_error table, file_name, stream_index = parse_module_id(module_ref) if not table or not file_name: return invalid_argument(method, "module_ref", MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE) with cache_connection() as conn: row = conn.execute( """ SELECT * FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND module_ref=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), module_ref), ).fetchone() if not row: row = conn.execute( """ SELECT * FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND module_ref=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), normalize_module_ref_for_form_owner(module_ref)), ).fetchone() if not row: return {"schema": "onec_code_index_verify.v1", "method": method, "status": "not_found", "error": "not_found", "base_id": base_id, "module_ref": module_ref} cached = code_index_row_payload(row, config) data, _read_config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30)) if error: code_index_mark_dirty( config, reason="live_source_read_failed", source_table=table, file_names=[file_name], event_kind="source_verification_failed", ) return {"schema": "onec_code_index_verify.v1", "method": method, "status": "source_missing", "error": "source_missing", "base_id": base_id, "module_ref": module_ref, "diagnostics": error.get("diagnostics")} current_payload_sha1 = hashlib.sha1(data).hexdigest() text, extraction = current_code_index_text_from_sql( base_id=base_id, table=table, file_name=file_name, module_ref=cached["module_ref"], data=data, bsl_offset=cached.get("bsl_offset"), timeout_seconds=int(payload.get("timeout_seconds") or 30), ) current_text_sha1 = code_text_sha1(text) fresh = current_payload_sha1 == cached.get("payload_sha1") and current_text_sha1 == cached.get("text_sha1") now = time.time() refreshed = False refreshed_vector_chunks = 0 with cache_connection() as conn: if fresh: conn.execute( "UPDATE metadata_code_index_cache SET last_verified_at=?, last_seen_at=? WHERE server_key=? AND database_name=? AND module_ref=?", (now, now, cache_server_key(config), cache_database_name(config), cached["module_ref"]), ) if not fresh: code_index_mark_dirty( config, reason="live_source_hash_changed", source_table=table, file_names=[file_name], event_kind="external_source_changed", ) if refresh_stale and text.strip() and extraction.get("status") == "ok": cached_owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} cached_extension = cached_owner.get("extension") if isinstance(cached_owner.get("extension"), dict) else {} indexed = code_index_upsert( config, base_id=base_id, table=table, file_name=file_name, module_ref=cached["module_ref"], data=data, text=text, owner={ "owner_kind": cached_owner.get("kind"), "owner_name": cached_owner.get("name"), "owner_guid": cached_owner.get("guid"), "form_name": cached_owner.get("form"), "extension_guid": cached_extension.get("guid"), "extension_name": cached_extension.get("name"), }, bsl_offset=cached.get("bsl_offset"), stream_index=cached.get("stream_index"), verified=True, ) refreshed_vector_chunks = code_vector_upsert_chunks( config, { **indexed, "module_ref": cached["module_ref"], "text": text, "routines": indexed.get("routines") or [], }, ) code_index_mark_indexed(config, source_table=table, file_names=[file_name]) refreshed = True return { "schema": "onec_code_index_verify.v1", "method": method, "status": "ok" if fresh else "refreshed" if refreshed else "stale", **({"error": "stale"} if not fresh and not refreshed else {}), "base_id": base_id, "module_ref": cached["module_ref"], "freshness": { "source": "code_index_cache", "verified_against_sql": True, "payload_sha1": cached.get("payload_sha1"), "text_sha1": cached.get("text_sha1"), "current_payload_sha1": current_payload_sha1, "current_text_sha1": current_text_sha1, "status": "cache_hit_verified" if fresh else "cache_refreshed_from_sql" if refreshed else "cache_hit_stale", }, "cache": {key: cached.get(key) for key in ("table", "file_name", "owner", "bsl_offset", "routine_count", "updated_at", "last_verified_at")}, "extraction": extraction, **( { "refresh": { "status": "updated", "vector_chunks": refreshed_vector_chunks, "index_state": code_index_state_status(config), } } if refreshed else {} ), } def metadata_code_index_search(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.search" execution_mode = str(payload.get("execution_mode") or "").strip().casefold() if execution_mode: if execution_mode != "job": return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) job_payload = {key: value for key, value in payload.items() if key != "execution_mode"} return adapter_start_job({"method": method, "payload": job_payload, "timeout_seconds": payload.get("timeout_seconds") or 60}) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) query = str(payload.get("query") or payload.get("pattern") or "").strip() if not query: return invalid_argument(method, "query", "Передайте непустой query.") owner_kind, owner_name = parse_object_query(None, str(payload.get("ref") or "").strip()) owner_kind = canonical_kind(owner_kind) or owner_kind owner_name = str(owner_name or "").strip() mode = str(payload.get("mode") or "fast").strip().casefold() if mode not in {"fast", "live", "background_refresh"}: return invalid_argument(method, "mode", "mode must be one of: fast, live, background_refresh.", allowed_values=["fast", "live", "background_refresh"]) if mode == "live": result = search_modules({**payload, "method": None, "query": query, "resolve_owners": True}) result["method"] = method result["freshness"] = {"status": "live_sql_verified", "verified_against_sql": True} return result limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=10000) if scan_limit_error: return scan_limit_error verify, verify_error = strict_bool_argument(payload, "verify", method=method, default=True) if verify_error: return verify_error strict, strict_error = strict_bool_argument(payload, "strict", method=method, default=bool(verify)) if strict_error: return strict_error if strict and not verify: return invalid_argument(method, "verify", "strict=true requires verify=true.") overfetch_factor, overfetch_error = parse_int_argument( payload, "overfetch_factor", method=method, default=5, minimum=1, maximum=20, ) if overfetch_error: return overfetch_error index_state = code_index_state_status(config) if strict and index_state.get("status") == "dirty": result = search_modules({**payload, "method": None, "query": query, "resolve_owners": True}) result["method"] = method result["fallback_reason"] = "local_code_index_dirty" result["freshness"] = { "status": "live_sql_verified", "verified_against_sql": True, "index_state": index_state, "snapshot_token": None, } return result search_deadline = adapter_now() + max(1, int(payload.get("timeout_seconds") or 30)) like = f"%{query}%" def matching_rows() -> tuple[list[sqlite3.Row], int]: owner_filter_sql = " AND owner_kind=? AND owner_name=?" if owner_kind and owner_name else "" owner_filter_values: tuple[Any, ...] = (owner_kind, owner_name) if owner_kind and owner_name else () with cache_connection() as conn: rows = conn.execute( f""" SELECT * FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND text LIKE ?{owner_filter_sql} ORDER BY last_verified_at DESC, updated_at DESC LIMIT ? """, (cache_server_key(config), cache_database_name(config), like, *owner_filter_values, int(scan_limit or 1000)), ).fetchall() saved_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') GROUP BY source_table, file_name """, (cache_server_key(config), cache_database_name(config)), ).fetchall() tombstone_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? """, (cache_server_key(config), cache_database_name(config)), ).fetchall() saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} filtered: list[sqlite3.Row] = [] shadowed = 0 for row in rows: saved_table = CODE_INDEX_SAVED_OVERLAYS.get(str(row["source_table"] or "")) if saved_table and ( (saved_table, str(row["file_name"] or "")) in saved_files or (saved_table, str(row["file_name"] or "")) in tombstones ): shadowed += 1 continue filtered.append(row) return filtered, shadowed rows, shadowed_candidates = matching_rows() matches: list[dict[str, Any]] = [] rejected_stale = 0 refreshed_modules = 0 time_budget_exhausted = False candidate_window = max(int(limit or 20), int(limit or 20) * int(overfetch_factor or 5)) for search_pass in range(2): matches = [] refreshed_this_pass = False for row in rows[:candidate_window]: remaining_seconds = int(search_deadline - adapter_now()) if remaining_seconds <= 0: time_budget_exhausted = True break cached = code_index_row_payload(row, config) text = str(cached.get("text") or "") offset = text.casefold().find(query.casefold()) snippet = text_snippet(text, query) if offset >= 0 else {"text": text[:300], "offset": None} freshness = { "source": "code_index_cache", "verified_against_sql": False, "payload_sha1": cached.get("payload_sha1"), "text_sha1": cached.get("text_sha1"), "status": "cache_hit_unverified", } if verify: verification = metadata_code_index_verify( { "base_id": base_id, "module_ref": cached["module_ref"], "timeout_seconds": max(1, min(5, remaining_seconds)), "refresh_stale": bool(strict), } ) freshness = verification.get("freshness") or freshness if freshness.get("status") == "cache_refreshed_from_sql": refreshed_modules += 1 refreshed_this_pass = True continue if strict and freshness.get("status") != "cache_hit_verified": rejected_stale += 1 continue owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} cached_module_table, _cached_file_name, _cached_stream_index = parse_module_id(str(cached.get("module_ref") or "")) if not owner.get("name") or (cached_module_table == "ConfigCAS" and not owner_extension.get("name")): metadata_module_owner_resolve_from_ref( base_id, config, str(cached.get("module_ref") or ""), timeout_seconds=int(payload.get("timeout_seconds") or 30), ) owner = code_index_owner_payload(config, str(cached.get("module_ref") or ""), dict(row)) owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) read_selector = { "method": "modules.read", "base_id": base_id, "module_ref": cached["module_ref"], "preview": True, "max_chars": int(payload.get("read_max_chars") or 4000), **({"bsl_offset": cached.get("bsl_offset")} if cached.get("bsl_offset") is not None else {}), } matches.append( { "score": 1.0, "snippet": snippet, "owner": owner, "object_ref": public_ref or None, "object_selector": { **({"kind": owner.get("kind")} if owner.get("kind") else {}), **({"name": owner.get("name")} if owner.get("name") else {}), **({"guid": owner.get("guid")} if owner.get("guid") else {}), **({"extension": owner_extension.get("name")} if owner_extension.get("name") else {}), **({"extension_guid": owner_extension.get("guid")} if owner_extension.get("guid") else {}), }, "module": {"name": "Модуль БСЛ", "routine_count": cached.get("routine_count"), "form": owner.get("form")}, "read_selector": read_selector, "origin": {"source": "code_index_cache", "status": "verified" if freshness.get("status") == "cache_hit_verified" else "candidate"}, "freshness": freshness, } ) if len(matches) >= int(limit or 20): break if time_budget_exhausted: break if refreshed_this_pass and search_pass == 0: rows, pass_shadowed = matching_rows() shadowed_candidates = max(shadowed_candidates, pass_shadowed) continue break index_state = code_index_state_status(config) return { "schema": "onec_code_index_search.v1", "method": method, "status": "partial" if time_budget_exhausted else "ok" if matches else "not_found", **({"error": "not_found"} if not matches and not time_budget_exhausted else {}), "base_id": base_id, "source": {"kind": "code_index_cache", "authoritative": False}, "query": { "query": query, **({"ref": object_selector_ref(owner_kind, owner_name)} if owner_kind and owner_name else {}), "mode": mode, "limit": int(limit or 20), "scan_limit": int(scan_limit or 1000), "verify": bool(verify), "strict": bool(strict), "overfetch_factor": int(overfetch_factor or 5), }, "matches": matches, "counts": { "matches": len(matches), "candidates": len(rows), "candidate_window": min(len(rows), candidate_window), "refreshed_modules": refreshed_modules, "rejected_stale": rejected_stale, "shadowed_candidates": shadowed_candidates, "time_budget_exhausted": time_budget_exhausted, }, "freshness": { "status": "live_verified_candidates" if strict else "candidate_only", "verified_against_sql": bool(strict), "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, **({"diagnostics": [{"code": "time_budget_exhausted", "message": "Strict live verification reached the request time budget; returned matches are verified."}]} if time_budget_exhausted else {}), } def metadata_code_index_refresh_changed(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.refresh_changed" search = metadata_code_index_search({**payload, "method": None, "query": str(payload.get("query") or ""), "verify": True, "strict": False}) stale = [match for match in search.get("matches") or [] if (match.get("freshness") or {}).get("status") == "cache_hit_stale"] refreshed = 0 for match in stale: selector = match.get("read_selector") or {} module_ref = str(selector.get("module_ref") or "") table, file_name, _ = parse_module_id(module_ref) if not table or not file_name: continue build = metadata_code_index_build({"base_id": payload.get("base_id"), "table": table, "prefix": file_name, "max_items": 1, "scan_limit": 1, "include_vectors": True}) refreshed += int((build.get("counts") or {}).get("indexed") or 0) return {"schema": "onec_code_index_refresh_changed.v1", "method": method, "status": "ok", "base_id": payload.get("base_id"), "counts": {"stale": len(stale), "refreshed": refreshed}, "search": search} def code_index_delete_file(config: dict[str, str], *, source_table: str, file_name: str) -> int: with cache_connection() as conn: rows = conn.execute( """ SELECT module_ref FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? """, (cache_server_key(config), cache_database_name(config), source_table, file_name), ).fetchall() module_refs = [str(row["module_ref"] or "") for row in rows if str(row["module_ref"] or "")] for module_ref in module_refs: conn.execute( """ DELETE FROM metadata_code_vector_cache WHERE server_key=? AND database_name=? AND module_ref=? """, (cache_server_key(config), cache_database_name(config), module_ref), ) conn.execute( """ DELETE FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? AND module_ref=? """, (cache_server_key(config), cache_database_name(config), module_ref), ) cursor = conn.execute( """ DELETE FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? """, (cache_server_key(config), cache_database_name(config), source_table, file_name), ) if source_table in CODE_INDEX_ACTIVE_TABLES: code_index_overlay_tombstone_clear(config, source_table=source_table, file_name=file_name) return int(cursor.rowcount or 0) def metadata_code_index_sync_pending(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.sync_pending" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error max_items, max_items_error = parse_int_argument(payload, "max_items", method=method, default=5000, minimum=1, maximum=50000) if max_items_error: return max_items_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) if timeout_error: return timeout_error with cache_connection() as conn: rows = conn.execute( """ SELECT event_id, source_revision, event_kind, source_table, file_name, reason, created_at FROM metadata_code_index_outbox WHERE server_key=? AND database_name=? AND status='pending' ORDER BY source_revision, created_at, event_id LIMIT ? """, (cache_server_key(config), cache_database_name(config), int(limit or 20)), ).fetchall() processed = 0 rebuilt_modules = 0 removed_modules = 0 errors: list[dict[str, Any]] = [] events: list[dict[str, Any]] = [] handled_targets: set[tuple[str, str | None]] = set() for row in rows: source_table = str(row["source_table"] or "") file_name = str(row["file_name"] or "").strip() or None target = (source_table, file_name) if target in handled_targets: continue handled_targets.add(target) if source_table not in STORAGE_TABLES: errors.append( { "event_id": row["event_id"], "status": "unsupported", "source_table": source_table or None, "file_name": file_name, } ) continue build_payload = { "base_id": base_id, "table": source_table, "include_vectors": True, "timeout_seconds": int(timeout_seconds or 120), } if file_name: build_payload.update({"prefix": file_name, "max_items": 1, "scan_limit": 1}) else: build_payload.update({"max_items": int(max_items or 5000), "scan_limit": int(max_items or 5000)}) build = metadata_code_index_build(build_payload) build_counts = build.get("counts") if isinstance(build.get("counts"), dict) else {} event_result = { "event_id": row["event_id"], "source_revision": int(row["source_revision"] or 0), "source_table": source_table, "file_name": file_name, "status": build.get("status"), "counts": build_counts, } if build.get("status") == "ok": scanned_files = int(build_counts.get("scanned_files") or 0) if file_name and scanned_files == 0: removed = code_index_delete_file(config, source_table=source_table, file_name=file_name) removed_modules += removed code_index_mark_indexed(config, source_table=source_table, file_names=[file_name]) event_result["status"] = "source_removed" event_result["removed_modules"] = removed rebuilt_modules += int(build_counts.get("indexed") or 0) processed += 1 else: errors.append( { "event_id": row["event_id"], "status": build.get("status") or "error", "source_table": source_table, "file_name": file_name, "diagnostics": build.get("diagnostics"), } ) events.append(event_result) index_state = code_index_state_status(config) return { "schema": "onec_code_index_sync_pending.v1", "method": method, "status": "ok" if not errors else "partial", "base_id": base_id, "source": {"kind": "live_sql", "authoritative": True}, "events": events, "counts": { "selected_events": len(rows), "processed_targets": processed, "rebuilt_modules": rebuilt_modules, "removed_modules": removed_modules, "errors": len(errors), "remaining_events": int(index_state.get("pending_events") or 0), }, "freshness": { "status": index_state.get("status"), "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, "diagnostics": errors[:20], } def metadata_code_index_poll_changes(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.poll_changes" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) include_active, include_active_error = strict_bool_argument(payload, "include_active", method=method, default=False) if include_active_error: return include_active_error max_items, max_items_error = parse_int_argument(payload, "max_items", method=method, default=1000, minimum=1, maximum=50000) if max_items_error: return max_items_error verify_limit, verify_limit_error = parse_int_argument(payload, "verify_limit", method=method, default=200, minimum=1, maximum=5000) if verify_limit_error: return verify_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) if timeout_error: return timeout_error requested_table = str(payload.get("table") or "").strip() if requested_table and requested_table not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) tables = [requested_table] if requested_table else ["ConfigSave", "ConfigCASSave"] if not requested_table and include_active: tables = ["Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"] discovered_new = 0 discovered_removed = 0 verified = 0 refreshed = 0 tombstones_rechecked = 0 coverage_complete = True table_results: list[dict[str, Any]] = [] diagnostics: list[dict[str, Any]] = [] for table in tables: files = storage_files_list( { "base_id": base_id, "table": table, "limit": int(max_items or 1000), "timeout_seconds": int(timeout_seconds or 120), "_internal": True, } ) if files.get("status") != "ok": coverage_complete = False diagnostics.append( { "table": table, "status": files.get("status") or "error", "diagnostics": files.get("diagnostics"), } ) continue live_files = { str(row.get("FileName") or "") for row in files.get("files") or [] if isinstance(row, dict) and str(row.get("FileName") or "") } table_complete = len(live_files) < int(max_items or 1000) coverage_complete = coverage_complete and table_complete with cache_connection() as conn: cached_rows = conn.execute( """ SELECT module_ref, file_name FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table=? ORDER BY last_verified_at ASC, updated_at ASC """, (cache_server_key(config), cache_database_name(config), table), ).fetchall() tombstone_rows = conn.execute( """ SELECT file_name FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? AND source_table=? """, (cache_server_key(config), cache_database_name(config), table), ).fetchall() cached_files = {str(row["file_name"] or "") for row in cached_rows if str(row["file_name"] or "")} tombstone_files = {str(row["file_name"] or "") for row in tombstone_rows if str(row["file_name"] or "")} known_files = cached_files | tombstone_files new_files = sorted(live_files - known_files) removed_files = sorted(known_files - live_files) if table_complete else [] if new_files: code_index_mark_dirty( config, reason="poll_discovered_new_source", source_table=table, file_names=new_files, event_kind="external_source_added", ) discovered_new += len(new_files) for file_name in removed_files: code_index_mark_dirty( config, reason="poll_discovered_removed_source", source_table=table, file_names=[file_name], event_kind="external_source_removed", ) code_index_delete_file(config, source_table=table, file_name=file_name) code_index_mark_indexed(config, source_table=table, file_names=[file_name]) discovered_removed += 1 verification_rows = [row for row in cached_rows if str(row["file_name"] or "") in live_files] for row in verification_rows[: int(verify_limit or 200)]: verification = metadata_code_index_verify( { "base_id": base_id, "module_ref": row["module_ref"], "timeout_seconds": int(timeout_seconds or 120), "refresh_stale": True, } ) verified += 1 if (verification.get("freshness") or {}).get("status") == "cache_refreshed_from_sql": refreshed += 1 elif verification.get("status") not in {"ok", "refreshed"}: diagnostics.append( { "table": table, "module_ref": row["module_ref"], "status": verification.get("status") or "error", "diagnostics": verification.get("diagnostics"), } ) # A saved file that previously contained no BSL can later gain code without # changing its name. Requeue tombstones so the payload is decoded again. recheck_tombstones = sorted(tombstone_files & live_files) if recheck_tombstones: code_index_mark_dirty( config, reason="poll_recheck_saved_overlay_tombstone", source_table=table, file_names=recheck_tombstones, event_kind="tombstone_recheck", ) tombstones_rechecked += len(recheck_tombstones) table_results.append( { "table": table, "live_files": len(live_files), "cached_files": len(cached_files), "new_files": len(new_files), "removed_files": len(removed_files), "tombstones_rechecked": len(recheck_tombstones), "coverage_complete": table_complete, } ) sync_result = metadata_code_index_sync_pending( { "base_id": base_id, "limit": min(200, max(1, discovered_new + tombstones_rechecked)), "max_items": int(max_items or 1000), "timeout_seconds": int(timeout_seconds or 120), } ) index_state = code_index_state_status(config) return { "schema": "onec_code_index_poll_changes.v1", "method": method, "status": "ok" if not diagnostics else "partial", "base_id": base_id, "source": {"kind": "live_sql", "authoritative": True}, "query": { "table": requested_table or None, "include_active": bool(include_active), "max_items": int(max_items or 1000), "verify_limit": int(verify_limit or 200), }, "tables": table_results, "counts": { "new_files": discovered_new, "removed_files": discovered_removed, "verified_modules": verified, "refreshed_modules": refreshed, "tombstones_rechecked": tombstones_rechecked, "remaining_events": int(index_state.get("pending_events") or 0), }, "coverage": { "complete": coverage_complete, "message": ( "All returned table inventories fit within max_items." if coverage_complete else "At least one table reached max_items; increase max_items before treating absence as deletion." ), }, "sync": sync_result, "freshness": { "status": index_state.get("status"), "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, "diagnostics": diagnostics, } def code_vector_chunk_full_text(row: sqlite3.Row) -> str: try: routines = json.loads(row["routines_json"] or "[]") except Exception: routines = [] chunks = code_index_chunk_texts(str(row["module_text"] or ""), routines if isinstance(routines, list) else []) for chunk in chunks: if ( str(chunk.get("chunk_kind") or "") == str(row["chunk_kind"] or "") and int(chunk.get("chunk_index") or 0) == int(row["chunk_index"] or 0) and str(chunk.get("routine_name") or "") == str(row["routine_name"] or "") ): return str(chunk.get("text") or "") return str(row["text_preview"] or "") def metadata_code_vector_pending(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_vector.pending" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) embedding_model = str(payload.get("embedding_model") or "").strip() if not embedding_model: return invalid_argument(method, "embedding_model", "embedding_model is required.") limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error requested_chunk_kinds = payload.get("chunk_kinds") chunk_kinds: list[str] = [] if requested_chunk_kinds is not None: if not isinstance(requested_chunk_kinds, list): return invalid_argument(method, "chunk_kinds", "chunk_kinds must be a JSON array.") for value in requested_chunk_kinds: chunk_kind = str(value or "").strip().lower() if chunk_kind not in {"module", "routine"}: return invalid_argument( method, "chunk_kinds", "Unsupported chunk kind.", allowed_values=["module", "routine"], ) if chunk_kind not in chunk_kinds: chunk_kinds.append(chunk_kind) if not chunk_kinds: return invalid_argument(method, "chunk_kinds", "chunk_kinds must not be empty.") kind_clause = "" kind_params: list[Any] = [] if chunk_kinds: kind_clause = f" AND v.chunk_kind IN ({','.join('?' for _ in chunk_kinds)})" kind_params.extend(chunk_kinds) max_text_chars: int | None = None if payload.get("max_text_chars") not in {None, ""}: max_text_chars, max_text_chars_error = parse_int_argument( payload, "max_text_chars", method=method, default=4000, minimum=100, maximum=100000, ) if max_text_chars_error: return max_text_chars_error with cache_connection() as conn: rows = conn.execute( f""" SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, v.chunk_index, v.text_sha1, v.payload_sha1, v.text_preview, c.text AS module_text, c.routines_json, c.owner_kind, c.owner_name, c.owner_guid, c.form_name, c.source_table, c.file_name FROM metadata_code_vector_cache v JOIN metadata_code_index_cache c ON c.server_key=v.server_key AND c.database_name=v.database_name AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 LEFT JOIN metadata_code_embedding_cache e ON e.server_key=v.server_key AND e.database_name=v.database_name AND e.chunk_id=v.chunk_id AND e.embedding_model=? AND e.text_sha1=v.text_sha1 WHERE v.server_key=? AND v.database_name=? AND e.chunk_id IS NULL {kind_clause} ORDER BY CASE v.chunk_kind WHEN 'routine' THEN 0 ELSE 1 END, v.updated_at ASC, v.chunk_id LIMIT ? """, ( embedding_model, cache_server_key(config), cache_database_name(config), *kind_params, min(5000, int(limit or 100) * 5), ), ).fetchall() saved_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') GROUP BY source_table, file_name """, (cache_server_key(config), cache_database_name(config)), ).fetchall() tombstone_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? """, (cache_server_key(config), cache_database_name(config)), ).fetchall() saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} chunks: list[dict[str, Any]] = [] for row in rows: saved_table = CODE_INDEX_SAVED_OVERLAYS.get(str(row["source_table"] or "")) if saved_table and ( (saved_table, str(row["file_name"] or "")) in saved_files or (saved_table, str(row["file_name"] or "")) in tombstones ): continue owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) chunk_text = code_vector_chunk_full_text(row) if max_text_chars is not None and len(chunk_text) > max_text_chars: continue chunks.append( { "chunk_id": row["chunk_id"], "module_ref": row["module_ref"], "chunk": { "kind": row["chunk_kind"], "routine_name": row["routine_name"], "index": int(row["chunk_index"] or 0), }, "object_ref": public_ref or None, "object_selector": { **({"kind": owner.get("kind")} if owner.get("kind") else {}), **({"name": owner.get("name")} if owner.get("name") else {}), **({"guid": owner.get("guid")} if owner.get("guid") else {}), }, "text": chunk_text, "text_sha1": row["text_sha1"], "payload_sha1": row["payload_sha1"], "precondition": { "chunk_id": row["chunk_id"], "text_sha1": row["text_sha1"], "message": "Both values must still match when metadata.code_vector.embedding.upsert is called.", }, } ) if len(chunks) >= int(limit or 100): break index_state = code_index_state_status(config) return { "schema": "onec_code_vector_pending.v1", "method": method, "status": "ok", "base_id": base_id, "embedding_model": embedding_model, "query": { "chunk_kinds": chunk_kinds or None, "max_text_chars": max_text_chars, "limit": int(limit or 100), }, "chunks": chunks, "counts": {"chunks": len(chunks)}, "freshness": { "status": index_state.get("status"), "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, } def metadata_code_vector_embedding_upsert(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_vector.embedding.upsert" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) chunk_id = str(payload.get("chunk_id") or "").strip() text_sha1 = str(payload.get("text_sha1") or "").strip().lower() embedding_model = str(payload.get("embedding_model") or "").strip() embedding = numeric_vector(payload.get("embedding")) if not chunk_id: return invalid_argument(method, "chunk_id", "chunk_id is required.") if not re.fullmatch(r"[0-9a-f]{40}", text_sha1): return invalid_argument(method, "text_sha1", "text_sha1 must be a 40-character SHA1 string.") if not embedding_model: return invalid_argument(method, "embedding_model", "embedding_model is required.") if not embedding: return invalid_argument(method, "embedding", "embedding must be a non-empty JSON array of numbers.") with cache_connection() as conn: row = conn.execute( """ SELECT v.module_ref, v.text_sha1, v.payload_sha1 FROM metadata_code_vector_cache v JOIN metadata_code_index_cache c ON c.server_key=v.server_key AND c.database_name=v.database_name AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 WHERE v.server_key=? AND v.database_name=? AND v.chunk_id=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), chunk_id), ).fetchone() if not row: return { "schema": "onec_code_vector_embedding_upsert.v1", "method": method, "status": "conflict", "error": "chunk_missing_or_changed", "base_id": base_id, "chunk_id": chunk_id, } if str(row["text_sha1"] or "").lower() != text_sha1: return { "schema": "onec_code_vector_embedding_upsert.v1", "method": method, "status": "conflict", "error": "text_sha1_changed", "base_id": base_id, "chunk_id": chunk_id, "expected_text_sha1": text_sha1, "current_text_sha1": row["text_sha1"], } dimensions_row = conn.execute( """ SELECT dimensions FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? AND embedding_model=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), embedding_model), ).fetchone() if dimensions_row and int(dimensions_row["dimensions"] or 0) != len(embedding): return invalid_argument( method, "embedding", f"Embedding dimensions must match existing model dimensions ({int(dimensions_row['dimensions'] or 0)}).", ) now = time.time() conn.execute( """ INSERT INTO metadata_code_embedding_cache ( server_key, database_name, chunk_id, module_ref, text_sha1, payload_sha1, embedding_model, dimensions, embedding_json, updated_at, last_seen_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(server_key, database_name, chunk_id, embedding_model) DO UPDATE SET module_ref=excluded.module_ref, text_sha1=excluded.text_sha1, payload_sha1=excluded.payload_sha1, dimensions=excluded.dimensions, embedding_json=excluded.embedding_json, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, ( cache_server_key(config), cache_database_name(config), chunk_id, row["module_ref"], text_sha1, row["payload_sha1"], embedding_model, len(embedding), json.dumps(embedding, ensure_ascii=False, separators=(",", ":")), now, now, ), ) return { "schema": "onec_code_vector_embedding_upsert.v1", "method": method, "status": "ok", "base_id": base_id, "chunk_id": chunk_id, "text_sha1": text_sha1, "embedding_model": embedding_model, "dimensions": len(embedding), } def metadata_code_vector_search(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_vector.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) query = str(payload.get("query") or "").strip() embedding_model = str(payload.get("embedding_model") or CODE_INDEX_VECTOR_MODEL).strip() supplied_query_embedding = numeric_vector(payload.get("query_embedding")) if payload.get("query_embedding") is not None and supplied_query_embedding is None: return invalid_argument(method, "query_embedding", "query_embedding must be a non-empty JSON array of numbers.") query_embedding = supplied_query_embedding if query_embedding is None and query and embedding_model == CODE_INDEX_VECTOR_MODEL: query_embedding = code_hashing_embedding(query) if query_embedding is None and query and embedding_model != CODE_INDEX_VECTOR_MODEL: return invalid_argument( method, "query_embedding", "A query_embedding computed by the selected embedding_model is required for external embeddings.", ) if not query_embedding: return invalid_argument(method, "query", "Pass query or query_embedding.") limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=10, minimum=1, maximum=100) if limit_error: return limit_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=2000, minimum=1, maximum=20000) if scan_limit_error: return scan_limit_error verify, verify_error = strict_bool_argument(payload, "verify", method=method, default=True) if verify_error: return verify_error strict, strict_error = strict_bool_argument(payload, "strict", method=method, default=bool(verify)) if strict_error: return strict_error if strict and not verify: return invalid_argument(method, "verify", "strict=true requires verify=true.") overfetch_factor, overfetch_error = parse_int_argument( payload, "overfetch_factor", method=method, default=5, minimum=1, maximum=20, ) if overfetch_error: return overfetch_error def ranked_candidates() -> tuple[list[tuple[float, sqlite3.Row]], int]: with cache_connection() as conn: if embedding_model == CODE_INDEX_VECTOR_MODEL: rows = conn.execute( """ SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, v.chunk_index, v.text_sha1, v.payload_sha1, v.text_preview, v.embedding_json AS search_embedding_json, c.owner_kind, c.owner_name, c.owner_guid, c.form_name, c.extension_guid, c.extension_name, c.bsl_offset, c.source_table, c.file_name FROM metadata_code_vector_cache v JOIN metadata_code_index_cache c ON c.server_key=v.server_key AND c.database_name=v.database_name AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 WHERE v.server_key=? AND v.database_name=? AND v.embedding_model=? ORDER BY v.updated_at DESC LIMIT ? """, (cache_server_key(config), cache_database_name(config), embedding_model, int(scan_limit or 2000)), ).fetchall() else: rows = conn.execute( """ SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, v.chunk_index, v.text_sha1, v.payload_sha1, v.text_preview, e.embedding_json AS search_embedding_json, c.owner_kind, c.owner_name, c.owner_guid, c.form_name, c.extension_guid, c.extension_name, c.bsl_offset, c.source_table, c.file_name FROM metadata_code_embedding_cache e JOIN metadata_code_vector_cache v ON v.server_key=e.server_key AND v.database_name=e.database_name AND v.chunk_id=e.chunk_id AND v.text_sha1=e.text_sha1 JOIN metadata_code_index_cache c ON c.server_key=v.server_key AND c.database_name=v.database_name AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 WHERE e.server_key=? AND e.database_name=? AND e.embedding_model=? AND e.dimensions=? ORDER BY e.updated_at DESC LIMIT ? """, ( cache_server_key(config), cache_database_name(config), embedding_model, len(query_embedding), int(scan_limit or 2000), ), ).fetchall() saved_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') GROUP BY source_table, file_name """, (cache_server_key(config), cache_database_name(config)), ).fetchall() tombstone_rows = conn.execute( """ SELECT source_table, file_name FROM metadata_code_overlay_tombstone WHERE server_key=? AND database_name=? """, (cache_server_key(config), cache_database_name(config)), ).fetchall() saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} ranked: list[tuple[float, sqlite3.Row]] = [] shadowed = 0 for row in rows: source_table = str(row["source_table"] or "") saved_table = CODE_INDEX_SAVED_OVERLAYS.get(source_table) if saved_table and ( (saved_table, str(row["file_name"] or "")) in saved_files or (saved_table, str(row["file_name"] or "")) in tombstones ): shadowed += 1 continue try: embedding = numeric_vector(json.loads(row["search_embedding_json"] or "[]")) except Exception: embedding = None vector_score = cosine_similarity(query_embedding, embedding or []) lexical_score = ( min(1.0, semantic_lexical_score(query, str(row["text_preview"] or ""), str(row["owner_name"] or "")) / 10.0) if query else 0.0 ) score = max(float(vector_score or 0.0), float(lexical_score)) if score <= 0: continue ranked.append((score, row)) ranked.sort(key=lambda pair: (-pair[0], str(pair[1]["chunk_id"] or ""))) return ranked, shadowed candidates, shadowed_candidates = ranked_candidates() matches: list[dict[str, Any]] = [] rejected_stale = 0 rejected_unverified = 0 refreshed_modules = 0 verified_modules = 0 candidate_window = max(int(limit or 10), int(limit or 10) * int(overfetch_factor or 5)) for search_pass in range(2): matches = [] verification_by_module: dict[str, dict[str, Any]] = {} refreshed_this_pass = False for score, row in candidates[:candidate_window]: freshness = { "source": "code_vector_cache", "verified_against_sql": False, "payload_sha1": row["payload_sha1"], "text_sha1": row["text_sha1"], "status": "vector_candidate_unverified", } if verify: module_ref = str(row["module_ref"] or "") verification = verification_by_module.get(module_ref) verification_new = verification is None if verification is None: verification = metadata_code_index_verify( { "base_id": base_id, "module_ref": module_ref, "timeout_seconds": payload.get("timeout_seconds", 30), "refresh_stale": bool(strict), } ) verification_by_module[module_ref] = verification verified_modules += 1 freshness = verification.get("freshness") or freshness freshness_status = str(freshness.get("status") or "") if freshness_status == "cache_refreshed_from_sql": if verification_new: refreshed_modules += 1 refreshed_this_pass = True continue if strict and freshness_status != "cache_hit_verified": if freshness_status == "cache_hit_stale": rejected_stale += 1 else: rejected_unverified += 1 continue owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} row_module_table, _row_file_name, _row_stream_index = parse_module_id(str(row["module_ref"] or "")) if not owner.get("name") or (row_module_table == "ConfigCAS" and not owner_extension.get("name")): metadata_module_owner_resolve_from_ref( base_id, config, str(row["module_ref"] or ""), timeout_seconds=int(payload.get("timeout_seconds") or 30), ) owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) matches.append( { "score": score, "match_by": "vector_embedding", "chunk": {"id": row["chunk_id"], "kind": row["chunk_kind"], "routine_name": row["routine_name"], "index": row["chunk_index"]}, "text_preview": row["text_preview"], "owner": owner, "object_ref": public_ref or None, "object_selector": { **({"kind": owner.get("kind")} if owner.get("kind") else {}), **({"name": owner.get("name")} if owner.get("name") else {}), **({"guid": owner.get("guid")} if owner.get("guid") else {}), **({"extension": owner_extension.get("name")} if owner_extension.get("name") else {}), **({"extension_guid": owner_extension.get("guid")} if owner_extension.get("guid") else {}), }, "read_selector": { "method": "modules.read", "base_id": base_id, "module_ref": row["module_ref"], "preview": True, "max_chars": int(payload.get("read_max_chars") or 4000), **({"routine_name": row["routine_name"]} if row["routine_name"] else {}), **({"bsl_offset": row["bsl_offset"]} if row["bsl_offset"] is not None else {}), }, "freshness": freshness, } ) if len(matches) >= int(limit or 10): break if refreshed_this_pass and search_pass == 0: candidates, pass_shadowed = ranked_candidates() shadowed_candidates = max(shadowed_candidates, pass_shadowed) continue break index_state = code_index_state_status(config) live_fallback: dict[str, Any] | None = None if strict and query and index_state.get("status") == "dirty": live_fallback = metadata_code_index_search( { "base_id": base_id, "query": query, "mode": "live", "limit": int(limit or 10), "scan_limit": int(scan_limit or 2000), "timeout_seconds": payload.get("timeout_seconds", 30), } ) return { "schema": "onec_code_vector_search.v1", "method": method, "status": "ok" if matches or (live_fallback or {}).get("matches") else "not_found", **({"error": "not_found"} if not matches and not (live_fallback or {}).get("matches") else {}), "base_id": base_id, "source": {"kind": "code_vector_cache", "authoritative": False, "embedding_model": embedding_model}, "query": { "query": query or None, "query_embedding": {"dimensions": len(query_embedding)}, "embedding_model": embedding_model, "verify": bool(verify), "strict": bool(strict), "overfetch_factor": int(overfetch_factor or 5), "limit": int(limit or 10), "scan_limit": int(scan_limit or 2000), }, "matches": matches, "counts": { "matches": len(matches), "candidates": len(candidates), "candidate_window": min(len(candidates), candidate_window), "verified_modules": verified_modules, "refreshed_modules": refreshed_modules, "rejected_stale": rejected_stale, "rejected_unverified": rejected_unverified, "shadowed_candidates": shadowed_candidates, "live_fallback_matches": len((live_fallback or {}).get("matches") or []), }, "freshness": { "status": "live_verified_candidates" if index_state.get("status") == "clean" else "dirty_with_live_fallback", "validation_required": False if strict else True, "index_state": index_state, "snapshot_token": index_state.get("snapshot_token"), }, **({"live_fallback": live_fallback} if live_fallback is not None else {}), "diagnostics": [ { "message": ( "Strict mode returns only candidates verified against live SQL. " "When the local index is dirty, live_fallback covers newly added code until reindexing completes." ) } ], } def semantic_cache_pending(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.pending" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or "")) if (payload.get("kind") or payload.get("object_kind")) else None status_filter = str(payload.get("vector_status") or "pending_embedding").strip() if status_filter not in {"pending_embedding", "embedded", "error", "all"}: return invalid_argument(method, "vector_status", "vector_status must be one of: pending_embedding, embedded, error, all.") clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if object_kind: clauses.append("object_kind=?") params.append(object_kind) if status_filter != "all": clauses.append("vector_status=?") params.append(status_filter) with cache_connection() as conn: rows = conn.execute( f""" SELECT document_id, object_kind, object_guid, object_name, extension_guid, source_route_json, content_sha1, text_preview, vector_status, authoritative_source, updated_at, last_seen_at FROM semantic_document_cache WHERE {' AND '.join(clauses)} ORDER BY updated_at ASC LIMIT ? """, (*params, int(limit or 100)), ).fetchall() documents: list[dict[str, Any]] = [] for row in rows: try: source_route = json.loads(row["source_route_json"] or "{}") except Exception: source_route = {} if not isinstance(source_route, dict): source_route = {} documents.append( { "document_id": row["document_id"], "object": { "kind": row["object_kind"], "name": row["object_name"], "guid": row["object_guid"], "extension_guid": row["extension_guid"], }, "content_sha1": row["content_sha1"], "text": row["text_preview"], "source_route": source_route, "read_selector": semantic_cache_read_selector(base_id, row["object_kind"], source_route), "vector_status": row["vector_status"], "authoritative_source": row["authoritative_source"], "updated_at": row["updated_at"], "last_seen_at": row["last_seen_at"], "precondition": { "document_id": row["document_id"], "content_sha1": row["content_sha1"], "message": "Pass both values to semantic.cache.embedding.upsert. If content_sha1 changed, the embedding will be rejected.", }, } ) return { "schema": "onec_semantic_cache_pending.v1", "status": "ok", "base_id": base_id, "source": {"kind": "semantic_cache", "authoritative": False}, "query": {"kind": object_kind, "vector_status": status_filter, "limit": int(limit or 100)}, "documents": documents, "counts": {"documents": len(documents)}, } def semantic_cache_status(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.status" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or "")) if (payload.get("kind") or payload.get("object_kind")) else None include_entries, include_entries_error = strict_bool_argument(payload, "include_entries", method=method, default=False) if include_entries_error: return include_entries_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if object_kind: clauses.append("object_kind=?") params.append(object_kind) where_sql = " AND ".join(clauses) with cache_connection() as conn: total_row = conn.execute( f""" SELECT COUNT(*) AS total, SUM(CASE WHEN vector_status='pending_embedding' THEN 1 ELSE 0 END) AS pending, SUM(CASE WHEN vector_status='embedded' THEN 1 ELSE 0 END) AS embedded, SUM(CASE WHEN vector_status='error' THEN 1 ELSE 0 END) AS error, MIN(updated_at) AS oldest_updated_at, MAX(updated_at) AS newest_updated_at FROM semantic_document_cache WHERE {where_sql} """, params, ).fetchone() group_rows = conn.execute( f""" SELECT COALESCE(object_kind, '') AS object_kind, COALESCE(vector_status, '') AS vector_status, COALESCE(embedding_model, '') AS embedding_model, COUNT(*) AS count, MIN(updated_at) AS oldest_updated_at, MAX(updated_at) AS newest_updated_at FROM semantic_document_cache WHERE {where_sql} GROUP BY object_kind, vector_status, embedding_model ORDER BY object_kind, vector_status, embedding_model """, params, ).fetchall() entry_rows = [] if include_entries: entry_rows = conn.execute( f""" SELECT document_id, object_kind, object_guid, object_name, extension_guid, content_sha1, vector_status, embedding_model, authoritative_source, updated_at, last_seen_at FROM semantic_document_cache WHERE {where_sql} ORDER BY CASE WHEN vector_status='pending_embedding' THEN 0 WHEN vector_status='error' THEN 1 ELSE 2 END, updated_at, document_id LIMIT ? """, (*params, int(limit or 50)), ).fetchall() groups = [ { "kind": row["object_kind"] or None, "vector_status": row["vector_status"] or None, "embedding_model": row["embedding_model"] or None, "count": int(row["count"] or 0), "oldest_updated_at": row["oldest_updated_at"], "newest_updated_at": row["newest_updated_at"], } for row in group_rows ] entries = [ { "document_id": row["document_id"], "object": { "kind": row["object_kind"], "name": row["object_name"], "guid": row["object_guid"], "extension_guid": row["extension_guid"], }, "content_sha1": row["content_sha1"], "vector_status": row["vector_status"], "embedding_model": row["embedding_model"], "authoritative_source": row["authoritative_source"], "updated_at": row["updated_at"], "last_seen_at": row["last_seen_at"], } for row in entry_rows ] return { "schema": "onec_semantic_cache_status.v1", "status": "ok", "base_id": base_id, "source": {"kind": "semantic_document_cache", "authoritative": False}, "query": {"kind": object_kind, "include_entries": bool(include_entries), "limit": int(limit or 50)}, "counts": { "total": int((total_row or {})["total"] or 0) if total_row else 0, "pending_embedding": int((total_row or {})["pending"] or 0) if total_row else 0, "embedded": int((total_row or {})["embedded"] or 0) if total_row else 0, "error": int((total_row or {})["error"] or 0) if total_row else 0, "groups": len(groups), }, "oldest_updated_at": (total_row or {})["oldest_updated_at"] if total_row else None, "newest_updated_at": (total_row or {})["newest_updated_at"] if total_row else None, "groups": groups, **({"entries": entries} if include_entries else {}), "diagnostics": [ { "message": "Semantic cache status is readiness telemetry. Semantic documents are not authoritative source objects; validate via source route/read_selector before programming changes.", } ], } def semantic_cache_validate(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.validate" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) document_id = str(payload.get("document_id") or "").strip() row = semantic_document_cache_lookup(config, document_id) if not row: return { "schema": "onec_semantic_cache_validate.v1", "status": "not_found", "error": "not_found", "base_id": base_id, "document_id": document_id, "diagnostics": [{"message": "Semantic cache document was not found for this base_id."}], } try: source_route = json.loads(row.get("source_route_json") or "{}") except Exception: source_route = {} if not isinstance(source_route, dict): source_route = {} table = str(source_route.get("table") or "ConfigCAS").strip() or "ConfigCAS" file_name = str(source_route.get("file_name") or source_route.get("part_id") or "").strip() if not file_name: semantic_document_cache_mark_changed(config, document_id) return { "schema": "onec_semantic_cache_validate.v1", "status": "stale", "error": "route_missing", "base_id": base_id, "document_id": document_id, "object": { "kind": row.get("object_kind"), "name": row.get("object_name"), "guid": row.get("object_guid"), "extension_guid": row.get("extension_guid"), }, "freshness": { "status": "stale", "validation_required": True, "reason": "semantic_source_route_missing_file_name", }, "cache": {"content_sha1": row.get("content_sha1"), "vector_status": "error"}, } timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) if timeout_error: return timeout_error data, _, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if error or data is None: semantic_document_cache_mark_changed(config, document_id) return { "schema": "onec_semantic_cache_validate.v1", "status": "source_missing", "error": "source_missing", "base_id": base_id, "document_id": document_id, "object": { "kind": row.get("object_kind"), "name": row.get("object_name"), "guid": row.get("object_guid"), "extension_guid": row.get("extension_guid"), }, "source_route": source_route, "freshness": { "status": "stale", "validation_required": True, "reason": "semantic_source_payload_unreadable", }, "cache": {"content_sha1": row.get("content_sha1"), "vector_status": "error"}, "diagnostics": [{"message": "Failed to read current source bytes for semantic cache validation.", "error": error}], } current_sha1 = hashlib.sha1(data).hexdigest() cached_sha1 = str(row.get("content_sha1") or "").strip().lower() if current_sha1 != cached_sha1: semantic_document_cache_mark_changed(config, document_id) return { "schema": "onec_semantic_cache_validate.v1", "status": "stale", "error": "content_changed", "base_id": base_id, "document_id": document_id, "object": { "kind": row.get("object_kind"), "name": row.get("object_name"), "guid": row.get("object_guid"), "extension_guid": row.get("extension_guid"), }, "source_route": source_route, "read_selector": semantic_cache_read_selector(base_id, row.get("object_kind"), source_route), "freshness": { "status": "stale", "validation_required": True, "reason": "semantic_source_payload_changed", }, "cache": { "cached_content_sha1": cached_sha1, "current_content_sha1": current_sha1, "vector_status": "error", }, "diagnostics": [ { "message": "Semantic cache document no longer matches current source bytes. Re-decode the object and recompute embedding before using this result for programming changes.", } ], } semantic_document_cache_mark_seen(config, document_id) return { "schema": "onec_semantic_cache_validate.v1", "status": "ok", "base_id": base_id, "document_id": document_id, "object": { "kind": row.get("object_kind"), "name": row.get("object_name"), "guid": row.get("object_guid"), "extension_guid": row.get("extension_guid"), }, "source_route": source_route, "read_selector": semantic_cache_read_selector(base_id, row.get("object_kind"), source_route), "freshness": { "status": "fresh", "validated_by": "current_source_sha1", "validation_required": False, }, "cache": { "content_sha1": cached_sha1, "vector_status": row.get("vector_status"), "embedding_model": row.get("embedding_model"), "authoritative_source": row.get("authoritative_source"), }, "source": { "authoritative": True, "kind": "live_sql_payload_sha1", "message": "The semantic cache candidate matched current source bytes. Use read_selector for the actual source object payload.", }, } def semantic_cache_validate_batch(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.validate_batch" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) if timeout_error: return timeout_error ids_value = payload.get("document_ids") document_ids: list[str] = [] if ids_value is not None: if not isinstance(ids_value, list): return invalid_argument(method, "document_ids", "document_ids must be a JSON array of strings.") for item in ids_value: if not isinstance(item, str): return invalid_argument(method, "document_ids", "document_ids must contain only strings.") text = item.strip() if text and text not in document_ids: document_ids.append(text) document_ids = document_ids[: int(limit or 100)] else: object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or "")) if (payload.get("kind") or payload.get("object_kind")) else None status_filter = str(payload.get("vector_status") or "all").strip() if status_filter not in {"pending_embedding", "embedded", "error", "all"}: return invalid_argument(method, "vector_status", "vector_status must be one of: pending_embedding, embedded, error, all.") clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if object_kind: clauses.append("object_kind=?") params.append(object_kind) if status_filter != "all": clauses.append("vector_status=?") params.append(status_filter) with cache_connection() as conn: rows = conn.execute( f""" SELECT document_id FROM semantic_document_cache WHERE {' AND '.join(clauses)} ORDER BY updated_at ASC, document_id LIMIT ? """, (*params, int(limit or 100)), ).fetchall() document_ids = [str(row["document_id"] or "") for row in rows if str(row["document_id"] or "")] results: list[dict[str, Any]] = [] counts = {"checked": 0, "fresh": 0, "stale": 0, "source_missing": 0, "not_found": 0, "other": 0} for document_id in document_ids: item = semantic_cache_validate( { "base_id": base_id, "document_id": document_id, "timeout_seconds": int(timeout_seconds or 30), } ) compact = { "document_id": document_id, "status": item.get("status"), **({"error": item.get("error")} if item.get("error") else {}), **({"object": item.get("object")} if item.get("object") else {}), **({"freshness": item.get("freshness")} if item.get("freshness") else {}), **({"cache": item.get("cache")} if item.get("cache") else {}), **({"read_selector": item.get("read_selector")} if item.get("read_selector") else {}), } results.append(compact) counts["checked"] += 1 status = str(item.get("status") or "") if status == "ok": counts["fresh"] += 1 elif status == "stale": counts["stale"] += 1 elif status == "source_missing": counts["source_missing"] += 1 elif status == "not_found": counts["not_found"] += 1 else: counts["other"] += 1 return { "schema": "onec_semantic_cache_validate_batch.v1", "status": "ok", "base_id": base_id, "source": { "kind": "semantic_cache_validation_batch", "authoritative": False, "message": "Batch validation reports freshness only. Use each fresh read_selector for the actual source object.", }, "query": { "document_ids": document_ids if ids_value is not None else None, "kind": canonical_kind(str(payload.get("kind") or payload.get("object_kind") or "")) if (payload.get("kind") or payload.get("object_kind")) else None, "vector_status": str(payload.get("vector_status") or "all").strip() if ids_value is None else None, "limit": int(limit or 100), }, "counts": counts, "results": results, "diagnostics": [ { "message": "Fresh batch items are verified by current source bytes. Stale/source_missing items must be re-read and re-embedded before programming changes.", } ], } def semantic_cache_refresh(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.refresh" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) document_id = str(payload.get("document_id") or "").strip() row = semantic_document_cache_lookup(config, document_id) if not row: return { "schema": "onec_semantic_cache_refresh.v1", "status": "not_found", "error": "not_found", "base_id": base_id, "document_id": document_id, "diagnostics": [{"message": "Semantic cache document was not found for this base_id."}], } object_kind = str(row.get("object_kind") or "").strip() if object_kind and object_kind != "Template": return { "schema": "onec_semantic_cache_refresh.v1", "status": "unsupported", "error": "unsupported_kind", "base_id": base_id, "document_id": document_id, "object": { "kind": row.get("object_kind"), "name": row.get("object_name"), "guid": row.get("object_guid"), "extension_guid": row.get("extension_guid"), }, "diagnostics": [{"message": "semantic.cache.refresh currently supports Template semantic documents produced from decoded template artifacts."}], } try: source_route = json.loads(row.get("source_route_json") or "{}") except Exception: source_route = {} if not isinstance(source_route, dict): source_route = {} table = str(source_route.get("table") or "ConfigCAS").strip() or "ConfigCAS" file_name = str(source_route.get("file_name") or source_route.get("part_id") or "").strip() if not file_name: return { "schema": "onec_semantic_cache_refresh.v1", "status": "stale", "error": "route_missing", "base_id": base_id, "document_id": document_id, "freshness": {"status": "stale", "validation_required": True, "reason": "semantic_source_route_missing_file_name"}, } timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) if timeout_error: return timeout_error data, _, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 60)) if error or data is None: semantic_document_cache_mark_changed(config, document_id) return { "schema": "onec_semantic_cache_refresh.v1", "status": "source_missing", "error": "source_missing", "base_id": base_id, "document_id": document_id, "source_route": source_route, "freshness": {"status": "stale", "validation_required": True, "reason": "semantic_source_payload_unreadable"}, "diagnostics": [{"message": "Failed to read current source bytes for semantic cache refresh.", "error": error}], } current_sha1 = hashlib.sha1(data).hexdigest() cached_sha1 = str(row.get("content_sha1") or "").strip().lower() structure = extract_moxel_public_structure(data) semantic_text = template_structure_semantic_text(structure) if not semantic_text.strip(): semantic_document_cache_mark_changed(config, document_id) return { "schema": "onec_semantic_cache_refresh.v1", "status": "unsupported", "error": "empty_semantic_text", "base_id": base_id, "document_id": document_id, "source_route": source_route, "cache": {"cached_content_sha1": cached_sha1, "current_content_sha1": current_sha1, "vector_status": "error"}, "diagnostics": [{"message": "Current payload decoded, but no semantic text could be produced for embedding."}], } artifact = { "part_id": file_name, "table": table, "content_kind": "MOXCEL", "structure": structure, "artifact_cache": {"status": "refresh_stored", "content_sha1": current_sha1}, } decoded_artifact_cache_upsert( config, artifact_kind=MOXEL_TEMPLATE_ARTIFACT_KIND, content_sha1=current_sha1, source_table=table, source_file=file_name, payload_bytes=len(data), artifact=artifact, semantic_text=semantic_text, ) new_document_id = refreshed_semantic_document_id(document_id, {**source_route, "table": table, "file_name": file_name, "part_id": source_route.get("part_id") or file_name}, current_sha1) refreshed_route = {**source_route, "table": table, "file_name": file_name, "part_id": source_route.get("part_id") or file_name} semantic_document_cache_upsert( config, document_id=new_document_id, object_kind=row.get("object_kind") or "Template", object_guid=row.get("object_guid") or file_name, object_name=row.get("object_name"), extension_guid=row.get("extension_guid"), source_route=refreshed_route, content_sha1=current_sha1, text=semantic_text, authoritative_source=row.get("authoritative_source") or "decoded_artifact_cache", ) replaced_document = new_document_id != document_id if replaced_document: semantic_document_cache_delete(config, document_id) return { "schema": "onec_semantic_cache_refresh.v1", "status": "ok", "base_id": base_id, "document_id": new_document_id, "previous_document_id": document_id if replaced_document else None, "object": { "kind": row.get("object_kind") or "Template", "name": row.get("object_name"), "guid": row.get("object_guid") or file_name, "extension_guid": row.get("extension_guid"), }, "source_route": refreshed_route, "read_selector": semantic_cache_read_selector(base_id, row.get("object_kind") or "Template", refreshed_route), "cache": { "previous_content_sha1": cached_sha1 or None, "content_sha1": current_sha1, "content_changed": current_sha1 != cached_sha1, "vector_status": "pending_embedding", "artifact_cache": "stored", "semantic_text_chars": len(semantic_text), }, "freshness": { "status": "fresh", "validated_by": "refresh_current_source_decode", "validation_required": False, }, "precondition": { "document_id": new_document_id, "content_sha1": current_sha1, "message": "Pass both values to semantic.cache.embedding.upsert after computing a fresh embedding.", }, "diagnostics": [ { "message": "Semantic document was refreshed from current source bytes and queued for embedding.", } ], } def semantic_cache_rebuild(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.rebuild" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension_guid, extension_error = extension_filter_to_guid(base_id, str(payload.get("extension") or ""), method=method) if extension_error: return extension_error object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or payload.get("object_type") or "Template")) if object_kind != "Template": return invalid_argument(method, "kind", "semantic.cache.rebuild currently supports kind=Template only.") limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=5000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) if timeout_error: return timeout_error refresh_routes, refresh_routes_error = strict_bool_argument(payload, "refresh_routes", method=method, default=False) if refresh_routes_error: return refresh_routes_error include_entries, include_entries_error = strict_bool_argument(payload, "include_entries", method=method, default=False) if include_entries_error: return include_entries_error cache_config, config_error = sql_config_for_base(base_id) if not cache_config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) route_rebuild = None if refresh_routes: route_rebuild = extension_cache_rebuild( { "base_id": base_id, "extension": payload.get("extension"), "kind": "Template", "max_items": int(limit or 100), "timeout_seconds": int(timeout_seconds or 120), "include_matches": False, } ) if route_rebuild.get("status") not in {"ok", "not_found"}: return route_rebuild clauses = ["server_key=?", "database_name=?", "freshness_status!='stale'", "object_kind='Template'"] params: list[Any] = [cache_server_key(cache_config), cache_database_name(cache_config)] if extension_guid: clauses.append("extension_guid=?") params.append(extension_guid.lower()) with cache_connection() as conn: before_rows = conn.execute( """ SELECT document_id, content_sha1 FROM semantic_document_cache WHERE server_key=? AND database_name=? """, (cache_server_key(cache_config), cache_database_name(cache_config)), ).fetchall() before_docs = {str(row["document_id"] or ""): str(row["content_sha1"] or "") for row in before_rows} route_rows = conn.execute( f""" SELECT descriptor_cas_key, extension_guid, extension_name, object_kind, name, guid, route_json FROM extension_route_cache WHERE {' AND '.join(clauses)} ORDER BY updated_at DESC, descriptor_cas_key LIMIT ? """, (*params, int(limit or 100)), ).fetchall() entries: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] templates_read = 0 parts_seen = 0 for row in route_rows: try: route = json.loads(row["route_json"] or "{}") except Exception: route = {} if not isinstance(route, dict): route = {} table = str(route.get("table") or "ConfigCAS").strip() or "ConfigCAS" file_name = str(route.get("file_name") or row["descriptor_cas_key"] or "").strip() if not file_name: errors.append({"descriptor_cas_key": row["descriptor_cas_key"], "error": "route_missing_file_name"}) continue result = read_template_by_route( { "base_id": base_id, "kind": "Template", "table": table, "file_name": file_name, "timeout_seconds": int(timeout_seconds or 120), "view": "summary", } ) if result.get("status") != "ok": errors.append( { "descriptor_cas_key": row["descriptor_cas_key"], "name": row["name"], "status": result.get("status"), "error": result.get("error"), "diagnostics": result.get("diagnostics"), } ) continue templates_read += int((result.get("counts") or {}).get("templates") or 0) parts_seen += int((result.get("counts") or {}).get("parts") or 0) if include_entries: entries.append( { "descriptor_cas_key": row["descriptor_cas_key"], "object": { "kind": row["object_kind"], "name": row["name"], "guid": row["guid"], "extension_guid": row["extension_guid"], }, "route": {"table": table, "file_name": file_name}, "counts": result.get("counts"), } ) with cache_connection() as conn: after_rows = conn.execute( """ SELECT document_id, content_sha1, vector_status FROM semantic_document_cache WHERE server_key=? AND database_name=? """, (cache_server_key(cache_config), cache_database_name(cache_config)), ).fetchall() after_docs = {str(row["document_id"] or ""): {"content_sha1": str(row["content_sha1"] or ""), "vector_status": row["vector_status"]} for row in after_rows} created = [document_id for document_id in after_docs if document_id not in before_docs] changed = [ document_id for document_id, item in after_docs.items() if document_id in before_docs and before_docs[document_id] != item["content_sha1"] ] pending = [document_id for document_id, item in after_docs.items() if item.get("vector_status") == "pending_embedding"] return { "schema": "onec_semantic_cache_rebuild.v1", "status": "ok", "base_id": base_id, "source": { "kind": "extension_route_cache", "authoritative": False, "message": "Semantic rebuild warms local search/index caches. Validate candidates against current source bytes before programming changes.", }, "query": { "extension": payload.get("extension"), "extension_guid": extension_guid, "kind": object_kind, "limit": int(limit or 100), "refresh_routes": bool(refresh_routes), }, "counts": { "routes_scanned": len(route_rows), "templates_read": templates_read, "parts_seen": parts_seen, "semantic_documents_created": len(created), "semantic_documents_changed": len(changed), "pending_embedding_total": len(pending), "errors": len(errors), **({"route_rebuild_cached_routes": (route_rebuild.get("counts") or {}).get("cached_routes")} if route_rebuild else {}), }, **({"route_rebuild": route_rebuild} if route_rebuild else {}), **({"entries": entries[:200]} if include_entries else {}), **({"errors": errors[:200]} if errors else {}), "diagnostics": [ { "message": "Only Template routes are rebuilt at this stage; semantic/vector results remain cache candidates until validated.", } ], } def semantic_cache_embedding_upsert(payload: dict[str, Any]) -> dict[str, Any]: method = "semantic.cache.embedding.upsert" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) document_id = str(payload.get("document_id") or "").strip() content_sha1 = str(payload.get("content_sha1") or "").strip().lower() embedding_model = str(payload.get("embedding_model") or "").strip() embedding = numeric_vector(payload.get("embedding")) if not document_id: return invalid_argument(method, "document_id", "document_id is required.") if not re.fullmatch(r"[0-9a-f]{40}", content_sha1): return invalid_argument(method, "content_sha1", "content_sha1 must be a 40-character lowercase SHA1 hex string from semantic.cache.pending.") if not embedding_model: return invalid_argument(method, "embedding_model", "embedding_model is required.") if embedding is None: return invalid_argument(method, "embedding", "embedding must be a non-empty JSON array of numbers.") now = time.time() with cache_connection() as conn: row = conn.execute( """ SELECT content_sha1, vector_status FROM semantic_document_cache WHERE server_key=? AND database_name=? AND document_id=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), document_id), ).fetchone() if not row: return { "schema": "onec_semantic_cache_embedding_upsert.v1", "status": "not_found", "error": "document_not_found", "base_id": base_id, "document_id": document_id, "diagnostics": {"message": "Semantic document was not found. Rebuild/read the source artifact first."}, } current_sha1 = str(row["content_sha1"] or "").lower() if current_sha1 != content_sha1: return { "schema": "onec_semantic_cache_embedding_upsert.v1", "status": "conflict", "error": "content_sha1_mismatch", "base_id": base_id, "document_id": document_id, "expected_content_sha1": current_sha1, "provided_content_sha1": content_sha1, "diagnostics": { "message": "Embedding was rejected because the semantic document changed after it was queued. Fetch semantic.cache.pending again and recompute embedding.", }, } conn.execute( """ UPDATE semantic_document_cache SET embedding_model=?, embedding_json=?, vector_status='embedded', updated_at=?, last_seen_at=? WHERE server_key=? AND database_name=? AND document_id=? AND content_sha1=? """, ( embedding_model, json.dumps(embedding, ensure_ascii=False, separators=(",", ":")), now, now, cache_server_key(config), cache_database_name(config), document_id, content_sha1, ), ) return { "schema": "onec_semantic_cache_embedding_upsert.v1", "status": "ok", "base_id": base_id, "document_id": document_id, "content_sha1": content_sha1, "embedding_model": embedding_model, "dimensions": len(embedding), "vector_status": "embedded", "freshness": { "status": "stored_with_content_precondition", "message": "Embedding is tied to this content_sha1 and will not be reused for changed source content.", }, } FIELD_TYPE_CACHE_ROLE = "metadata_field_type_v1" def metadata_field_type_cache_lookup(config: dict[str, str] | None, field_guids: set[str]) -> dict[str, dict[str, Any]]: wanted = sorted({str(guid or "").lower() for guid in field_guids if is_guid_text(str(guid or ""))}) if not config or not wanted: return {} result: dict[str, dict[str, Any]] = {} with cache_connection() as conn: for start in range(0, len(wanted), 500): chunk = wanted[start : start + 500] placeholders = ",".join(["?"] * len(chunk)) rows = conn.execute( f""" SELECT guid, payload_json FROM metadata_guid_index WHERE server_key = ? AND database_name = ? AND guid_role = ? AND guid IN ({placeholders}) """, (cache_server_key(config), cache_database_name(config), FIELD_TYPE_CACHE_ROLE, *chunk), ).fetchall() for row in rows: try: payload = json.loads(row["payload_json"] or "{}") except Exception: continue if isinstance(payload, dict): result[str(row["guid"]).lower()] = payload return result def metadata_field_type_cache_upsert(config: dict[str, str] | None, field_guid: str, type_info: Any, *, owner: dict[str, Any] | None = None, field_name: str | None = None) -> None: guid = str(field_guid or "").lower() if not config or not is_guid_text(guid) or not isinstance(type_info, dict): return owner = owner or {} metadata_guid_index_upsert( config, { "guid": guid, "guid_role": FIELD_TYPE_CACHE_ROLE, "kind": "Attribute", "kind_ru": "Реквизит", "name": field_name, "presentation": str(type_info.get("presentation") or ""), "owner_guid": owner.get("guid"), "owner_kind": owner.get("kind"), "owner_name": owner.get("name"), "payload": sanitize_public_result(type_info), "source_file": owner.get("guid"), }, ) def metadata_attributes_cache_role(scope: str) -> str: normalized_scope = normalize(scope or "all") or "all" if normalized_scope in {"attrs", "requisites"}: normalized_scope = "attributes" if normalized_scope in {"tabs", "tabularsections", "tableparts"}: normalized_scope = "tabular_sections" return f"object_attributes_v2_{normalized_scope}" def metadata_commands_cache_role(include_form_commands: bool) -> str: return "object_commands_v2_with_forms" if include_form_commands else "object_commands_v2_object_only" def public_visible_command(item: dict[str, Any], *, include_storage: bool = False) -> bool: if include_storage: return True if item.get("status") == "source_missing" and not item.get("name") and not item.get("synonym") and not item.get("title"): return False return True def metadata_modules_cache_role() -> str: return "object_modules_v5" def metadata_module_owner_cache_role() -> str: return "module_owner_v1" def metadata_module_owner_cache_upsert( config: dict[str, str], module_ref: str, owner: dict[str, Any], module: dict[str, Any] | None = None, ) -> int: normalized_module_ref = str(module_ref or "").strip() if not config or not normalized_module_ref: return 0 owner_guid = str((owner or {}).get("guid") or "").lower() if not is_guid_text(owner_guid): return 0 now = time.time() module_payload = { "module_name": (module or {}).get("name"), "module_ordinal": (module or {}).get("module_ordinal"), "suffix": (module or {}).get("suffix"), "stream_index": (module or {}).get("stream_index"), "file_name": (module or {}).get("file_name"), "payload": (module or {}).get("payload"), "extension": (module or {}).get("extension"), } payload_json = json.dumps(module_payload, ensure_ascii=False, sort_keys=True) if module_payload else None module_table, module_file_name, module_stream_index = parse_module_id(normalized_module_ref) with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_module_owner_cache ( server_key, database_name, module_ref, module_table, file_name, stream_index, owner_guid, owner_kind, owner_name, owner_synonym, module_payload_json, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :module_ref, :module_table, :file_name, :stream_index, :owner_guid, :owner_kind, :owner_name, :owner_synonym, :payload_json, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, module_ref) DO UPDATE SET module_table=excluded.module_table, file_name=excluded.file_name, stream_index=excluded.stream_index, owner_guid=excluded.owner_guid, owner_kind=excluded.owner_kind, owner_name=excluded.owner_name, owner_synonym=excluded.owner_synonym, module_payload_json=excluded.module_payload_json, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "module_ref": normalized_module_ref, "module_table": module_table, "file_name": module_file_name, "stream_index": module_stream_index, "owner_guid": owner_guid or None, "owner_kind": owner.get("kind"), "owner_name": owner.get("name"), "owner_synonym": owner.get("synonym"), "payload_json": payload_json, "updated_at": now, "last_seen_at": now, }, ) module_ref_candidates = [normalized_module_ref] if module_table in CODE_INDEX_SAVED_OVERLAYS: module_ref_candidates.append( f"{CODE_INDEX_SAVED_OVERLAYS[module_table]}:{normalized_module_ref.split(':', 1)[1]}" ) elif module_table in CODE_INDEX_ACTIVE_TABLES: module_ref_candidates.append( f"{CODE_INDEX_ACTIVE_TABLES[module_table]}:{normalized_module_ref.split(':', 1)[1]}" ) placeholders = ",".join(["?"] * len(module_ref_candidates)) module_extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} enriched_code_rows = conn.execute( f""" UPDATE metadata_code_index_cache SET owner_kind=COALESCE(owner_kind, ?), owner_name=COALESCE(owner_name, ?), owner_guid=COALESCE(owner_guid, ?), extension_guid=COALESCE(extension_guid, ?), extension_name=COALESCE(extension_name, ?) WHERE server_key=? AND database_name=? AND module_ref IN ({placeholders}) AND ( owner_kind IS NULL OR owner_name IS NULL OR owner_guid IS NULL OR extension_guid IS NULL OR extension_name IS NULL ) """, ( owner.get("kind"), owner.get("name"), owner_guid, module_extension.get("guid"), module_extension.get("name"), cache_server_key(config), cache_database_name(config), *module_ref_candidates, ), ).rowcount return max(0, int(enriched_code_rows or 0)) def metadata_module_owner_cache_lookup(config: dict[str, str], module_ref: str) -> dict[str, Any] | None: normalized_module_ref = str(module_ref or "").strip() if not config or not normalized_module_ref: return None candidate_refs = [normalized_module_ref] module_table, _module_file_name, _module_stream_index = parse_module_id(normalized_module_ref) if ":" in normalized_module_ref: module_tail = normalized_module_ref.split(":", 1)[1] if module_table in CODE_INDEX_SAVED_OVERLAYS: candidate_refs.append(f"{CODE_INDEX_SAVED_OVERLAYS[module_table]}:{module_tail}") elif module_table in CODE_INDEX_ACTIVE_TABLES: candidate_refs.append(f"{CODE_INDEX_ACTIVE_TABLES[module_table]}:{module_tail}") row = None with cache_connection() as conn: for candidate_ref in candidate_refs: row = conn.execute( """ SELECT owner_guid, owner_kind, owner_name, owner_synonym, module_payload_json FROM metadata_module_owner_cache WHERE server_key = ? AND database_name = ? AND module_ref = ? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), candidate_ref), ).fetchone() if row: break if not row: return None payload = {} if row["module_payload_json"]: try: loaded = json.loads(row["module_payload_json"]) if isinstance(loaded, dict): payload = loaded except Exception: payload = {} return { "owner_guid": row["owner_guid"] or None, "owner_kind": row["owner_kind"] or None, "owner_name": row["owner_name"] or None, "owner_synonym": row["owner_synonym"] or None, "module_payload": payload, "owner": {"kind": row["owner_kind"] or None, "name": row["owner_name"] or None, "synonym": row["owner_synonym"] or None, "guid": row["owner_guid"] or None}, } def metadata_extension_module_owner_resolve_from_ref( base_id: str, config: dict[str, str], module_ref: str, *, timeout_seconds: int = 30, ) -> dict[str, Any] | None: module_table, file_name, stream_index = parse_module_id(str(module_ref or "").strip()) if module_table != "ConfigCAS" or not file_name: return None related_entries, _diagnostics = manifest_related_entries_for_cas_key( base_id, file_name, timeout_seconds=int(timeout_seconds or 30), ) if not related_entries: return None descriptor = next( ( entry for entry in related_entries if isinstance(entry, dict) and str(entry.get("suffix") or "") == "" and entry.get("cas_key") ), None, ) if not descriptor: return None descriptor_key = str(descriptor.get("cas_key") or "").strip().lower() extension = descriptor.get("extension") if isinstance(descriptor.get("extension"), dict) else {} row: dict[str, Any] | None = None with cache_connection() as conn: cached_row = conn.execute( """ SELECT * FROM extension_route_cache WHERE server_key=? AND database_name=? AND descriptor_cas_key=? AND freshness_status!='stale' LIMIT 1 """, (cache_server_key(config), cache_database_name(config), descriptor_key), ).fetchone() if cached_row: row = dict(cached_row) owner_guid = str((row or {}).get("guid") or descriptor.get("object_base_id") or "").strip().lower() owner_kind = str((row or {}).get("object_kind") or "").strip() owner_name = str((row or {}).get("name") or "").strip() owner_synonym = str((row or {}).get("synonym") or "").strip() or None descriptor_payload_sha1: str | None = (row or {}).get("descriptor_payload_sha1") if not owner_kind or not owner_name or not is_guid_text(owner_guid): descriptor_data, _descriptor_config, descriptor_error = read_storage_file_bytes( base_id, "ConfigCAS", descriptor_key, timeout_seconds=int(timeout_seconds or 30), ) if descriptor_error or descriptor_data is None: return None try: from parser.cas_payload import classify_payload classification = classify_payload(descriptor_data, include_text=False) except Exception: classification = {} identity = config_identity_from_bytes(descriptor_data) or {} owner_guid = str(identity.get("guid") or descriptor.get("object_base_id") or "").strip().lower() owner_kind = str(extension_metadata_payload_kind(descriptor_data, identity, classification) or "").strip() owner_name = str(identity.get("name") or "").strip() synonyms = identity.get("synonyms") if isinstance(identity.get("synonyms"), dict) else {} owner_synonym = next(iter(synonyms.values()), None) descriptor_payload_sha1 = hashlib.sha1(descriptor_data).hexdigest() if not owner_name or not is_guid_text(owner_guid): return None extension_guid = str(extension.get("guid") or (row or {}).get("extension_guid") or "").strip().lower() extension_name = str(extension.get("name") or (row or {}).get("extension_name") or "").strip() public_extension = { "guid": extension_guid if is_guid_text(extension_guid) else None, "name": extension_name or None, } owner = { "kind": owner_kind, "name": owner_name, "synonym": owner_synonym, "guid": owner_guid, } # Once the descriptor has supplied a proven kind, resolve the exact BSL # stream through the object's own manifest parts. This is deliberately # not a suffix-to-role guess: only a module_id emitted by # metadata.object.modules is retained as an ordinal/role route. resolved_module: dict[str, Any] = {} modules_result = metadata_object_modules( { "base_id": base_id, "kind": owner_kind, "name": owner_name, "guid": owner_guid, "extension": extension_name or extension_guid, "extension_guid": extension_guid if is_guid_text(extension_guid) else None, "table": "ConfigCAS", "include_storage": True, "timeout_seconds": int(timeout_seconds or 30), } ) if owner_kind else {} if isinstance(modules_result, dict) and modules_result.get("status") == "ok": for ordinal, module in enumerate(modules_result.get("modules") or [], start=1): if not isinstance(module, dict) or not module_ref_matches(str(module.get("module_id") or ""), module_ref): continue resolved_module = { "module_name": module.get("name"), "module_type": module.get("kind"), "module_ordinal": module.get("module_ordinal") or ordinal, "stream_index": stream_index, "file_name": file_name, **({"form": module.get("form")} if isinstance(module.get("form"), dict) else {}), } break route = { "route_type": "extension_manifest_cas", "table": "ConfigCAS", "file_name": descriptor_key, "manifest_entry": descriptor, "manifest_entries": len(related_entries), } extension_route_cache_upsert( config, { **owner, "origin": {"source": "extension", "extension": public_extension}, "route": route, "manifest_entries": related_entries, }, descriptor_payload_sha1=descriptor_payload_sha1, freshness_status="fresh", ) metadata_module_owner_cache_upsert( config, module_ref, owner, module={ # This resolver is invoked only for a payload that has already # produced a BSL search hit. The stream role is therefore proven # as BSL, while a more specific object/form module role remains # deliberately unknown until metadata evidence provides it. "module_name": "Модуль БСЛ", "module_type": "bsl_module", "stream_index": stream_index, "file_name": file_name, "extension": public_extension, **resolved_module, }, ) return metadata_module_owner_cache_lookup(config, module_ref) def metadata_module_owner_resolve_from_ref( base_id: str, config: dict[str, str], module_ref: str, *, timeout_seconds: int = 30, ) -> dict[str, Any] | None: module_table, file_name, _stream_index = parse_module_id(str(module_ref or "").strip()) cached = metadata_module_owner_cache_lookup(config, module_ref) if cached: cached_module_payload = cached.get("module_payload") if isinstance(cached.get("module_payload"), dict) else {} cached_extension = cached_module_payload.get("extension") if isinstance(cached_module_payload.get("extension"), dict) else {} cached_owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} if module_table != "ConfigCAS" or ( (cached_extension.get("guid") or cached_extension.get("name")) and cached_owner.get("kind") ): return cached if module_table == "ConfigCAS": return metadata_extension_module_owner_resolve_from_ref( base_id, config, module_ref, timeout_seconds=int(timeout_seconds or 30), ) if module_table not in {"Config", "ConfigSave"} or not file_name: return None guid_match = re.match( r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\.|$)", str(file_name), ) if not guid_match: return None owner_guid = guid_match.group(1).lower() object_result = get_object( None, owner_guid, base_id=base_id, view="effective", limit=2, include_storage=False, include_semantic=False, timeout_seconds=int(timeout_seconds or 30), table=module_table, ) if object_result.get("status") != "ok": return None object_card = object_result.get("object") if isinstance(object_result.get("object"), dict) else {} if str(object_card.get("guid") or "").lower() != owner_guid: return None owner = { "kind": object_card.get("kind"), "name": object_card.get("name"), "synonym": object_card.get("synonym"), "guid": owner_guid, } if not owner.get("kind") or not owner.get("name"): return None metadata_module_owner_cache_upsert(config, module_ref, owner) return metadata_module_owner_cache_lookup(config, module_ref) def metadata_module_owner_backfill_kinds() -> list[str]: return [ kind for kind in sorted(KIND_CAPABILITIES) if "modules" in KIND_CAPABILITIES.get(kind, []) and "list" in KIND_CAPABILITIES.get(kind, []) ] def metadata_module_owner_cache_backfill(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.module_owner_cache.backfill" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error config, config_error = sql_config_for_base(base_id) if not config: return public_error_result( {"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method, ) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=200) if limit_error: return limit_error offset, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0) if offset_error: return offset_error kind_index, kind_index_error = parse_int_argument(payload, "kind_index", method=method, default=0, minimum=0) if kind_index_error: return kind_index_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) if timeout_error: return timeout_error refresh_cache, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method=method, default=False) if refresh_cache_error: return refresh_cache_error deep, deep_error = strict_bool_argument(payload, "deep", method=method, default=False) if deep_error: return deep_error table = str(payload.get("table") or "Config").strip() if table not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) requested_kind = str(payload.get("kind") or "").strip() all_kinds = metadata_module_owner_backfill_kinds() if requested_kind: current_kind = canonical_kind(requested_kind) if current_kind not in all_kinds: return invalid_argument( method, "kind", "kind must be a listable metadata kind with BSL modules.", allowed_values=all_kinds, ) current_kind_index = all_kinds.index(current_kind) scoped_kinds = [current_kind] else: current_kind_index = int(kind_index or 0) if current_kind_index >= len(all_kinds): return { "schema": "onec_module_owner_cache_backfill.v1", "method": method, "status": "complete", "base_id": base_id, "complete": True, "next_cursor": None, "counts": {"objects": 0, "modules": 0, "code_index_rows_enriched": 0, "errors": 0}, } current_kind = all_kinds[current_kind_index] scoped_kinds = all_kinds with cache_connection() as conn: before_entries = int( conn.execute( "SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] or 0 ) page = list_objects( current_kind, base_id=base_id, limit=int(limit or 50), offset=int(offset or 0), include_storage=False, refresh_cache=bool(refresh_cache), table=table, ) if page.get("status") != "ok": result = dict(page) result.update({"schema": "onec_module_owner_cache_backfill.v1", "method": method}) return result objects = [item for item in page.get("objects") or [] if isinstance(item, dict)] modules_seen = 0 enriched_code_rows = 0 index_resolved_objects = 0 metadata_resolved_objects = 0 skipped_not_indexed_objects = 0 errors: list[dict[str, Any]] = [] processed_objects: list[dict[str, Any]] = [] for object_card in objects: object_guid = str(object_card.get("guid") or "").strip().lower() indexed_module_refs: list[str] = [] if is_guid_text(object_guid): with cache_connection() as conn: indexed_module_refs = [ str(row["module_ref"]) for row in conn.execute( """ SELECT module_ref FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND source_table IN ('Config', 'ConfigSave') AND (file_name=? OR file_name LIKE ?) ORDER BY module_ref """, ( cache_server_key(config), cache_database_name(config), object_guid, f"{object_guid}.%", ), ).fetchall() if row["module_ref"] ] if indexed_module_refs: owner = { "kind": object_card.get("kind") or current_kind, "name": object_card.get("name"), "synonym": object_card.get("synonym"), "guid": object_guid, } for module_ref in indexed_module_refs: metadata_module_owner_cache_upsert(config, module_ref, owner) modules_seen += len(indexed_module_refs) index_resolved_objects += 1 processed_objects.append( { "ref": object_selector_ref(owner.get("kind"), owner.get("name")), "modules": len(indexed_module_refs), "resolved_by": "code_index_guid_prefix", } ) continue if not deep: skipped_not_indexed_objects += 1 processed_objects.append( { "ref": object_selector_ref(object_card.get("kind") or current_kind, object_card.get("name")), "modules": 0, "resolved_by": "not_indexed_skipped", } ) continue selector = { "base_id": base_id, "kind": object_card.get("kind") or current_kind, "guid": object_card.get("guid"), "table": table, "include_storage": True, "refresh_cache": bool(refresh_cache), "timeout_seconds": int(timeout_seconds or 60), } modules_result = metadata_object_modules(selector) if modules_result.get("status") != "ok": errors.append( { "ref": object_selector_ref(object_card.get("kind") or current_kind, object_card.get("name")), "status": modules_result.get("status"), "error": modules_result.get("error"), } ) continue modules = [item for item in modules_result.get("modules") or [] if isinstance(item, dict)] modules_seen += len(modules) metadata_resolved_objects += 1 owner = modules_result.get("object") if isinstance(modules_result.get("object"), dict) else object_card processed_objects.append( { "ref": object_selector_ref(owner.get("kind") or current_kind, owner.get("name")), "modules": len(modules), "resolved_by": "metadata.object.modules", } ) # metadata.object.modules performs the owner-cache upsert. Count how many # existing code-index rows gained an owner during this bounded page. with cache_connection() as conn: after_entries = int( conn.execute( "SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] or 0 ) enriched_code_rows = int( conn.execute( """ SELECT COUNT(*) AS count FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND owner_kind IS NOT NULL AND owner_name IS NOT NULL AND owner_guid IS NOT NULL """, (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] or 0 ) page_counts = page.get("counts") if isinstance(page.get("counts"), dict) else {} total = int(page_counts.get("total_visible") or page_counts.get("total") or 0) next_offset = int(offset or 0) + len(objects) same_kind_has_more = bool(objects) and (next_offset < total if total else len(objects) >= int(limit or 50)) if same_kind_has_more: next_cursor = {"kind_index": current_kind_index, "kind": current_kind, "offset": next_offset} elif requested_kind or current_kind_index + 1 >= len(scoped_kinds): next_cursor = None else: next_cursor = {"kind_index": current_kind_index + 1, "kind": scoped_kinds[current_kind_index + 1], "offset": 0} complete = next_cursor is None return { "schema": "onec_module_owner_cache_backfill.v1", "method": method, "status": "complete" if complete else "ok", "base_id": base_id, "complete": complete, "scope": { "kind": current_kind, "table": table, "offset": int(offset or 0), "limit": int(limit or 50), "deep": bool(deep), }, "next_cursor": next_cursor, "counts": { "objects": len(objects), "modules": modules_seen, "cache_entries_before": before_entries, "cache_entries_after": after_entries, "cache_entries_added": max(0, after_entries - before_entries), "code_index_rows_with_complete_owner": enriched_code_rows, "objects_resolved_from_code_index": index_resolved_objects, "objects_resolved_from_metadata": metadata_resolved_objects, "objects_skipped_not_indexed": skipped_not_indexed_objects, "errors": len(errors), }, "objects": processed_objects, "errors": errors[:20], "freshness": { "source": "current_metadata", "authoritative_code_source": "live_sql", "writes_platform_sql": False, }, } def form_owner_module_refs(table: str, file_name: str) -> list[str]: if table not in STORAGE_TABLES or not file_name or Path(file_name).name != file_name: return [] base_ref = f"{table}:{file_name}" return [base_ref, f"{base_ref}#form_module"] def normalize_module_ref_for_form_owner(module_ref: str) -> str: table, file_name, _stream_index = parse_module_id(str(module_ref or "").strip()) if not table or not file_name: return str(module_ref or "").strip() return f"{table}:{file_name}" def metadata_form_owner_cache_upsert( config: dict[str, str] | None, *, base_id: str, owner_kind: str | None, form_name: str | None, table: str, file_name: str, extension: dict[str, Any] | None = None, owner_name: str | None = None, owner_guid: str | None = None, form_guid: str | None = None, bsl_offset: int | None = None, payload: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not config or table not in STORAGE_TABLES or not file_name or Path(file_name).name != file_name: return None kind = canonical_kind(str(owner_kind or "")) or str(owner_kind or "") or None name = str(form_name or owner_name or "").strip() if not name and not form_guid: return None extension = extension if isinstance(extension, dict) else {} normalized_extension_guid = str(extension.get("guid") or "").strip().lower() or None extension_name = str(extension.get("name") or "").strip() or None normalized_form_guid = str(form_guid or "").strip().lower() or None normalized_owner_guid = str(owner_guid or normalized_form_guid or "").strip().lower() or None primary_module_ref = f"{table}:{file_name}" form_key_parts = [normalized_extension_guid or normalize(extension_name or ""), kind or "", normalize(name) or normalized_form_guid or primary_module_ref] form_key = "|".join(form_key_parts) now = time.time() cache_payload = { "base_id": base_id, "extension": extension or None, "owner": {"kind": kind, "name": owner_name or name or None, "guid": normalized_owner_guid}, "form": {"name": name or None, "guid": normalized_form_guid, "kind": kind}, "source": {"table": table, "file_name": file_name}, "module_ref": primary_module_ref, "bsl_offset": bsl_offset, **(payload or {}), } with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_form_owner_cache ( server_key, database_name, form_key, extension_guid, extension_name, owner_kind, owner_name, owner_guid, form_name, form_guid, table_name, file_name, module_ref, bsl_offset, payload_json, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :form_key, :extension_guid, :extension_name, :owner_kind, :owner_name, :owner_guid, :form_name, :form_guid, :table_name, :file_name, :module_ref, :bsl_offset, :payload_json, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, form_key) DO UPDATE SET extension_guid=excluded.extension_guid, extension_name=excluded.extension_name, owner_kind=excluded.owner_kind, owner_name=excluded.owner_name, owner_guid=excluded.owner_guid, form_name=excluded.form_name, form_guid=excluded.form_guid, table_name=excluded.table_name, file_name=excluded.file_name, module_ref=excluded.module_ref, bsl_offset=excluded.bsl_offset, payload_json=excluded.payload_json, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "form_key": form_key, "extension_guid": normalized_extension_guid, "extension_name": extension_name, "owner_kind": kind, "owner_name": owner_name or name or None, "owner_guid": normalized_owner_guid, "form_name": name or None, "form_guid": normalized_form_guid, "table_name": table, "file_name": file_name, "module_ref": primary_module_ref, "bsl_offset": bsl_offset, "payload_json": json.dumps(cache_payload, ensure_ascii=False, sort_keys=True), "updated_at": now, "last_seen_at": now, }, ) return cache_payload def metadata_form_owner_cache_row_payload(row: dict[str, Any]) -> dict[str, Any]: payload: dict[str, Any] = {} try: loaded = json.loads(row.get("payload_json") or "{}") if isinstance(loaded, dict): payload = loaded except Exception: payload = {} table = row.get("table_name") file_name = row.get("file_name") module_ref = row.get("module_ref") or (f"{table}:{file_name}" if table and file_name else None) return { **payload, "extension": payload.get("extension") or {"guid": row.get("extension_guid"), "name": row.get("extension_name")}, "owner": payload.get("owner") or {"kind": row.get("owner_kind"), "name": row.get("owner_name"), "guid": row.get("owner_guid")}, "form": payload.get("form") or {"name": row.get("form_name"), "guid": row.get("form_guid"), "kind": row.get("owner_kind")}, "source": {"table": table, "file_name": file_name}, "module_ref": module_ref, "bsl_offset": row.get("bsl_offset"), } def metadata_form_owner_cache_lookup( config: dict[str, str] | None, *, owner_kind: str | None = None, form_name: str | None = None, extension: str | None = None, table: str | None = None, file_name: str | None = None, form_guid: str | None = None, module_ref: str | None = None, ) -> dict[str, Any] | None: if not config: return None clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if form_guid: clauses.append("form_guid=?") params.append(str(form_guid).strip().lower()) elif module_ref: normalized_ref = normalize_module_ref_for_form_owner(module_ref) clauses.append("module_ref=?") params.append(normalized_ref) elif table and file_name: clauses.extend(["table_name=?", "file_name=?"]) params.extend([table, file_name]) else: kind = canonical_kind(str(owner_kind or "")) or str(owner_kind or "") or None if kind: clauses.append("owner_kind=?") params.append(kind) normalized_form_name = normalize(form_name or "") if normalized_form_name: clauses.append("(form_name=? OR replace(form_name, ' ', '')=? OR lower(replace(form_name, ' ', ''))=?)") params.extend([str(form_name or ""), str(form_name or "").replace(" ", ""), normalized_form_name]) if extension: normalized_extension = normalize(extension) clauses.append("(extension_guid=? OR lower(extension_name)=? OR replace(lower(extension_name), ' ', '')=?)") params.extend([str(extension).lower(), str(extension).lower(), normalized_extension]) with cache_connection() as conn: try: row = conn.execute( f""" SELECT * FROM metadata_form_owner_cache WHERE {' AND '.join(clauses)} ORDER BY updated_at DESC LIMIT 1 """, params, ).fetchone() except sqlite3.OperationalError: return None if not row: return None return metadata_form_owner_cache_row_payload(dict(row)) def metadata_module_owner_cache_prune_for_owner(config: dict[str, str], owner_guid: str, module_refs: list[str]) -> None: normalized_owner_guid = str(owner_guid or "").lower() if not is_guid_text(normalized_owner_guid): return normalized_refs = sorted({str(module_ref or "").strip() for module_ref in module_refs if str(module_ref or "").strip()}) with cache_connection() as conn: if normalized_refs: placeholders = ",".join(["?"] * len(normalized_refs)) conn.execute( f""" DELETE FROM metadata_module_owner_cache WHERE server_key=? AND database_name=? AND owner_guid=? AND module_ref NOT IN ({placeholders}) """, ( cache_server_key(config), cache_database_name(config), normalized_owner_guid, *normalized_refs, ), ) else: conn.execute( """ DELETE FROM metadata_module_owner_cache WHERE server_key=:server_key AND database_name=:database_name AND owner_guid=:owner_guid """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "owner_guid": normalized_owner_guid, }, ) def metadata_type_cache_lookup_many(config: dict[str, str], type_guids: set[str]) -> dict[str, dict[str, Any]]: wanted = sorted({str(guid or "").lower() for guid in type_guids if is_guid_text(str(guid or ""))}) if not wanted: return {} result: dict[str, dict[str, Any]] = {} with cache_connection() as conn: for start in range(0, len(wanted), 500): chunk = wanted[start : start + 500] placeholders = ",".join(["?"] * len(chunk)) rows = conn.execute( f""" SELECT type_guid, payload_json FROM metadata_type_cache WHERE server_key = ? AND database_name = ? AND type_guid IN ({placeholders}) """, (cache_server_key(config), cache_database_name(config), *chunk), ).fetchall() for row in rows: try: payload = json.loads(row["payload_json"]) except Exception: continue if isinstance(payload, dict): result[str(row["type_guid"]).lower()] = payload return result def metadata_type_cache_upsert(config: dict[str, str], type_guid: str, resolved: dict[str, Any]) -> None: guid = str(type_guid or "").lower() if not is_guid_text(guid) or not isinstance(resolved, dict): return value_type = resolved.get("value_type") if isinstance(resolved.get("value_type"), dict) else {} presentation = resolved_type_presentation(resolved) if resolved.get("status") == "ok" else str(resolved.get("presentation") or "") metadata_guid_index_upsert( config, { "guid": guid, "guid_role": resolved.get("guid_role") or "metadata_type", "kind": resolved.get("kind"), "kind_ru": resolved.get("kind_ru"), "name": resolved.get("name"), "presentation": presentation, "owner_guid": resolved.get("owner_guid"), "type_guid": guid, "value_guid": resolved.get("value_guid"), "value_type_guid": value_type.get("type_guid"), "value_presentation": value_type.get("presentation"), "payload": resolved, "source_file": resolved.get("owner_guid"), }, ) now = time.time() payload_json = json.dumps(resolved, ensure_ascii=False, sort_keys=True) with cache_connection() as conn: conn.execute( """ INSERT INTO metadata_type_cache ( server_key, database_name, type_guid, status, presentation, kind, kind_ru, name, owner_guid, generated_category, payload_json, updated_at, last_seen_at ) VALUES ( :server_key, :database_name, :type_guid, :status, :presentation, :kind, :kind_ru, :name, :owner_guid, :generated_category, :payload_json, :updated_at, :last_seen_at ) ON CONFLICT(server_key, database_name, type_guid) DO UPDATE SET status=excluded.status, presentation=excluded.presentation, kind=excluded.kind, kind_ru=excluded.kind_ru, name=excluded.name, owner_guid=excluded.owner_guid, generated_category=excluded.generated_category, payload_json=excluded.payload_json, updated_at=excluded.updated_at, last_seen_at=excluded.last_seen_at """, { "server_key": cache_server_key(config), "database_name": cache_database_name(config), "type_guid": guid, "status": str(resolved.get("status") or ""), "presentation": resolved_type_presentation(resolved) if resolved.get("status") == "ok" else str(resolved.get("presentation") or ""), "kind": resolved.get("kind"), "kind_ru": resolved.get("kind_ru"), "name": resolved.get("name"), "owner_guid": resolved.get("owner_guid"), "generated_category": resolved.get("generated_category"), "payload_json": payload_json, "updated_at": now, "last_seen_at": now, }, ) def metadata_guid_index_migrate_legacy_types(config: dict[str, str]) -> int: migrated = 0 with cache_connection() as conn: rows = conn.execute( """ SELECT type_guid, payload_json FROM metadata_type_cache WHERE server_key = ? AND database_name = ? """, (cache_server_key(config), cache_database_name(config)), ).fetchall() for row in rows: try: payload = json.loads(row["payload_json"] or "{}") except Exception: continue if not isinstance(payload, dict): continue metadata_type_cache_upsert(config, str(row["type_guid"] or ""), payload) migrated += 1 return migrated def metadata_cache_status(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.cache.status") if isinstance(base_id_or_error, dict): return base_id_or_error _, include_samples_error = strict_bool_argument(payload, "include_samples", method="metadata.cache.status", default=False) if include_samples_error: return include_samples_error base_ids = [base_id_or_error] with cache_connection() as conn: rows = [] for base_id in base_ids: config, config_error = sql_config_for_base(base_id) if not config: rows.append({"base_id": base_id, "status": "source_missing", "diagnostics": config_error}) continue count = conn.execute( "SELECT COUNT(*) AS count FROM metadata_identity_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] type_count = conn.execute( "SELECT COUNT(*) AS count FROM metadata_type_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] guid_count = conn.execute( "SELECT COUNT(*) AS count FROM metadata_guid_index WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] owner_map_count = conn.execute( "SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] extension_route_count = conn.execute( "SELECT COUNT(*) AS count FROM extension_route_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] extension_route_stale_count = conn.execute( "SELECT COUNT(*) AS count FROM extension_route_cache WHERE server_key=? AND database_name=? AND freshness_status='stale'", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] semantic_document_count = conn.execute( "SELECT COUNT(*) AS count FROM semantic_document_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] decoded_artifact_count = conn.execute( "SELECT COUNT(*) AS count FROM decoded_artifact_cache WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] rows.append( { "base_id": base_id, "server": config["server"], "database": config["database"], "cached_objects": int(count), "cached_types": int(type_count), "guid_index_entries": int(guid_count), "module_owner_cache_entries": int(owner_map_count), "extension_route_cache_entries": int(extension_route_count), "extension_route_cache_stale_entries": int(extension_route_stale_count), "semantic_document_cache_entries": int(semantic_document_count), "decoded_artifact_cache_entries": int(decoded_artifact_count), } ) return {"schema": "onec_metadata_cache_status.v1", "status": "ok", "caches": rows, "counts": {"bases": len(rows)}} def metadata_cache_lookup(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.cache.lookup" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("guid", "kind", "name"): if argument not in payload: continue value = payload.get(argument) if value is None or value == "": return invalid_argument("metadata.cache.lookup", argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("metadata.cache.lookup", argument, f"{argument} must be a JSON string.") guid = str(payload.get("guid") or "").strip() if guid: obj = metadata_cache_lookup_guid(base_id_or_error, guid) else: row = metadata_cache_lookup_row(base_id_or_error, str(payload.get("kind") or ""), str(payload.get("name") or "")) obj = metadata_cache_public_row(row) if row else None return { "schema": "onec_metadata_cache_lookup.v1", "status": "ok" if obj else "not_found", **({"error": "not_found"} if not obj else {}), "base_id": base_id_or_error, "object": obj, } def metadata_cache_invalidate(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.cache.invalidate") if isinstance(base_id_or_error, dict): return base_id_or_error dry_run, dry_run_error = strict_bool_argument(payload, "dry_run", method="metadata.cache.invalidate", default=False) if dry_run_error: return dry_run_error preserve_code_index = bool(payload.get("_preserve_code_index")) base_ids = [base_id_or_error] deleted = 0 full_invalidated_configs: list[dict[str, str]] = [] with cache_connection() as conn: for base_id in base_ids: config, _ = sql_config_for_base(base_id) if not config: continue cache_tables = ( "metadata_identity_cache", "metadata_type_cache", "metadata_guid_index", "metadata_module_owner_cache", "metadata_form_owner_cache", "extension_route_cache", "semantic_document_cache", "decoded_artifact_cache", "metadata_code_index_cache", "metadata_code_vector_cache", "metadata_code_embedding_cache", "metadata_code_index_state", "metadata_code_index_outbox", "metadata_code_overlay_tombstone", ) if preserve_code_index: cache_tables = tuple( table_name for table_name in cache_tables if table_name not in { "metadata_code_index_cache", "metadata_code_vector_cache", "metadata_code_embedding_cache", "metadata_code_index_state", "metadata_code_index_outbox", "metadata_code_overlay_tombstone", } ) if dry_run: for table_name in cache_tables: deleted += int( conn.execute( f"SELECT COUNT(*) AS count FROM {table_name} WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ).fetchone()["count"] ) continue for table_name in cache_tables: cursor = conn.execute( f"DELETE FROM {table_name} WHERE server_key=? AND database_name=?", (cache_server_key(config), cache_database_name(config)), ) deleted += int(cursor.rowcount or 0) if not preserve_code_index: full_invalidated_configs.append(config) for config in full_invalidated_configs: for source_table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): code_index_mark_dirty( config, reason="manual_cache_invalidate", source_table=source_table, event_kind="full_rebuild_required", ) return {"schema": "onec_metadata_cache_invalidate.v1", "status": "ok", "dry_run": bool(dry_run), "counts": {"bases": len(base_ids), "deleted": deleted}} def invalidate_adapter_caches_after_saved_state_change( base_id: str, *, reason: str, source_table: str | None = None, file_names: list[str] | None = None, sync_code_index: bool = True, ) -> dict[str, Any]: """Invalidate persistent and process-local views after a committed saved-state mutation.""" normalized_base_id = str(base_id or "").strip() persistent: dict[str, Any] try: persistent = metadata_cache_invalidate( { "base_id": normalized_base_id, "dry_run": False, "_preserve_code_index": True, } ) except Exception as exc: persistent = {"status": "error", "diagnostics": {"message": str(exc)}} config, _config_error = sql_config_for_base(normalized_base_id) index_state = ( code_index_mark_dirty( config, reason=reason, source_table=source_table, file_names=file_names, ) if config else {"status": "source_missing"} ) code_index_sync: dict[str, Any] | None = None if config and sync_code_index and source_table and file_names and len(file_names) <= 20: try: code_index_sync = metadata_code_index_sync_pending( { "base_id": normalized_base_id, "limit": len(file_names), "timeout_seconds": 30, } ) sync_freshness = code_index_sync.get("freshness") if isinstance(code_index_sync.get("freshness"), dict) else {} if isinstance(sync_freshness.get("index_state"), dict): index_state = sync_freshness["index_state"] except Exception as exc: code_index_sync = {"status": "error", "diagnostics": {"message": str(exc)}} runtime_counts = { "base_root_metadata": 0, "data_schema": 0, "extension_manifests": 0, } with BASE_ROOT_METADATA_CACHE_LOCK: root_keys = [ key for key in BASE_ROOT_METADATA_CACHE if isinstance(key, tuple) and key and str(key[0]).strip() == normalized_base_id ] for key in root_keys: BASE_ROOT_METADATA_CACHE.pop(key, None) runtime_counts["base_root_metadata"] = len(root_keys) normalized_casefold = normalized_base_id.casefold() with DATA_SCHEMA_CACHE_LOCK: schema_keys = [] for key in DATA_SCHEMA_CACHE: try: cached_selector = json.loads(key) except (TypeError, ValueError): continue if str(cached_selector.get("base_id") or "").casefold() == normalized_casefold: schema_keys.append(key) for key in schema_keys: DATA_SCHEMA_CACHE.pop(key, None) runtime_counts["data_schema"] = len(schema_keys) manifest_cache = globals().get("EXTENSION_MANIFEST_CACHE") manifest_lock = globals().get("EXTENSION_MANIFEST_CACHE_LOCK") if isinstance(manifest_cache, dict): def clear_manifest_entries() -> int: manifest_keys = [ key for key in manifest_cache if isinstance(key, tuple) and key and str(key[0]).strip() == normalized_base_id ] for key in manifest_keys: manifest_cache.pop(key, None) return len(manifest_keys) if manifest_lock is not None: with manifest_lock: runtime_counts["extension_manifests"] = clear_manifest_entries() else: runtime_counts["extension_manifests"] = clear_manifest_entries() persistent_status = str(persistent.get("status") or "error") return { "schema": "onec_saved_state_cache_invalidation.v1", "status": "ok" if persistent_status == "ok" else "partial", "base_id": normalized_base_id, "reason": reason, "persistent": { "status": persistent_status, "deleted": int(((persistent.get("counts") or {}).get("deleted") or 0)), }, "code_index": index_state, **({"code_index_sync": code_index_sync} if code_index_sync is not None else {}), "runtime": runtime_counts, **( {"diagnostics": persistent.get("diagnostics")} if persistent_status != "ok" and persistent.get("diagnostics") else {} ), } def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.module_owner_cache.prune" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload validation_error = validate_metadata_module_owner_cache_prune_payload(payload) if validation_error: return validation_error base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, _ = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) include_storage, _ = strict_include_storage(payload, method) owner_guid = str(payload.get("owner_guid") or "").strip().lower() public_selector_used = not owner_guid and has_object_selector(payload) owner_resolution: dict[str, Any] | None = None if public_selector_used: resolved_owner_guid, owner_resolution = metadata_saved_state_modules_owner_guid_from_selector( base_id, payload, timeout_seconds=int(timeout_seconds or 30), ) owner_guid = str(resolved_owner_guid or "").strip().lower() if not is_guid_text(owner_guid): status = str((owner_resolution or {}).get("status") or "not_found") return { "schema": "onec_module_owner_cache_prune.v1", "method": method, "status": status if status in {"not_found", "ambiguous"} else "not_found", "error": "owner_not_resolved", "base_id": base_id, "selector": { **({"ref": payload.get("ref")} if payload.get("ref") else {}), **({"kind": payload.get("kind")} if payload.get("kind") else {}), **({"name": payload.get("name")} if payload.get("name") else {}), }, "owner_resolution": owner_resolution, "diagnostics": (owner_resolution or {}).get("diagnostics") or {"message": "The named 1C module owner was not resolved."}, } module_ref = str(payload.get("module_ref") or "").strip() module_refs_raw = payload.get("module_refs") module_refs = [] if isinstance(module_refs_raw, (list, tuple)): for index, item in enumerate(module_refs_raw): if not isinstance(item, str): return invalid_argument( "metadata.module_owner_cache.prune", "module_refs", f"module_refs[{index}] must be a JSON string.", ) item = item.strip() if item: module_refs.append(item) module_refs = sorted({ref for ref in module_refs if ref}) elif module_refs_raw is not None: return invalid_argument("metadata.module_owner_cache.prune", "module_refs", "module_refs must be a JSON array of strings.") dry_run, dry_run_error = strict_bool_argument(payload, "dry_run", method="metadata.module_owner_cache.prune", default=False) if dry_run_error: return dry_run_error config, config_error = sql_config_for_base(base_id) if not config: return { "schema": "onec_module_owner_cache_prune.v1", "status": "error", "base_id": base_id, "error": "source_missing", "diagnostics": config_error or {"message": "SQL source is not configured for this base_id."}, } where_parts = ["server_key = ?", "database_name = ?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] if owner_guid: where_parts.append("owner_guid = ?") params.append(owner_guid) if module_ref: where_parts.append("module_ref = ?") params.append(module_ref) if module_refs: placeholders = ",".join(["?"] * len(module_refs)) where_parts.append(f"module_ref IN ({placeholders})") params.extend(module_refs) where = " AND ".join(where_parts) query = f"SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE {where}" public_selector = { **({"ref": payload.get("ref")} if payload.get("ref") else {}), **({"kind": payload.get("kind")} if payload.get("kind") else {}), **({"name": payload.get("name")} if payload.get("name") else {}), } resolved_owner = (owner_resolution or {}).get("object") if isinstance((owner_resolution or {}).get("object"), dict) else {} public_owner = { **({"kind": resolved_owner.get("kind")} if resolved_owner.get("kind") else {}), **({"name": resolved_owner.get("name")} if resolved_owner.get("name") else {}), **({"synonym": resolved_owner.get("synonym")} if resolved_owner.get("synonym") else {}), **( { "ref": resolved_owner.get("ref") or object_selector_ref(resolved_owner.get("kind"), resolved_owner.get("name")) } if resolved_owner.get("ref") or (resolved_owner.get("kind") and resolved_owner.get("name")) else {} ), } public_query = ( { "owner": public_owner or public_selector, **({"module_ref": module_ref} if module_ref else {}), **({"module_refs": module_refs} if module_refs else {}), **({"storage": {"owner_guid": owner_guid}} if include_storage else {}), } if public_selector_used else {"owner_guid": owner_guid or None, "module_ref": module_ref or None, "module_refs": module_refs or None} ) with cache_connection() as conn: matched = int(conn.execute(query, params).fetchone()["count"]) if bool(dry_run): return { "schema": "onec_module_owner_cache_prune.v1", "status": "ok", "base_id": base_id, "dry_run": True, "counts": {"matched": matched}, "query": public_query, **({"resolved_by": "public_object_selector"} if public_selector_used else {}), } cursor = conn.execute(f"DELETE FROM metadata_module_owner_cache WHERE {where}", params) deleted = int(cursor.rowcount or 0) return { "schema": "onec_module_owner_cache_prune.v1", "status": "ok", "base_id": base_id, "dry_run": False, "counts": {"matched": matched, "deleted": deleted}, "query": public_query, **({"resolved_by": "public_object_selector"} if public_selector_used else {}), } def metadata_cache_rebuild_base(base_id: str, *, table: str = "Config", timeout_seconds: int = 240, batch_size: int = 100) -> dict[str, Any]: storage_table = str(table or "Config") if storage_table not in STORAGE_TABLES: return { "base_id": base_id, "status": "error", "counts": {"candidates": 0, "updated": 0, "failed": 0, "type_updated": 0, "migrated_types": 0, "defined_type_files": 0}, "diagnostics": {"message": f"Unsupported storage table '{storage_table}'."}, } config, config_error = sql_config_for_base(base_id) if not config: return {"base_id": base_id, "status": "source_missing", "diagnostics": config_error} records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: return {"base_id": base_id, "status": "error", "diagnostics": error.get("diagnostics") or error} migrated_types = metadata_guid_index_migrate_legacy_types(config) candidates: dict[str, dict[str, Any]] = {} for record in records or []: kind = DBNAMES_ROLE_KIND.get(getattr(record, "storage_role", "")) guid = str(getattr(record, "guid", "") or "").lower() if not kind or not guid: continue candidates.setdefault( guid, { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": PUBLIC_KIND.get(kind, "other"), "name": None, "synonym": None, "source": "base", }, ) # DBNames contains storage-backed objects, but several first-class metadata # kinds (for example SettingsStorage and Subsystem) are fully enumerated only # by the configuration root descriptor. Cache rebuilding must use the same # merged discovery set as metadata.objects.list; otherwise a valid but partial # cache shadows objects that are visible through live root discovery. dbnames_guids = set(candidates) root_priority_guids: set[str] = set() if storage_table in {"Config", "ConfigSave"}: root_rows, _root_diagnostics = live_base_root_metadata_index(base_id, table=storage_table, timeout_seconds=timeout_seconds) merge_root_metadata_candidates(candidates, root_rows, wanted_kind=None, requested_public=None) root_priority_guids = { str(row.get("guid") or "").lower() for row in root_rows if row.get("guid") and str(row.get("guid") or "").lower() not in dbnames_guids } # Root-only kinds must become usable even when a very large DBNames/type # refresh reaches its job deadline later in the rebuild. items = sorted( candidates.values(), key=lambda row: ( 0 if str(row.get("guid") or "").lower() in root_priority_guids else 1, str(row.get("kind") or ""), str(row.get("guid") or ""), ), ) updated = 0 type_updated = 0 failed = 0 started = time.time() for start in range(0, len(items), max(1, batch_size)): chunk = items[start : start + batch_size] payloads, _, read_error = read_storage_files_bytes(base_id, storage_table, [row["guid"] for row in chunk], timeout_seconds=timeout_seconds) if read_error: failed += len(chunk) continue for row in chunk: identity = config_identity_from_bytes(payloads[row["guid"]]) if payloads and row["guid"] in payloads else None if not identity: failed += 1 continue row["name"] = identity.get("name") synonyms = identity.get("synonyms") or {} row["synonym"] = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None row["identity"] = identity metadata_cache_upsert(config, row) for generated in generated_type_records_from_bytes(payloads[row["guid"]], kind=str(row.get("kind") or ""), guid=str(row.get("guid") or "")): type_guid = str(generated.get("type_guid") or "").lower() if not type_guid: continue resolved = resolved_type_from_generated(base_id, type_guid, generated, timeout_seconds=timeout_seconds) metadata_type_cache_upsert(config, type_guid, resolved) type_updated += 1 updated += 1 last_file_name = "" seen_defined_files = 0 while True: page = live_config_file_name_page_after( base_id, last_file_name, page_size=max(1, min(batch_size * 10, 5000)), timeout_seconds=timeout_seconds, table=storage_table, ) if not page: break last_file_name = page[-1] payloads, _, read_error = read_storage_files_bytes(base_id, storage_table, page, timeout_seconds=timeout_seconds) if read_error: continue for guid, data in (payloads or {}).items(): generated_records = generated_type_records_from_bytes(data, kind="DefinedType", guid=guid) if not generated_records: continue seen_defined_files += 1 for generated in generated_records: type_guid = str(generated.get("type_guid") or "").lower() if not type_guid: continue resolved = resolved_type_from_generated(base_id, type_guid, generated, timeout_seconds=timeout_seconds) metadata_type_cache_upsert(config, type_guid, resolved) type_updated += 1 return { "base_id": base_id, "status": "ok", "server": config["server"], "database": config["database"], "counts": {"candidates": len(items), "updated": updated, "failed": failed, "type_updated": type_updated, "migrated_types": migrated_types, "defined_type_files": seen_defined_files}, "duration_ms": int((time.time() - started) * 1000), } def metadata_cache_rebuild(payload: dict[str, Any]) -> dict[str, Any]: validation_error = validate_metadata_cache_rebuild_payload(payload) if validation_error: return validation_error return adapter_start_job({"method": "metadata.cache.rebuild", "payload": payload}) def validate_metadata_cache_rebuild_payload(payload: dict[str, Any]) -> dict[str, Any] | None: if "_run_sync" in payload: return invalid_argument("metadata.cache.rebuild", "_run_sync", "_run_sync is an internal adapter flag and is not accepted by the public API.") base_id_or_error = require_base_id(payload, "metadata.cache.rebuild") if isinstance(base_id_or_error, dict): return base_id_or_error _, refresh_error = strict_bool_argument(payload, "refresh", method="metadata.cache.rebuild", default=True) if refresh_error: return refresh_error _, timeout_seconds_error = parse_int_argument(payload, "timeout_seconds", method="metadata.cache.rebuild", default=240, minimum=1) if timeout_seconds_error: return timeout_seconds_error _, batch_size_error = parse_int_argument(payload, "batch_size", method="metadata.cache.rebuild", default=100, minimum=1, maximum=5000) if batch_size_error: return batch_size_error table_error = metadata_storage_table(payload, "metadata.cache.rebuild") if isinstance(table_error, dict): return table_error return None def adapter_cache_rebuild_partial_result(payload: dict[str, Any]) -> dict[str, Any]: return { "schema": "onec_metadata_cache_rebuild.v1", "status": "partial", "base_id": payload.get("base_id"), "query": { "base_id": payload.get("base_id"), "batch_size": payload.get("batch_size") or 100, "timeout_seconds": payload.get("timeout_seconds") or 240, "table": payload.get("table") or "Config", }, "sections": { "dbnames": "pending", "objects": "pending", "defined_types": "pending", }, "results": [], "counts": { "bases": 1, "ok_bases": 0, "failed_bases": 0, "candidate_objects": 0, "updated": 0, "type_updated": 0, "failed_objects": 0, "failed": 0, "defined_type_files": 0, }, "failed_sections": [], "diagnostics": [], } def adapter_update_cache_rebuild_job( job_id: str, partial: dict[str, Any], current_step: str, completed: int, total: int | None, *, started_at: float, running_steps: list[str] | None = None, queued_steps: list[str] | None = None, done_steps: list[str] | None = None, failed_steps: list[str] | None = None, ) -> None: now = adapter_now() elapsed = round(max(0.0, now - started_at), 3) partial["elapsed_seconds"] = elapsed progress: dict[str, Any] = { "current_step": current_step, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, "elapsed_seconds": elapsed, } if running_steps is not None: progress["running_steps"] = running_steps if queued_steps is not None: progress["queued_steps"] = queued_steps if done_steps is not None: progress["done_steps"] = done_steps if failed_steps is not None: progress["failed_steps"] = failed_steps adapter_job_set(job_id, partial_result=partial, current_step=current_step, progress=progress) def adapter_run_metadata_cache_rebuild_job(job_id: str, payload: dict[str, Any], timeout_seconds: float) -> None: partial = adapter_cache_rebuild_partial_result(payload) started_at = adapter_now() base_id = str(payload.get("base_id") or "") table_or_error = metadata_storage_table(payload, "metadata.cache.rebuild") if isinstance(table_or_error, dict): adapter_job_finish(job_id, "error", result=adapter_public_error("metadata.cache.rebuild", "invalid_argument", table_or_error)) return table = table_or_error partial["query"]["table"] = table batch_size = int(payload.get("batch_size") or 100) timeout_value = adapter_timeout_payload_value(float(payload.get("timeout_seconds") or timeout_seconds or 240)) queued_steps = ["dbnames", "objects", "defined_types"] done_steps: list[str] = [] failed_steps: list[str] = [] adapter_update_cache_rebuild_job(job_id, partial, "starting", 0, None, started_at=started_at, running_steps=[], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) try: config, config_error = sql_config_for_base(base_id) if not config: partial["status"] = "error" partial["failed_sections"].append({"section": "dbnames", "status": "source_missing", "diagnostics": config_error}) partial["counts"]["failed_bases"] = 1 partial["counts"]["failed"] = 1 adapter_job_finish(job_id, "done", result=partial, progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}) return queued_steps.remove("dbnames") partial["sections"]["dbnames"] = "running" adapter_update_cache_rebuild_job(job_id, partial, "dbnames", 0, None, started_at=started_at, running_steps=["dbnames"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) records, error = live_dbnames_records(base_id, timeout_seconds=timeout_value) if error: partial["status"] = "error" partial["sections"]["dbnames"] = "failed" partial["failed_sections"].append({"section": "dbnames", "status": "error", "diagnostics": error.get("diagnostics") or error}) partial["counts"]["failed_bases"] = 1 partial["counts"]["failed"] = 1 adapter_job_finish(job_id, "done", result=partial, progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}) return migrated_types = metadata_guid_index_migrate_legacy_types(config) partial["sections"]["dbnames"] = "ok" done_steps.append("dbnames") candidates: dict[str, dict[str, Any]] = {} for record in records or []: kind = DBNAMES_ROLE_KIND.get(getattr(record, "storage_role", "")) guid = str(getattr(record, "guid", "") or "").lower() if not kind or not guid: continue candidates.setdefault( guid, { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": PUBLIC_KIND.get(kind, "other"), "name": None, "synonym": None, "source": "base", }, ) items = list(candidates.values()) partial["counts"]["candidate_objects"] = len(items) object_chunks = max(1, math.ceil(len(items) / max(1, batch_size))) total_steps = 2 + object_chunks queued_steps.remove("objects") partial["sections"]["objects"] = "running" completed = 1 updated = 0 failed = 0 type_updated = 0 adapter_update_cache_rebuild_job(job_id, partial, "objects", completed, total_steps, started_at=started_at, running_steps=["objects"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) for start in range(0, len(items), max(1, batch_size)): if adapter_job_cancel_requested(job_id): partial["status"] = "cancelled" adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial) return chunk = items[start : start + batch_size] payloads, _, read_error = read_storage_files_bytes(base_id, table, [row["guid"] for row in chunk], timeout_seconds=timeout_value) if read_error: failed += len(chunk) else: for row in chunk: data = payloads[row["guid"]] if payloads and row["guid"] in payloads else None identity = config_identity_from_bytes(data) if data else None if not identity: failed += 1 continue row["name"] = identity.get("name") synonyms = identity.get("synonyms") or {} row["synonym"] = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None row["identity"] = identity metadata_cache_upsert(config, row) for generated in generated_type_records_from_bytes(data, kind=str(row.get("kind") or ""), guid=str(row.get("guid") or "")): type_guid = str(generated.get("type_guid") or "").lower() if not type_guid: continue resolved = resolved_type_from_generated(base_id, type_guid, generated, timeout_seconds=timeout_value) metadata_type_cache_upsert(config, type_guid, resolved) type_updated += 1 updated += 1 completed += 1 partial["counts"].update({"updated": updated, "failed_objects": failed, "type_updated": type_updated}) adapter_update_cache_rebuild_job(job_id, partial, f"objects:{min(start + len(chunk), len(items))}/{len(items)}", completed, total_steps, started_at=started_at, running_steps=["objects"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) partial["sections"]["objects"] = "ok" done_steps.append("objects") queued_steps.remove("defined_types") partial["sections"]["defined_types"] = "running" adapter_update_cache_rebuild_job(job_id, partial, "defined_types", completed, total_steps, started_at=started_at, running_steps=["defined_types"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) last_file_name = "" seen_defined_files = 0 while True: if adapter_job_cancel_requested(job_id): partial["status"] = "cancelled" adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial) return page = live_config_file_name_page_after( base_id, last_file_name, page_size=max(1, min(batch_size * 10, 5000)), timeout_seconds=timeout_value, table=table, ) if not page: break last_file_name = page[-1] payloads, _, read_error = read_storage_files_bytes(base_id, table, page, timeout_seconds=timeout_value) if read_error: continue for guid, data in (payloads or {}).items(): generated_records = generated_type_records_from_bytes(data, kind="DefinedType", guid=guid) if not generated_records: continue seen_defined_files += 1 for generated in generated_records: type_guid = str(generated.get("type_guid") or "").lower() if not type_guid: continue resolved = resolved_type_from_generated(base_id, type_guid, generated, timeout_seconds=timeout_value) metadata_type_cache_upsert(config, type_guid, resolved) type_updated += 1 partial["counts"].update({"type_updated": type_updated, "defined_type_files": seen_defined_files}) adapter_update_cache_rebuild_job(job_id, partial, f"defined_types:{last_file_name}", completed, total_steps, started_at=started_at, running_steps=["defined_types"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps) partial["sections"]["defined_types"] = "ok" done_steps.append("defined_types") completed = total_steps partial["status"] = "ok" if failed == 0 else "partial" partial["counts"].update( { "ok_bases": 1, "failed_bases": 0, "candidate_objects": len(items), "updated": updated, "type_updated": type_updated, "failed_objects": failed, "failed": failed, "defined_type_files": seen_defined_files, } ) partial["results"] = [ { "base_id": base_id, "status": partial["status"], "server": config["server"], "database": config["database"], "counts": dict(partial["counts"]), "duration_ms": int((adapter_now() - started_at) * 1000), } ] adapter_update_cache_rebuild_job(job_id, partial, "done", completed, total_steps, started_at=started_at, running_steps=[], queued_steps=[], done_steps=done_steps, failed_steps=failed_steps) adapter_job_finish(job_id, "done", result=partial, partial_result=partial, progress={"current_step": "done", "completed_steps": completed, "total_steps": total_steps, "percent": 100, "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial.get("elapsed_seconds")}) except Exception as exc: adapter_job_finish(job_id, "error", **adapter_public_error("metadata.cache.rebuild", "adapter_job_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)})) def import_pymssql(): try: import pymssql # type: ignore except Exception as exc: return None, { "schema": "onec_adapter_error.v1", "status": "error", "source": {"kind": "live_sql", "status": "driver_unavailable"}, "diagnostics": {"message": str(exc)}, } return pymssql, None def connect_live_sql(base_id: str, method: str, *, timeout_seconds: int = 30): config, config_error = sql_config_for_base(base_id) if not config: return None, None, live_source_unavailable(method, base_id, config_error) pymssql, import_error = import_pymssql() if not pymssql: error = dict(import_error or {}) error.update({"method": method, "base_id": base_id}) return None, None, error try: conn = pymssql.connect( server=config["server"], user=config["user"], password=config["password"], database=config["database"], login_timeout=min(timeout_seconds, 15), timeout=timeout_seconds, ) except Exception as exc: return None, config, { "schema": "onec_adapter_source_error.v1", "method": method, "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"]}, "diagnostics": {"message": str(exc)}, } return conn, config, None def require_base_id(payload: dict[str, Any], method: str) -> str | dict[str, Any]: if not payload.get("base_id"): return base_id_required(method) if not isinstance(payload.get("base_id"), str): return invalid_argument(method, "base_id", "base_id must be a JSON string.") return str(payload.get("base_id")) def schema_tables_list(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "schema.tables.list") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error include_columns, include_columns_error = strict_bool_argument(payload, "include_columns", method="schema.tables.list", default=False) if include_columns_error: return include_columns_error limit, limit_error = parse_int_argument(payload, "limit", method="schema.tables.list", default=500, minimum=1, maximum=5000) if limit_error: return limit_error like_error = validate_optional_non_empty_string_arguments(payload, "schema.tables.list", ["like"]) if like_error: return like_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="schema.tables.list", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "schema.tables.list") if diagnostic_error: return diagnostic_error like = str(payload.get("like") or "%") conn, config, error = connect_live_sql(base_id, "schema.tables.list", timeout_seconds=int(timeout_seconds or 30)) if error: return error rows = [] started = time.time() try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( """ SELECT TOP (%d) TABLE_SCHEMA AS [schema_name], TABLE_NAME AS [table_name], TABLE_TYPE AS [table_type] FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE %%s ORDER BY TABLE_SCHEMA, TABLE_NAME """ % limit, (like,), ) rows = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] except Exception as exc: return { "schema": "onec_schema_tables.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database")}, "diagnostics": {"message": str(exc)}, "tables": [], "counts": {"tables": 0}, } return { "schema": "onec_schema_tables.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"]}, "query": {"like": like, "limit": limit, "include_columns": bool(include_columns)}, "tables": rows, "counts": {"tables": len(rows)}, "duration_ms": int((time.time() - started) * 1000), } def storage_table(payload: dict[str, Any], method: str) -> str | dict[str, Any]: if "table" in payload and payload.get("table") is not None and not isinstance(payload.get("table"), str): return invalid_argument(method, "table", "table must be a JSON string.") table = str(payload.get("table") or "") if table not in STORAGE_TABLES: return { "schema": "onec_adapter_request_error.v1", "method": method, "status": "invalid_argument", "error": "invalid_argument", "argument": "table", "supported_tables": sorted(STORAGE_TABLES), } return table def metadata_storage_table(payload: dict[str, Any], method: str, default_table: str = "Config") -> str | dict[str, Any]: normalized_payload = dict(payload) if normalized_payload.get("table") is None: normalized_payload["table"] = default_table return storage_table(normalized_payload, method) def storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "storage.files.list") if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, "storage.files.list") if isinstance(table_or_error, dict): return table_or_error base_id = base_id_or_error table = table_or_error limit, limit_error = parse_int_argument(payload, "limit", method="storage.files.list", default=200, minimum=1, maximum=5000) if limit_error: return limit_error prefix_error = validate_optional_non_empty_string_arguments( payload, "storage.files.list", ["prefix", "exact_file_name"], ) if prefix_error: return prefix_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="storage.files.list", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "storage.files.list") if diagnostic_error: return diagnostic_error prefix = str(payload.get("prefix") or "") exact_file_name = str(payload.get("exact_file_name") or "") like = f"{prefix}%" if prefix else "%" conn, config, error = connect_live_sql(base_id, "storage.files.list", timeout_seconds=int(timeout_seconds or 30)) if error: return error rows = [] started = time.time() try: with conn: with conn.cursor(as_dict=True) as cursor: if exact_file_name: cursor.execute( f""" SELECT TOP ({limit}) FileName, COUNT(*) AS PartCount, SUM(DATALENGTH(BinaryData)) AS Bytes FROM dbo.[{table}] WHERE FileName = %s GROUP BY FileName ORDER BY FileName """, (exact_file_name,), ) else: cursor.execute( f""" SELECT TOP ({limit}) FileName, COUNT(*) AS PartCount, SUM(DATALENGTH(BinaryData)) AS Bytes FROM dbo.[{table}] WHERE FileName LIKE %s GROUP BY FileName ORDER BY FileName """, (like,), ) rows = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] except Exception as exc: return { "schema": "onec_storage_files.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table}, "diagnostics": {"message": str(exc)}, "files": [], "counts": {"files": 0}, } if payload.get("_internal") is not True and table in {"ConfigCAS", "ConfigCASSave"}: active_guids = set(extension_map_by_guid(base_id)) if table == "ConfigCASSave": rows = [ row for row in rows if str(row.get("FileName") or "").split("__", 1)[0].strip().lower() in active_guids ] else: manifests, _diagnostics = live_extension_manifests(base_id, timeout_seconds=int(timeout_seconds or 30)) active_keys = { str(manifest.get("root_cas_key") or "").lower() for manifest in manifests if isinstance(manifest, dict) } active_keys.update( str(entry.get("cas_key") or "").lower() for manifest in manifests if isinstance(manifest, dict) for entry in (manifest.get("entries") or []) if isinstance(entry, dict) ) rows = [row for row in rows if str(row.get("FileName") or "").lower() in active_keys] return { "schema": "onec_storage_files.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table}, "query": { "prefix": prefix, "exact_file_name": exact_file_name or None, "limit": limit, }, "files": rows, "counts": {"files": len(rows)}, "duration_ms": int((time.time() - started) * 1000), } def read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30) -> tuple[bytes | None, dict[str, str] | None, dict[str, Any] | None]: conn, config, error = connect_live_sql(base_id, "storage.file.get", timeout_seconds=timeout_seconds) if error: return None, config, error parts: list[bytes] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute(f"SELECT BinaryData FROM dbo.[{table}] WHERE FileName = %s ORDER BY PartNo", (file_name,)) for row in cursor.fetchall(): value = row.get("BinaryData") if isinstance(value, (bytes, bytearray)): parts.append(bytes(value)) except Exception as exc: return None, config, { "schema": "onec_adapter_source_error.v1", "method": "storage.file.get", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table}, "diagnostics": {"message": str(exc)}, } if not parts: return None, config, { "schema": "onec_adapter_source_missing.v1", "method": "storage.file.get", "status": "source_missing", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table, "file_name": file_name}, "diagnostics": {"message": "FileName was not found in the requested live SQL storage table."}, } return b"".join(parts), config, None BASE_SUPPORT_CONFIGURATION_ID_PATH = (3, 1, 1, 1, 1, 1, 2) def discover_base_support_source( base_id: str, *, timeout_seconds: int = 30, ) -> tuple[dict[str, str] | None, dict[str, Any] | None]: """Resolve the base configuration ParentConfigurations payload from live SQL.""" pointer_data, config, pointer_error = read_storage_file_bytes( base_id, "Config", "root", timeout_seconds=timeout_seconds ) if pointer_error: return None, public_error_result(pointer_error, method="metadata.support.decode") pointer_tree = parse_config_tree_from_bytes(pointer_data or b"") pointer_items = pointer_tree.get("items") if isinstance(pointer_tree, dict) else [] root_file = next( ( config_tree_scalar(item).lower() for item in (pointer_items or [])[1:] if is_guid_text(config_tree_scalar(item)) ), "", ) if not root_file: return None, { "schema": "onec_support_rules.v1", "method": "metadata.support.decode", "status": "source_unknown", "base_id": base_id, "diagnostics": { "message": "Config/root does not contain a valid configuration root descriptor GUID.", "permission_inference": "forbidden", }, } root_data, _, root_error = read_storage_file_bytes( base_id, "Config", root_file, timeout_seconds=timeout_seconds ) if root_error: return None, public_error_result(root_error, method="metadata.support.decode") root_tree = parse_config_tree_from_bytes(root_data or b"") configuration_id = config_tree_scalar_at_path(root_tree, BASE_SUPPORT_CONFIGURATION_ID_PATH).lower() if not is_guid_text(configuration_id): return None, { "schema": "onec_support_rules.v1", "method": "metadata.support.decode", "status": "source_unknown", "base_id": base_id, "diagnostics": { "message": "The base configuration descriptor does not contain the expected internal configuration GUID.", "root_file": root_file, "configuration_id_path": ".".join(map(str, BASE_SUPPORT_CONFIGURATION_ID_PATH)), "permission_inference": "forbidden", }, } return { "kind": "live_sql", "database": str((config or {}).get("database") or ""), "table": "Config", "file_name": f"{configuration_id}.4", "root_file": root_file, "configuration_id": configuration_id, "discovery": "base_root_descriptor", }, None def discover_extension_support_source( base_id: str, extension_guid: str, *, timeout_seconds: int = 30, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Resolve an extension ParentConfigurations payload from its complete CAS manifest.""" manifests, diagnostics = live_extension_manifests( base_id, extension_guid=extension_guid, timeout_seconds=timeout_seconds, ) manifest = next( ( item for item in manifests if str((item.get("extension") or {}).get("guid") or "").lower() == extension_guid.lower() ), None, ) if not manifest: return None, { "schema": "onec_support_rules.v1", "method": "metadata.support.decode", "status": "source_unknown", "base_id": base_id, "layer_id": f"extension:{extension_guid}", "diagnostics": { "message": "The extension CAS manifest could not be resolved from live SQL.", "manifest_diagnostics": diagnostics, "permission_inference": "forbidden", }, } configuration_id = str(manifest.get("extension_configuration_guid") or "").lower() if not is_guid_text(configuration_id): return None, { "schema": "onec_support_rules.v1", "method": "metadata.support.decode", "status": "source_unknown", "base_id": base_id, "layer_id": f"extension:{extension_guid}", "diagnostics": { "message": "The extension manifest does not contain a valid configuration GUID.", "permission_inference": "forbidden", }, } support_object_id = f"{configuration_id}.4" entry = next( ( item for item in (manifest.get("entries") or []) if str(item.get("object_id") or "").lower() == support_object_id ), None, ) source = { "kind": "live_sql", "database": "", "table": "ConfigCAS", "root_cas_key": str(manifest.get("root_cas_key") or ""), "configuration_id": configuration_id, "extension": manifest.get("extension"), "manifest_declared_count": manifest.get("declared_count"), "manifest_entry_count": manifest.get("entry_count"), "discovery": "extension_cas_manifest", } if not entry: return {**source, "support_payload_present": False}, None return { **source, "file_name": str(entry.get("cas_key") or "").lower(), "support_object_id": support_object_id, "support_payload_present": True, }, None def metadata_support_decode(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.support.decode" normalized = normalize_object_selector_aliases(payload, method) if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": return normalized payload = normalized base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error argument_error = validate_optional_non_empty_string_arguments( payload, method, ["file_name", "object_guid", "guid", "layer_id"], ) if argument_error: return argument_error layer_id = str(payload.get("layer_id") or "base").strip().lower() if layer_id != "base" and not layer_id.startswith("extension:"): return invalid_argument(method, "layer_id", "layer_id must be base or extension:.") if layer_id.startswith("extension:") and not is_guid_text(layer_id.split(":", 1)[1]): return invalid_argument(method, "layer_id", "The extension layer_id suffix must be a GUID.") object_guid = str(payload.get("object_guid") or payload.get("guid") or "").strip().lower() if object_guid and not is_guid_text(object_guid): return invalid_argument(method, "object_guid", "object_guid must be a GUID string.") file_name = str(payload.get("file_name") or "").strip() source_discovery = "caller_verified" discovered_source: dict[str, Any] | None = None timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error resolved_object: dict[str, Any] | None = None if not object_guid and payload.get("name"): if layer_id == "base": resolved = get_object( payload.get("kind"), str(payload.get("name") or ""), base_id=base_id_or_error, include_semantic=False, timeout_seconds=int(timeout_seconds or 30), ) if resolved.get("status") != "ok": result = dict(resolved) result["method"] = method return result object_card = resolved.get("object") if isinstance(resolved.get("object"), dict) else resolved else: found = extension_objects_find( { "base_id": base_id_or_error, "extension_guid": layer_id.split(":", 1)[1], "query": payload.get("name"), "kind": payload.get("kind"), "limit": 20, "scan_limit": 5000, } ) if found.get("status") not in {"ok", "partial"}: result = dict(found) result["method"] = method return result wanted_name = normalize_exact(payload.get("name")) object_card = next( ( item for item in found.get("objects") or [] if isinstance(item, dict) and normalize_exact(item.get("name")) == wanted_name and ( not payload.get("kind") or canonical_kind(str(item.get("kind") or "")) == canonical_kind(str(payload.get("kind") or "")) ) ), None, ) if not object_card: return { "schema": "onec_support_rules.v1", "method": method, "status": "not_found", "error": "object_not_found", "base_id": base_id_or_error, "query": { "kind": payload.get("kind"), "name": payload.get("name"), "layer_id": layer_id, }, "diagnostics": {"message": "The selected extension object was not found by its 1C name."}, } object_guid = str((object_card or {}).get("guid") or "").strip().lower() if not is_guid_text(object_guid): return { "schema": "onec_support_rules.v1", "method": method, "status": "source_missing", "error": "object_guid_unresolved", "base_id": base_id_or_error, "query": {"kind": payload.get("kind"), "name": payload.get("name"), "layer_id": layer_id}, "diagnostics": {"message": "The selected object was resolved by name, but its metadata GUID is unavailable."}, } resolved_object = public_metadata_row(object_card) if not file_name: if layer_id == "base": discovered_source, discovery_error = discover_base_support_source( base_id_or_error, timeout_seconds=int(timeout_seconds or 30) ) else: discovered_source, discovery_error = discover_extension_support_source( base_id_or_error, layer_id.split(":", 1)[1], timeout_seconds=int(timeout_seconds or 30), ) if discovery_error: return discovery_error if discovered_source and discovered_source.get("support_payload_present") is False: return { "schema": "onec_support_rules.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "layer_id": layer_id, "source": discovered_source, "format_marker": None, "supplier_count": 0, "suppliers": [], "query": { "object_guid": object_guid or None, **({"object": resolved_object} if resolved_object else {}), }, "object_support": { "object_guid": object_guid or None, "status": "not_supported", "edit_allowed_by_support": True, "evidence": "support_payload_absent_from_complete_extension_manifest", }, "matches": [], "counts": {"suppliers": 0, "matches": 0}, "diagnostics": { "source_discovery": "extension_cas_manifest", "repository_lock_evaluated": False, }, } file_name = str((discovered_source or {}).get("file_name") or "") source_discovery = str((discovered_source or {}).get("discovery") or "") if Path(file_name).name != file_name: return invalid_argument(method, "file_name", "file_name must be a safe storage file name.") table_payload = dict(payload) if discovered_source: table_payload["table"] = discovered_source["table"] table_or_error = storage_table(table_payload, method) if isinstance(table_or_error, dict): return table_or_error data, config, source_error = read_storage_file_bytes( base_id_or_error, table_or_error, file_name, timeout_seconds=int(timeout_seconds or 30), ) if source_error: return public_error_result(source_error, method=method) try: from parser.support_rules import parse_parent_configurations_bytes decoded = parse_parent_configurations_bytes(data or b"", source=file_name) except ValueError as exc: return { "schema": "onec_support_rules.v1", "method": method, "status": "invalid_format", "base_id": base_id_or_error, "diagnostics": {"message": str(exc), "permission_inference": "forbidden"}, } suppliers = [] matches = [] for supplier in decoded["suppliers"]: rules = list(supplier.rules) suppliers.append({ "configuration_guid": supplier.configuration_guid, "general_mode_code": supplier.general_mode_code, "general_mode": supplier.general_mode, "version": supplier.version, "producer": supplier.producer, "name": supplier.name, "declared_object_count": supplier.declared_object_count, "parsed_object_count": len(rules), }) if object_guid: matches.extend({**rule.to_dict(), "configuration_guid": supplier.configuration_guid} for rule in rules if rule.object_guid == object_guid) object_support = None if object_guid: if not matches: object_support = { "object_guid": object_guid, "status": "not_supported", "edit_allowed_by_support": True, "evidence": "absent_from_complete_parent_configurations", } elif any(int(match.get("rule_code", -1)) == 0 for match in matches): object_support = { "object_guid": object_guid, "status": "not_editable", "edit_allowed_by_support": False, "evidence": "effective_supplier_rule", } elif any(int(match.get("rule_code", -1)) == 1 for match in matches): object_support = { "object_guid": object_guid, "status": "editable_support_preserved", "edit_allowed_by_support": True, "evidence": "effective_supplier_rule", } else: object_support = { "object_guid": object_guid, "status": "not_supported", "edit_allowed_by_support": True, "evidence": "effective_supplier_rule", } return { "schema": "onec_support_rules.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "layer_id": layer_id, "source": discovered_source or {"kind": "live_sql", "database": (config or {}).get("database"), "table": table_or_error, "file_name": file_name}, "format_marker": decoded["format_marker"], "supplier_count": decoded["supplier_count"], "suppliers": suppliers, "query": { "object_guid": object_guid or None, **({"object": resolved_object} if resolved_object else {}), }, "object_support": object_support, "matches": matches, "counts": {"suppliers": len(suppliers), "matches": len(matches)}, "diagnostics": { "source_discovery": source_discovery, "missing_match_semantics": "not_supported" if object_guid and not matches else None, "repository_lock_evaluated": False, }, } REPOSITORY_SQL_STATE_TABLES = ("Params", "Config", "ConfigSave", "ConfigCAS", "ConfigCASSave") def repository_sql_state_snapshot(payload: dict[str, Any]) -> dict[str, Any]: method = "repository.sql_state.snapshot" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error diagnostic_error = require_diagnostic_mode(payload, method) if diagnostic_error: return diagnostic_error raw_tables = payload.get("tables", ["Params"]) if not isinstance(raw_tables, list) or not raw_tables or any(not isinstance(item, str) for item in raw_tables): return invalid_argument(method, "tables", "tables must be a non-empty JSON array of storage table names.") tables = list(dict.fromkeys(str(item).strip() for item in raw_tables)) unsupported = [item for item in tables if item not in REPOSITORY_SQL_STATE_TABLES] if unsupported: return invalid_argument( method, "tables", "Unsupported SQL state table.", allowed_values=list(REPOSITORY_SQL_STATE_TABLES), ) include_extensions, include_extensions_error = strict_bool_argument( payload, "include_extensions", method=method, default=True ) if include_extensions_error: return include_extensions_error timeout_seconds, timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600 ) if timeout_error: return timeout_error conn, config, connection_error = connect_live_sql( base_id_or_error, method, timeout_seconds=int(timeout_seconds or 120) ) if connection_error: return connection_error records: list[dict[str, Any]] = [] extensions: list[dict[str, Any]] = [] started = time.time() try: with conn: with conn.cursor(as_dict=True) as cursor: for table in tables: cursor.execute( f"SELECT FileName, PartNo, BinaryData FROM dbo.[{table}] ORDER BY FileName, PartNo" ) current_name: str | None = None current_parts: list[dict[str, Any]] = [] current_hash = hashlib.sha1() current_bytes = 0 def finish_record() -> None: nonlocal current_name, current_parts, current_hash, current_bytes if current_name is None: return records.append( { "area": "storage", "table": table, "file_name": current_name, "part_count": len(current_parts), "bytes": current_bytes, "sha1": current_hash.hexdigest(), "parts": current_parts, } ) for row in cursor: file_name = str(row.get("FileName") or "") if current_name is not None and file_name != current_name: finish_record() current_parts = [] current_hash = hashlib.sha1() current_bytes = 0 current_name = file_name value = row.get("BinaryData") data = bytes(value) if isinstance(value, (bytes, bytearray, memoryview)) else b"" part_no = jsonable(row.get("PartNo")) current_hash.update(data) current_bytes += len(data) current_parts.append( {"part_no": part_no, "bytes": len(data), "sha1": hashlib.sha1(data).hexdigest()} ) finish_record() if include_extensions: cursor.execute( "SELECT [_IDRRef], [_ExtName], [_ExtensionOrder], [_ExtensionZippedInfo] " "FROM dbo.[_ExtensionsInfo] ORDER BY [_ExtensionOrder], [_ExtName]" ) for row in cursor.fetchall(): zipped = row.get("_ExtensionZippedInfo") zipped_data = bytes(zipped) if isinstance(zipped, (bytes, bytearray, memoryview)) else b"" extensions.append( { "area": "extensions_info", "guid": dbnames_ext_guid_from_idrref(row.get("_IDRRef")), "name": jsonable(row.get("_ExtName")), "order": jsonable(row.get("_ExtensionOrder")), "bytes": len(zipped_data), "sha1": hashlib.sha1(zipped_data).hexdigest(), "root_cas_key": extension_root_key_from_zipped_info(zipped_data), } ) except Exception as exc: return { "schema": "onec_repository_sql_state_snapshot.v1", "method": method, "status": "error", "base_id": base_id_or_error, "diagnostics": {"message": str(exc), "native_lock_inference": "forbidden"}, } portable_snapshot = { "schema": "onec_repository_sql_state_snapshot.v1", "base_id": base_id_or_error, "scope": {"tables": tables, "include_extensions": bool(include_extensions)}, "records": records, "extensions": extensions, } identity = json.dumps( portable_snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") snapshot_id = hashlib.sha256(identity).hexdigest() comparison_token = base64.urlsafe_b64encode(zlib.compress(identity, level=9)).decode("ascii") return { "schema": "onec_repository_sql_state_snapshot.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "snapshot_id": snapshot_id, "comparison_token": comparison_token, "source": {"kind": "live_sql", "database": (config or {}).get("database")}, "scope": {"tables": tables, "include_extensions": bool(include_extensions)}, "records": records, "extensions": extensions, "counts": {"records": len(records), "extensions": len(extensions)}, "duration_ms": int((time.time() - started) * 1000), "diagnostics": { "payload_returned": False, "native_lock_inference": "forbidden_until_repeatable_differential_validation", }, } def repository_sql_state_diff(payload: dict[str, Any]) -> dict[str, Any]: method = "repository.sql_state.diff" diagnostic_error = require_diagnostic_mode(payload, method) if diagnostic_error: return diagnostic_error def token_snapshot(value: Any, argument: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: if value is None or value == "": return None, None if not isinstance(value, str): return None, invalid_argument(method, argument, f"{argument} must be a comparison_token string.") try: decoded = zlib.decompress(base64.urlsafe_b64decode(value.encode("ascii"))) snapshot = json.loads(decoded.decode("utf-8")) except Exception: return None, invalid_argument(method, argument, f"{argument} is not a valid comparison_token.") if not isinstance(snapshot, dict): return None, invalid_argument(method, argument, f"{argument} did not decode to a snapshot object.") snapshot["snapshot_id"] = hashlib.sha256(decoded).hexdigest() return snapshot, None before = payload.get("before") after = payload.get("after") if before is None and payload.get("before_token") is not None: before, token_error = token_snapshot(payload.get("before_token"), "before_token") if token_error: return token_error if after is None and payload.get("after_token") is not None: after, token_error = token_snapshot(payload.get("after_token"), "after_token") if token_error: return token_error if not isinstance(before, dict) or not isinstance(after, dict): return invalid_argument(method, "before/after", "before and after must be snapshot JSON objects.") if before.get("schema") != "onec_repository_sql_state_snapshot.v1" or after.get("schema") != before.get("schema"): return invalid_argument(method, "before/after", "Both inputs must be repository.sql_state.snapshot responses.") if str(before.get("base_id") or "") != str(after.get("base_id") or ""): return invalid_argument(method, "before/after", "Snapshots must belong to the same base_id.") def keyed(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for item in snapshot.get("records") or []: if isinstance(item, dict): result[f"storage:{item.get('table')}:{item.get('file_name')}"] = item for item in snapshot.get("extensions") or []: if isinstance(item, dict): result[f"extensions_info:{item.get('guid') or item.get('name')}"] = item return result before_items = keyed(before) after_items = keyed(after) added = [] removed = [] changed = [] for key in sorted(set(before_items) | set(after_items)): old = before_items.get(key) new = after_items.get(key) if old is None: added.append({"key": key, "after": new}) elif new is None: removed.append({"key": key, "before": old}) elif old.get("sha1") != new.get("sha1") or old.get("bytes") != new.get("bytes"): old_parts = {str(item.get("part_no")): item for item in old.get("parts") or [] if isinstance(item, dict)} new_parts = {str(item.get("part_no")): item for item in new.get("parts") or [] if isinstance(item, dict)} changed_parts = [ part for part in sorted(set(old_parts) | set(new_parts)) if old_parts.get(part) != new_parts.get(part) ] changed.append( { "key": key, "before": {key: old.get(key) for key in ("bytes", "sha1", "part_count") if key in old}, "after": {key: new.get(key) for key in ("bytes", "sha1", "part_count") if key in new}, "changed_parts": changed_parts, } ) return { "schema": "onec_repository_sql_state_diff.v1", "method": method, "status": "ok", "base_id": before.get("base_id"), "before_snapshot_id": before.get("snapshot_id"), "after_snapshot_id": after.get("snapshot_id"), "changed": bool(added or removed or changed), "added": added, "removed": removed, "modified": changed, "counts": {"added": len(added), "removed": len(removed), "modified": len(changed)}, "diagnostics": {"native_lock_inference": "forbidden_until_repeatable_differential_validation"}, } SAVED_STATE_SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} SAVED_STATE_TARGET_BY_SOURCE = {"Config": "ConfigSave", "ConfigCAS": "ConfigCASSave"} SAVED_STATE_TABLE_BY_LAYER = {"base_saved_state": "ConfigSave", "extension_saved_state": "ConfigCASSave"} SAVED_STATE_LAYER_BY_TABLE = {table: layer for layer, table in SAVED_STATE_TABLE_BY_LAYER.items()} SAVED_STATE_ACTIVE_LAYER_BY_TABLE = {"ConfigSave": "base_configuration", "ConfigCASSave": "extension_configuration"} SAVED_STATE_COPY_COLUMNS = ("FileName", "Creation", "Modified", "Attributes", "DataSize", "BinaryData", "PartNo") def saved_state_row_public(row: dict[str, Any]) -> dict[str, Any]: def value(*names: str) -> Any: for name in names: if name in row: return row.get(name) lowered = {str(key).casefold(): key for key in row} for name in names: key = lowered.get(name.casefold()) if key is not None: return row.get(key) return None return { "file_name": value("FileName", "file_name"), "part_no": value("PartNo", "part_no"), "data_size": value("DataSize", "data_size"), "binary_bytes": value("BinaryBytes", "binary_bytes"), "binary_sha1": str(value("BinarySHA1", "binary_sha1") or "").lower(), } def saved_state_copy_row_details(base_id: str, table: str, file_names: list[str], *, timeout_seconds: int = 30) -> tuple[list[dict[str, Any]] | None, dict[str, str] | None, dict[str, Any] | None]: if table not in STORAGE_TABLES: return None, None, invalid_argument("metadata.saved_state.prepare", "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) names = sorted({name for name in file_names if name and Path(name).name == name}) if not names: return [], None, None conn, config, error = connect_live_sql(base_id, "metadata.saved_state.prepare", timeout_seconds=timeout_seconds) if error: return None, config, error rows: list[dict[str, Any]] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: for start in range(0, len(names), 500): chunk = names[start : start + 500] placeholders = ",".join(["%s"] * len(chunk)) cursor.execute( f""" SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 FROM dbo.[{table}] WHERE FileName IN ({placeholders}) ORDER BY FileName, PartNo """, tuple(chunk), ) rows.extend({key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()) except Exception as exc: return None, config, { "schema": "onec_adapter_source_error.v1", "method": "metadata.saved_state.prepare", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table}, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass return rows, config, None def saved_state_prepare_file_names(payload: dict[str, Any], base_id: str, source_table: str, timeout_seconds: int) -> tuple[list[str], dict[str, Any] | None, dict[str, Any] | None]: method = "metadata.saved_state.prepare" raw_file_names = payload.get("file_names") if raw_file_names is not None: if not isinstance(raw_file_names, list) or not all(isinstance(item, str) and item and Path(item).name == item for item in raw_file_names): return [], None, invalid_argument(method, "file_names", "file_names must be an array of safe FileName strings.") return sorted(set(raw_file_names)), {"mode": "explicit_file_names"}, None file_name = str(payload.get("file_name") or "").strip() if file_name: if Path(file_name).name != file_name: return [], None, invalid_argument(method, "file_name", "file_name must be a safe FileName value.") return [file_name], {"mode": "explicit_file_name"}, None module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() if module_ref: module_table, module_file_name, _stream_index = parse_module_id(module_ref) if not module_table or not module_file_name: return [], None, invalid_argument(method, "module_ref", "Use module_ref in the form
:[#stream:].") if module_table in SAVED_STATE_SOURCE_BY_TARGET: module_table = SAVED_STATE_SOURCE_BY_TARGET[module_table] if module_table != source_table: return [], None, invalid_argument(method, "module_ref", f"module_ref table must match source family {source_table}.") return [module_file_name], {"mode": "module_ref", "module_ref": module_ref}, None extension_name = str(payload.get("extension") or "").strip() if extension_name: if source_table != "ConfigCAS": return [], None, invalid_argument(method, "target_table", "Extension saved-state preparation must target ConfigCASSave.", allowed_values=["ConfigCASSave"]) requested_form = str(payload.get("form") or payload.get("form_name") or "").strip() if requested_form: # A form name is not an extension object name. Resolve it through its # public owner first, so a normal form prepare does not degrade into a # whole-ConfigCAS scan or accidentally select a same-named form. owner_ref = str(payload.get("ref") or "").strip() owner_kind = str(payload.get("kind") or payload.get("object_type") or "").strip() owner_name = str(payload.get("name") or payload.get("object_name") or "").strip() if not (owner_ref or (owner_kind and owner_name)): return [], {"mode": "extension_form_owner_required", "extension": extension_name, "form": requested_form}, { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "invalid_argument", "base_id": base_id, "error": "form_owner_required", "diagnostics": { "message": "For an extension form pass its public owner (ref or kind+name) together with form. A bare form name is not unique across an extension.", "next_call": { "method": "metadata.object.forms", "params": {"base_id": base_id, "extension": extension_name, "ref": "", "form": requested_form}, }, }, } forms_result = metadata_object_forms( { "base_id": base_id, "extension": extension_name, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), "form": requested_form, "include_storage": True, "timeout_seconds": timeout_seconds, } ) forms = forms_result.get("forms") if isinstance(forms_result.get("forms"), list) else [] candidates = [] for form in forms: source = form.get("source") if isinstance(form, dict) and isinstance(form.get("source"), dict) else {} candidate_name = str(source.get("file_name") or "").strip().lower() if candidate_name and Path(candidate_name).name == candidate_name: candidates.append((candidate_name, form)) unique_candidates = {name: form for name, form in candidates} if len(unique_candidates) == 1: file_name, form = next(iter(unique_candidates.items())) return [file_name], { "mode": "extension_form_owner", "kind": owner_kind or None, "name": owner_name or None, "ref": owner_ref or None, "form": form.get("name") or requested_form, "extension": extension_name, }, None return [], {"mode": "extension_form_owner", "extension": extension_name, "form": requested_form, "matches": len(unique_candidates)}, { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "not_found" if not unique_candidates else "ambiguous", "base_id": base_id, "error": "extension_form_route_not_resolved", "diagnostics": { "message": "The named extension form was not resolved to one active ConfigCAS payload through its owner. Review metadata.object.forms result; do not fall back to a global scan.", "forms_status": forms_result.get("status"), "matches": len(unique_candidates), "next_call": { "method": "metadata.object.forms", "params": {"base_id": base_id, "extension": extension_name, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), "form": requested_form, "include_storage": True}, }, }, } config, _ = sql_config_for_base(base_id) ref_kind, ref_name = parse_object_query(None, str(payload.get("ref") or "")) query = str(payload.get("query") or payload.get("name") or payload.get("object_name") or ref_name or payload.get("guid") or payload.get("object_guid") or "").strip() kind_filter = canonical_kind(str(payload.get("kind") or payload.get("object_type") or ref_kind or "")) guid_filter = str(payload.get("guid") or payload.get("object_guid") or "").strip().lower() rows = extension_route_cache_lookup( config, query=query, # Older cache rows may not yet have object_kind populated. Name # resolution is still exact and is verified below, so do not turn # a public ref into a false not_found solely through that cache # enrichment gap. kind_filter=None, guid_filter=guid_filter, extension_guid=None, limit=20, ) matches = [] requested_name = str(payload.get("name") or payload.get("object_name") or ref_name or "").strip() for row in rows: if extension_name and normalize(row.get("extension_name")) != normalize(extension_name) and str(row.get("extension_guid") or "").lower() != extension_name.lower(): continue if requested_name and normalize(row.get("name")) != normalize(requested_name): continue matches.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness={"status": "cache_hit_verified"})) if len(matches) != 1: # A prepare rollback invalidates route-cache entries for the saved # target. Rebuild the exact active extension route internally once # before treating the public selector as unresolved. The caller # must not have to invoke cache maintenance or retry with a file # key after a normal rollback. extension_objects_find( { "base_id": base_id, "extension": extension_name, "query": query, **({"kind": kind_filter} if kind_filter else {}), **({"guid": guid_filter} if guid_filter else {}), "state": "active", # Manifest resolution repopulates this exact route on a # miss. Do not force refresh_cache: that additionally # performs a full ConfigCAS scan and makes a normal retry # after rollback unnecessarily slow. "refresh_cache": False, "limit": 20, "timeout_seconds": timeout_seconds, } ) refreshed_rows = extension_route_cache_lookup( config, query=query, kind_filter=None, guid_filter=guid_filter, extension_guid=None, limit=20, ) matches = [] for row in refreshed_rows: if extension_name and normalize(row.get("extension_name")) != normalize(extension_name) and str(row.get("extension_guid") or "").lower() != extension_name.lower(): continue if requested_name and normalize(row.get("name")) != normalize(requested_name): continue matches.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness={"status": "refreshed_for_prepare"})) if len(matches) != 1: return [], {"mode": "extension_route_cache", "matches": len(matches), "extension": extension_name}, { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "not_found" if not matches else "ambiguous", "base_id": base_id, "source": {"kind": "extension_route_cache", "table": source_table}, "diagnostics": { "message": "Extension object route was not resolved to exactly one cached route. Run extension.cache.rebuild/validate or pass file_names/module_ref.", "matches": len(matches), }, } match = matches[0] route = match.get("route") if isinstance(match.get("route"), dict) else {} file_names = [] route_file = str(route.get("file_name") or "").strip().lower() if route_file and Path(route_file).name == route_file: file_names.append(route_file) for entry in match.get("manifest_entries") or []: if not isinstance(entry, dict): continue cas_key = str(entry.get("cas_key") or "").strip().lower() if cas_key and Path(cas_key).name == cas_key: file_names.append(cas_key) # ConfigCASSave's hash-keyed overlay is rooted at the extension # manifest. Copying only an object leaf can pass SQL readback but # leaves no saved root for Configurator to select. The root key is # live manifest evidence, not a name reconstructed by the adapter. root_cas_key = str(entry.get("root_cas_key") or "").strip().lower() if root_cas_key and Path(root_cas_key).name == root_cas_key: file_names.append(root_cas_key) # The active extension root is already a ConfigInfo-style map: it # carries logical object names and the SHA-1 of every referenced CAS # payload. For a normal object module, the proven first-overlay route # is its three logical files, not a same-name copy of hash keys: # # root manifest -> E__configinfo # O descriptor -> E__O # O module .S -> E__O.S # # The mapping is accepted only when the active manifest supplies one # root, one descriptor, and the one *selected* module leaf from the # decoded manifest, plus a concrete extension GUID. Module ordinal is # not always `.0`: for example, the evidenced manager module route is # `.2`. It must be taken from the resolved route, never guessed from # the object kind or routine name. `metadata_saved_state_prepare` # subsequently verifies all source rows before the atomic copy. Do not # apply this to form payloads or an unverified suffix family. extension_card = (match.get("origin") or {}).get("extension") if isinstance(match.get("origin"), dict) else {} extension_guid = str((extension_card or {}).get("guid") or "").strip().lower() object_guid = str(match.get("guid") or "").strip().lower() root_keys = sorted( { str(entry.get("root_cas_key") or "").strip().lower() for entry in (match.get("manifest_entries") or []) if isinstance(entry, dict) and str(entry.get("root_cas_key") or "").strip() and Path(str(entry.get("root_cas_key") or "").strip()).name == str(entry.get("root_cas_key") or "").strip() } ) descriptor_keys = sorted( { str(entry.get("cas_key") or "").strip().lower() for entry in (match.get("manifest_entries") or []) if isinstance(entry, dict) and str(entry.get("suffix") or "") == "" and str(entry.get("cas_key") or "").strip() } ) # `route.file_name` is the object route's manifest root in some cache # records, whereas a code-write resolver already has the exact BSL # source stream. The latter is an internal, adapter-issued value and # takes precedence solely to select the matching manifest leaf. selected_source_ref = str(payload.get("_resolved_source_module_ref") or "").strip() selected_source_table, selected_source_file_name, _selected_source_stream = parse_module_id(selected_source_ref) selected_module_key = ( selected_source_file_name.strip().lower() if selected_source_table == "ConfigCAS" and selected_source_file_name else route_file ) numeric_module_entries = [ entry for entry in (match.get("manifest_entries") or []) if isinstance(entry, dict) and re.fullmatch(r"\.\d+", str(entry.get("suffix") or "")) ] selected_module_entries = [ entry for entry in numeric_module_entries if str(entry.get("cas_key") or "").strip().lower() == selected_module_key ] # A public prepare has no routine/stream reference. It is still safe # for a single-leaf object route: there is no alternative module # ordinal to choose. More than one leaf remains unresolved rather than # selecting an ordinal by convention. if not selected_source_ref and not selected_module_entries and len(numeric_module_entries) == 1: selected_module_entries = numeric_module_entries saved_state_name_map: dict[str, str] = {} if is_guid_text(extension_guid) and is_guid_text(object_guid) and len(root_keys) == len(descriptor_keys) == len(selected_module_entries) == 1: selected_module = selected_module_entries[0] module_key = str(selected_module.get("cas_key") or "").strip().lower() module_suffix = str(selected_module.get("suffix") or "") saved_state_name_map = { root_keys[0]: f"{extension_guid}__configinfo", descriptor_keys[0]: f"{extension_guid}__{object_guid}", module_key: f"{extension_guid}__{object_guid}{module_suffix}", } file_names = list(saved_state_name_map) else: # Do not silently preserve hash keys when the descriptor family is # incomplete: it yields a SQL-only overlay invisible to # Configurator. file_names = [] # Form payloads are a separate protocol family. They are included # only for an explicitly selected form, never for an object-module # write, so that the three-file object overlay remains exact. include_related_parts = bool(payload.get("form") or payload.get("form_name")) and payload.get("include_related_parts") is not False related_forms: list[dict[str, Any]] = [] if include_related_parts and canonical_kind(str(match.get("kind") or "")) == "Report": forms_result = metadata_object_forms( { "base_id": base_id, "extension": extension_name, "kind": match.get("kind"), "name": match.get("name"), "include_storage": True, "timeout_seconds": timeout_seconds, } ) for form in forms_result.get("forms") or []: if not isinstance(form, dict): continue source = form.get("source") if isinstance(form.get("source"), dict) else {} form_file = str(source.get("file_name") or "").strip().lower() if form_file and Path(form_file).name == form_file: file_names.append(form_file) related_forms.append({ "name": form.get("name"), "guid": form.get("guid"), "file_name": form_file, }) return sorted(set(file_names)), { "mode": "extension_route_cache", "kind": match.get("kind"), "guid": match.get("guid"), "name": match.get("name"), "extension": (match.get("origin") or {}).get("extension") if isinstance(match.get("origin"), dict) else None, "related_forms": related_forms, "include_related_parts": include_related_parts, "saved_state_root_keys": root_keys, "saved_state_name_map": saved_state_name_map, "saved_state_protocol": "canonical_configinfo_pair" if saved_state_name_map else "unresolved_hash_layout", }, None guid, kind, object_card, error = resolve_object_guid( {**payload, "table": source_table}, base_id, timeout_seconds=timeout_seconds, method=method, table=source_table, ) if error: return [], None, error limit_value, limit_error = parse_int_argument(payload, "part_limit", method=method, default=5000, minimum=1, maximum=20000) if limit_error: return [], None, limit_error files = storage_files_list({"base_id": base_id, "table": source_table, "prefix": guid, "limit": int(limit_value or 5000), "timeout_seconds": timeout_seconds, "_internal": True}) if files.get("status") != "ok": return [], object_card, files names = [ str(row.get("FileName") or "") for row in files.get("files") or [] if str(row.get("FileName") or "") == guid or str(row.get("FileName") or "").startswith(f"{guid}.") or str(row.get("FileName") or "").startswith(f"{guid}__") ] return sorted(set(names)), object_card or {"guid": guid, "kind": kind}, None def apply_saved_state_prepare_copy( base_id: str, source_table: str, target_table: str, file_names: list[str], *, expected_source_rows: int, prepared_rows: list[dict[str, Any]], target_name_by_source: dict[str, str] | None = None, timeout_seconds: int, ) -> dict[str, Any]: method = "metadata.saved_state.prepare" conn, config, error = connect_live_sql(base_id, method, timeout_seconds=timeout_seconds) if error: return error started = time.time() names = sorted(set(file_names)) target_name_by_source = target_name_by_source or {} target_names = [str(target_name_by_source.get(name) or name) for name in names] if len(set(target_names)) != len(target_names) or any(Path(name).name != name for name in target_names): return {"schema": "onec_saved_state_prepare.v1", "status": "invalid_target_name_map", "applied": False, "base_id": base_id} try: cursor = conn.cursor(as_dict=True) placeholders = ",".join(["%s"] * len(names)) cursor.execute( f"SELECT FileName, PartNo FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK) WHERE FileName IN ({placeholders})", tuple(target_names), ) collisions = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] if collisions: conn.rollback() return { "schema": "onec_saved_state_prepare.v1", "status": "blocked_target_collision", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": source_table}, "target": {"table": target_table}, "collisions": [saved_state_row_public(row) for row in collisions], "diagnostics": {"message": "Target saved-state table already contains planned FileName values. Do not overwrite human or pending Configurator changes."}, } column_list = ", ".join(f"[{column}]" for column in SAVED_STATE_COPY_COLUMNS) source_columns = ", ".join(f"s.[{column}]" for column in SAVED_STATE_COPY_COLUMNS if column != "FileName") inserted = 0 for source_name in names: target_name = str(target_name_by_source.get(source_name) or source_name) cursor.execute( f""" INSERT INTO dbo.[{target_table}] ({column_list}) SELECT %s, {source_columns} FROM dbo.[{source_table}] AS s WHERE s.FileName=%s """, (target_name, source_name), ) inserted += int(cursor.rowcount or 0) if inserted != expected_source_rows: conn.rollback() return { "schema": "onec_saved_state_prepare.v1", "status": "precondition_failed", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": source_table}, "target": {"table": target_table}, "counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted}, "diagnostics": {"message": "Copied row count did not match current source row count."}, } receipt = write_saved_state_prepare_receipt( base_id=base_id, config=config, source_table=source_table, target_table=target_table, rows=prepared_rows, ) conn.commit() except Exception as exc: try: conn.rollback() except Exception: pass return { "schema": "onec_saved_state_prepare.v1", "status": "error", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": source_table}, "target": {"table": target_table}, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass return { "schema": "onec_saved_state_prepare.v1", "status": "applied", "applied": True, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": source_table}, "target": {"table": target_table}, "counts": {"inserted_rows": inserted, "file_names": len(target_names)}, "prepare_receipt": receipt, "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_prepare", source_table=target_table, file_names=target_names, ), "duration_ms": int((time.time() - started) * 1000), } def saved_state_prepare_receipt_dir() -> Path: return storage_apply_backup_dir() / "prepare-receipts" def write_saved_state_prepare_receipt( *, base_id: str, config: dict[str, str], source_table: str, target_table: str, rows: list[dict[str, Any]], ) -> dict[str, Any]: receipt_id = uuid.uuid4().hex created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") root = saved_state_prepare_receipt_dir() root.mkdir(parents=True, exist_ok=True) path = root / f"{created_at.replace(':', '').replace('-', '')}-{receipt_id}.json" normalized_rows = [ { key: row.get(key) for key in ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1") } for row in rows ] evidence = { "schema": "onec_saved_state_prepare_receipt.v1", "receipt_id": receipt_id, "created_at_utc": created_at, "base_id": base_id, "source": { "server": config.get("server"), "database": config.get("database"), "table": source_table, }, "target": {"table": target_table}, "rows": normalized_rows, "status": "prepared", **({"job_id": str(getattr(ADAPTER_JOB_CONTEXT, "job_id", "") or "")} if getattr(ADAPTER_JOB_CONTEXT, "job_id", None) else {}), } path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return { "receipt_id": receipt_id, "path": str(path), "rows": len(normalized_rows), } def resolve_saved_state_prepare_receipt(receipt_id: str) -> Path | None: normalized = str(receipt_id or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{32}", normalized): return None root = saved_state_prepare_receipt_dir() matches = sorted(root.glob(f"*{normalized}.json")) if root.is_dir() else [] return matches[0] if len(matches) == 1 else None def rollback_saved_state_prepare_receipt( base_id: str, receipt_id: str, *, timeout_seconds: int, ) -> dict[str, Any]: path = resolve_saved_state_prepare_receipt(receipt_id) if path is None: return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "not_found", "applied": False, "receipt_id": receipt_id, } try: evidence = json.loads(path.read_text(encoding="utf-8-sig")) except Exception as exc: return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "invalid_receipt", "applied": False, "receipt_id": receipt_id, "diagnostics": {"message": str(exc)}, } if str(evidence.get("base_id") or "") != str(base_id): return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "blocked", "applied": False, "error": "receipt_base_mismatch", "receipt_id": receipt_id, } if evidence.get("status") == "rolled_back": return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "already_rolled_back", "applied": True, "receipt_id": receipt_id, } target = evidence.get("target") if isinstance(evidence.get("target"), dict) else {} target_table = str(target.get("table") or "") if target_table not in SAVED_STATE_SOURCE_BY_TARGET: return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "invalid_receipt", "applied": False, "error": "receipt_target_invalid", "receipt_id": receipt_id, } expected_rows = [ row for row in (evidence.get("rows") or []) if isinstance(row, dict) and row.get("file_name") and Path(str(row["file_name"])).name == str(row["file_name"]) ] file_names = sorted({str(row["file_name"]) for row in expected_rows}) if not expected_rows or not file_names: return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "invalid_receipt", "applied": False, "error": "receipt_rows_missing", "receipt_id": receipt_id, } conn, config, error = connect_live_sql(base_id, "metadata.saved_state.prepare.rollback", timeout_seconds=timeout_seconds) if error: return error started = time.time() try: cursor = conn.cursor(as_dict=True) placeholders = ",".join(["%s"] * len(file_names)) cursor.execute( f""" SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK) WHERE FileName IN ({placeholders}) ORDER BY FileName, PartNo """, tuple(file_names), ) current_rows = [ saved_state_row_public({key: jsonable(value) for key, value in row.items()}) for row in cursor.fetchall() ] comparable_fields = ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1") expected_comparable = sorted( tuple(row.get(field) for field in comparable_fields) for row in expected_rows ) current_comparable = sorted( tuple(row.get(field) for field in comparable_fields) for row in current_rows ) if current_comparable != expected_comparable: conn.rollback() return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "precondition_failed", "applied": False, "error": "prepared_rows_changed", "receipt_id": receipt_id, "counts": { "expected_rows": len(expected_comparable), "current_rows": len(current_comparable), }, } cursor.execute( f"DELETE FROM dbo.[{target_table}] WHERE FileName IN ({placeholders})", tuple(file_names), ) deleted = int(cursor.rowcount or 0) if deleted != len(expected_rows): conn.rollback() return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "precondition_failed", "applied": False, "error": "prepared_row_delete_count_mismatch", "receipt_id": receipt_id, "counts": {"expected_rows": len(expected_rows), "deleted_rows": deleted}, } conn.commit() except Exception as exc: try: conn.rollback() except Exception: pass return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "error", "applied": False, "receipt_id": receipt_id, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass evidence["status"] = "rolled_back" evidence["rolled_back_at_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return { "schema": "onec_saved_state_prepare_rollback.v1", "status": "rolled_back", "applied": True, "base_id": base_id, "receipt_id": receipt_id, "target": {"table": target_table}, "counts": {"deleted_rows": deleted, "file_names": len(file_names)}, "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_prepare_rollback", source_table=target_table, file_names=file_names, ), "duration_ms": int((time.time() - started) * 1000), } def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(object_card, dict): return None kind = object_card.get("kind") or object_card.get("object_type") name = object_card.get("name") or object_card.get("object_name") extension = object_card.get("extension") result = { **({"kind": kind} if kind else {}), **({"name": name} if name else {}), **({"synonym": object_card.get("synonym")} if object_card.get("synonym") else {}), **({"extension": extension} if isinstance(extension, str) and extension else {}), } ref = object_selector_ref(str(kind or ""), str(name or "")) if ref: result["ref"] = ref return result or None def public_saved_state_prepare_call_payload(payload: dict[str, Any]) -> dict[str, Any]: target_table = str(payload.get("target_table") or payload.get("table") or "").strip() source_table = str(payload.get("source_table") or "").strip() layer = str(payload.get("layer") or "").strip() if layer not in SAVED_STATE_TABLE_BY_LAYER: if target_table in SAVED_STATE_LAYER_BY_TABLE: layer = SAVED_STATE_LAYER_BY_TABLE[target_table] elif source_table in SAVED_STATE_TARGET_BY_SOURCE: layer = SAVED_STATE_LAYER_BY_TABLE[SAVED_STATE_TARGET_BY_SOURCE[source_table]] elif str(payload.get("extension") or "").strip(): layer = "extension_saved_state" else: layer = "base_saved_state" ref = str(payload.get("ref") or "").strip() kind = str(payload.get("kind") or payload.get("object_type") or "").strip() name = str(payload.get("name") or payload.get("object_name") or "").strip() form = str(payload.get("form") or payload.get("form_name") or "").strip() if ref and not is_guid_text(ref): ref_kind, ref_name = parse_object_query(None, ref) kind = kind or str(ref_kind or "") name = name or str(ref_name or "") if not ref or is_guid_text(ref): ref = object_selector_ref(kind, name) or "" result: dict[str, Any] = { "base_id": payload.get("base_id"), "layer": layer, **({"ref": ref} if ref else {}), **({"kind": canonical_kind(kind)} if kind else {}), **({"name": name} if name else {}), **({"extension": payload.get("extension")} if isinstance(payload.get("extension"), str) and payload.get("extension") else {}), **({"form": form} if form else {}), **({"query": payload.get("query")} if isinstance(payload.get("query"), str) and payload.get("query") else {}), **({"merge_missing": True} if payload.get("merge_missing") is True else {}), "mode": "plan", } if not any(result.get(key) for key in ("ref", "name", "query")): result["selector_required"] = "Pass a public 1C ref or kind/name selector." return result def cleanup_job_prepare_receipts(job_id: str, base_id: str, *, timeout_seconds: int) -> list[dict[str, Any]]: """Best-effort compensation after an isolated write worker is terminated.""" cleaned: list[dict[str, Any]] = [] root = saved_state_prepare_receipt_dir() for path in sorted(root.glob("*.json")) if root.is_dir() else []: try: evidence = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: continue if ( str(evidence.get("job_id") or "") != str(job_id) or str(evidence.get("base_id") or "") != str(base_id) or str(evidence.get("status") or "") != "prepared" ): continue receipt_id = str(evidence.get("receipt_id") or "") if re.fullmatch(r"[0-9a-f]{32}", receipt_id): cleaned.append(rollback_saved_state_prepare_receipt(base_id, receipt_id, timeout_seconds=timeout_seconds)) return cleaned def public_saved_state_prepare_embedded_result(result: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: target_table = str(payload.get("target_table") or payload.get("table") or "").strip() source_table = str(payload.get("source_table") or "").strip() layer = str(payload.get("layer") or "").strip() if target_table not in SAVED_STATE_SOURCE_BY_TARGET: if layer in SAVED_STATE_TABLE_BY_LAYER: target_table = SAVED_STATE_TABLE_BY_LAYER[layer] elif source_table in SAVED_STATE_TARGET_BY_SOURCE: target_table = SAVED_STATE_TARGET_BY_SOURCE[source_table] elif str(payload.get("extension") or "").strip(): target_table = "ConfigCASSave" else: target_table = "ConfigSave" object_card = result.get("object") if isinstance(result.get("object"), dict) else None return public_saved_state_prepare_result( result, base_id=str(payload.get("base_id") or result.get("base_id") or ""), target_table=target_table, object_card=object_card, ) def public_saved_state_prepare_result( result: dict[str, Any], *, base_id: str, target_table: str, object_card: dict[str, Any] | None = None, ) -> dict[str, Any]: layer = SAVED_STATE_LAYER_BY_TABLE[target_table] raw_counts = result.get("counts") if isinstance(result.get("counts"), dict) else {} apply_result = result.get("apply_result") if isinstance(result.get("apply_result"), dict) else {} apply_counts = apply_result.get("counts") if isinstance(apply_result.get("counts"), dict) else {} result_object = result.get("object") if isinstance(result.get("object"), dict) else None object_public = public_saved_state_prepare_object(object_card or result_object) public: dict[str, Any] = { "schema": "onec_saved_state_prepare.v1", "method": "metadata.saved_state.prepare", "status": result.get("status") or "error", "applied": bool(result.get("applied")), "ready_to_copy": bool(result.get("ready_to_copy")), "base_id": base_id, "source": { "kind": "active_configuration", "layer": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[target_table], }, "target": {"layer": layer}, **({"object": object_public} if object_public else {}), "counts": { "selected_objects": 1 if object_public else 0, "source_records": int(raw_counts.get("source_rows") or 0), "existing_saved_records": int(raw_counts.get("target_rows") or 0), "inserted_records": int(apply_counts.get("inserted_rows") or 0), }, "write_mode": { "target": "saved_state", "sql_write_performed": bool(result.get("applied")), "requires_allow_flag": True, }, "freshness": { "source": "saved_state", "status": "live_verified", "verified": True, }, "diagnostics": { "note": "Storage coordinates, files, hashes, and row details are hidden unless include_storage=true.", }, } raw_diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else {} if isinstance(raw_diagnostics.get("next_call"), dict): public["next_call"] = raw_diagnostics["next_call"] if result.get("error"): public["error"] = result.get("error") verification = result.get("verification") if isinstance(result.get("verification"), dict) else None if verification: mismatched = verification.get("mismatched") if isinstance(verification.get("mismatched"), list) else [] public["verification"] = { "status": verification.get("status"), "expected_records": verification.get("expected_rows"), "actual_records": verification.get("actual_rows"), "mismatched_records": len(mismatched), } # The receipt is an opaque adapter capability, not a storage coordinate. # It must remain available in the public response: otherwise an agent can # create a saved-state copy but cannot invoke the deliberately separate, # guarded cleanup endpoint. Do not disclose the receipt path or its row # hashes unless include_storage was explicitly requested. prepare_receipt = apply_result.get("prepare_receipt") if isinstance(apply_result.get("prepare_receipt"), dict) else {} prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "").strip().lower() if re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): public["prepare_receipt_id"] = prepare_receipt_id if bool(result.get("applied")) and int(apply_counts.get("inserted_rows") or 0) > 0: is_extension_saved_state = target_table == "ConfigCASSave" public["configurator_refresh"] = { "required": True, "scope": "extension" if is_extension_saved_state else "configuration", "action": "close_reopen_extension" if is_extension_saved_state else "close_reopen_configuration", "reason": ( "The adapter created a new extension saved-state overlay; an already open Configurator can still use the active extension until it is reopened." if is_extension_saved_state else "The adapter created a new base-configuration saved-state overlay outside Configurator memory." ), } stages = result.get("stages") if isinstance(result.get("stages"), list) else [] if stages: public["stages"] = [ { "name": stage.get("name"), "status": stage.get("status"), "elapsed_ms": stage.get("elapsed_ms"), } for stage in stages if isinstance(stage, dict) ] return public def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.saved_state.prepare" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error normalized_payload = normalize_object_selector_aliases(payload, method) if normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(timeout_seconds or 60) mode = str(payload.get("mode") or payload.get("execution_mode") or "plan").strip().casefold() if mode not in {"plan", "apply", "apply_and_verify"}: return invalid_argument(method, "mode", "Unsupported mode.", allowed_values=["plan", "apply", "apply_and_verify"]) repository_error = repository_apply_gate(payload, method, mode) if repository_error: return repository_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error include_storage = bool(include_storage) # Extension saved-state is a working overlay: a repeated prepare must add # only absent confirmed parts and must never overwrite existing work. merge_missing, merge_missing_error = strict_bool_argument( payload, "merge_missing", method=method, default=bool(str(payload.get("extension") or "").strip()), ) if merge_missing_error: return merge_missing_error merge_missing = bool(merge_missing) layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) requested_target_table = str(payload.get("target_table") or payload.get("table") or "").strip() if layer and requested_target_table and requested_target_table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with target_table.") target_table = requested_target_table or SAVED_STATE_TABLE_BY_LAYER.get(layer, "") source_table = str(payload.get("source_table") or "").strip() if not target_table: if source_table in SAVED_STATE_TARGET_BY_SOURCE: target_table = SAVED_STATE_TARGET_BY_SOURCE[source_table] elif str(payload.get("extension") or "").strip(): target_table = "ConfigCASSave" else: target_table = "ConfigSave" if target_table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "target_table", "target_table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) if str(payload.get("extension") or "").strip() and target_table != "ConfigCASSave": return invalid_argument(method, "layer", "Extension object preparation requires extension_saved_state.", allowed_values=["extension_saved_state"]) expected_source_table = SAVED_STATE_SOURCE_BY_TARGET[target_table] if source_table and source_table != expected_source_table: return invalid_argument(method, "source_table", f"source_table must be {expected_source_table} for {target_table}.", allowed_values=[expected_source_table]) source_table = expected_source_table stages: list[dict[str, Any]] = [] def complete_stage(name: str, started_at: float, status: str, **details: Any) -> None: stages.append({ "name": name, "status": status, "elapsed_ms": round((time.monotonic() - started_at) * 1000, 1), **details, }) stage_started = time.monotonic() file_names, object_card, file_error = saved_state_prepare_file_names(payload, base_id, source_table, timeout_seconds) complete_stage("resolve_source", stage_started, "error" if file_error else "ok", file_names=len(file_names)) if file_error: file_error.setdefault("stages", stages) return file_error if include_storage else public_saved_state_prepare_result( file_error, base_id=base_id, target_table=target_table, object_card=object_card, ) if not file_names: no_rows_result = { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "blocked_no_active_source_rows", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "table": source_table}, "target": {"table": target_table}, "object": object_card, "counts": {"file_names": 0}, "stages": stages, } return no_rows_result if include_storage else public_saved_state_prepare_result( no_rows_result, base_id=base_id, target_table=target_table, object_card=object_card, ) canonical_name_map = ( object_card.get("saved_state_name_map") if target_table == "ConfigCASSave" and isinstance(object_card, dict) and isinstance(object_card.get("saved_state_name_map"), dict) else {} ) saved_state_protocol = ( str(object_card.get("saved_state_protocol") or "").strip() if isinstance(object_card, dict) else "" ) # A same-key ConfigCAS -> ConfigCASSave copy is useful forensic evidence, # but it is not a proven Configurator working-copy protocol. In # `фс_Отчеты` it produced an exact SQL readback while Configurator continued # to select ConfigCAS. Do not report that result as a writable saved state # or let code.write mutate it. A write becomes available only after a # layout-specific reverse codec has been evidenced (for example the # canonical __configinfo paired-container codec) and supplies an explicit # target-name map. if ( target_table == "ConfigCASSave" and saved_state_protocol == "source_key_copy" and not canonical_name_map ): blocked = { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "extension_saved_state_prepare_protocol_unproven", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "table": source_table}, "target": {"table": target_table}, "object": object_card, "saved_state_protocol": saved_state_protocol, "diagnostics": { "message": "The hash-keyed extension layout has no proven reverse working-copy codec. The adapter refuses to create a ConfigCASSave copy that SQL can read but Configurator does not select.", "next_action": "adapter_protocol_development_required", }, "stages": stages, } return blocked if include_storage else public_saved_state_prepare_result( blocked, base_id=base_id, target_table=target_table, object_card=object_card, ) target_name_by_source = {str(name): str(canonical_name_map.get(name) or name) for name in file_names} target_file_name_list = [target_name_by_source[name] for name in file_names] if len(set(target_file_name_list)) != len(target_file_name_list): return invalid_argument(method, "extension", "Resolved extension save protocol maps active parts to duplicate Configurator descriptor names.") stage_started = time.monotonic() source_rows, config, source_error = saved_state_copy_row_details(base_id, source_table, file_names, timeout_seconds=timeout_seconds) complete_stage("inspect_active_source", stage_started, "error" if source_error else "ok", file_names=len(file_names), rows=len(source_rows or [])) if source_error: source_error.setdefault("stages", stages) return source_error if include_storage else public_saved_state_prepare_result( source_error, base_id=base_id, target_table=target_table, object_card=object_card, ) stage_started = time.monotonic() target_rows, _target_config, target_error = saved_state_copy_row_details(base_id, target_table, target_file_name_list, timeout_seconds=timeout_seconds) complete_stage("inspect_saved_target", stage_started, "error" if target_error else "ok", file_names=len(target_file_name_list), rows=len(target_rows or [])) if target_error: target_error.setdefault("stages", stages) return target_error if include_storage else public_saved_state_prepare_result( target_error, base_id=base_id, target_table=target_table, object_card=object_card, ) source_rows = source_rows or [] target_rows = target_rows or [] public_source_rows = [saved_state_row_public(row) for row in source_rows] for row in public_source_rows: source_name = str(row.get("file_name") or "") if source_name: row["source_file_name"] = source_name row["file_name"] = target_name_by_source.get(source_name, source_name) public_target_rows = [saved_state_row_public(row) for row in target_rows] target_file_names = {str(row.get("FileName") or row.get("file_name") or "") for row in target_rows} configinfo_targets = { target_name for target_name in target_file_name_list if target_name.lower().endswith("__configinfo") } missing_configinfo = sorted(name for name in configinfo_targets if name not in target_file_names) # A canonical mapping has already proved that its active root is the # ConfigInfo-compatible source for this exact extension/object route. It # must be inserted with the descriptor and module on the first overlay; # otherwise Configurator has no logical file map to select. An existing # saved ConfigInfo remains untouched during merge. if missing_configinfo and not canonical_name_map: blocked = { "schema": "onec_saved_state_prepare.v1", "method": method, "status": "extension_saved_state_prepare_protocol_unproven", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": source_table}, "target": {"table": target_table}, "object": object_card, "missing_configinfo": missing_configinfo, "diagnostics": {"message": "The extension saved-state route lacks a proven ConfigInfo-compatible source. The adapter refuses to invent a ConfigCASSave preparation transform."}, "stages": stages, } return blocked if include_storage else public_saved_state_prepare_result(blocked, base_id=base_id, target_table=target_table, object_card=object_card) copy_file_names = [ name for name in file_names if target_name_by_source[name] not in target_file_names ] copy_source_rows = [row for row in source_rows if str(row.get("FileName") or row.get("file_name") or "") in set(copy_file_names)] if source_rows and not target_rows: status = "plan_ready" elif target_rows and merge_missing and copy_source_rows: status = "plan_ready_merge" elif target_rows and merge_missing: status = "already_prepared" else: status = "blocked_target_collision" if target_rows else "blocked_no_active_source_rows" result: dict[str, Any] = { "schema": "onec_saved_state_prepare.v1", "status": status, "applied": False, "ready_to_copy": status in {"plan_ready", "plan_ready_merge"}, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": source_table}, "target": {"table": target_table}, "object": object_card, "file_names": target_file_name_list, "source_file_names": file_names, "copy_file_names": [target_name_by_source[name] for name in copy_file_names], "copy_source_file_names": copy_file_names, "saved_state_protocol": (object_card or {}).get("saved_state_protocol") if isinstance(object_card, dict) else None, "source_rows": public_source_rows, "target_collisions": public_target_rows, "counts": {"file_names": len(target_file_name_list), "copy_file_names": len(copy_file_names), "source_rows": len(source_rows), "copy_source_rows": len(copy_source_rows), "target_rows": len(target_rows)}, "write_mode": {"sql_write_performed": False, "requires_allow_flag": True}, "stages": stages, } def append_prepared_form_parts(*, verified: bool) -> None: """Expose the exact embedded form-module handle after a form copy. A managed-form BSL module is a stream inside the form payload, rather than a second ConfigCAS/ConfigCASSave row. Resolve it from the copied form record itself instead of making the caller rediscover storage. """ prepared_form = str(payload.get("form") or payload.get("form_name") or "").strip() prepared_extension = str(payload.get("extension") or "").strip() if target_table != "ConfigCASSave" or not prepared_extension or not prepared_form: return prepared_kind = str(payload.get("kind") or payload.get("object_type") or "").strip() prepared_name = str(payload.get("name") or payload.get("object_name") or "").strip() prepared_ref = str(payload.get("ref") or "").strip() if prepared_ref and not (prepared_kind and prepared_name): parsed_kind, parsed_name = parse_object_query(None, prepared_ref) prepared_kind = prepared_kind or str(parsed_kind or "") prepared_name = prepared_name or str(parsed_name or "") module_search = metadata_saved_state_modules_search( { "base_id": base_id, "layer": "extension_saved_state", "extension": prepared_extension, "object_type": prepared_kind, "object_name": prepared_name, "form": prepared_form, "limit": 10, "scan_limit": 10, "include_storage": True, "timeout_seconds": timeout_seconds, } ) streams = [ stream for module in module_search.get("modules") or [] if isinstance(module, dict) for stream in module.get("streams") or [] if isinstance(stream, dict) and stream.get("module_ref") ] module_refs = [str(stream["module_ref"]) for stream in streams] result["prepared_modules"] = { "status": "resolved" if len(module_refs) == 1 else ("not_found" if not module_refs else "ambiguous"), "module_refs": module_refs, "search_counts": module_search.get("counts"), "readback_verified": verified, } form_file_names = [str(name) for name in file_names if name] parts: list[dict[str, Any]] = [ { "part_kind": "form", "form": prepared_form, **({"saved_state_ref": f"ConfigCASSave:{form_file_names[0]}"} if len(form_file_names) == 1 else {}), } ] if len(streams) == 1: stream = streams[0] parts.append({ "part_kind": "form_module", "form": prepared_form, "module_ref": stream["module_ref"], "text_sha1": stream.get("text_sha1"), }) result["prepared_parts"] = parts if status == "already_prepared": # Idempotent prepare is also the normal continuation after an earlier # agent turn. Return the same verified module handle instead of # forcing the caller to issue a separate storage-oriented search. append_prepared_form_parts(verified=True) if mode == "plan" or status == "already_prepared" or status not in {"plan_ready", "plan_ready_merge"}: return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, target_table=target_table, object_card=object_card, ) allow_prepare, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_prepare", method=method, default=False) if allow_error: return allow_error if not allow_prepare: return invalid_argument(method, "allow_sql_saved_state_prepare", "Saved-state preparation writes SQL inserts; pass allow_sql_saved_state_prepare=true after reviewing the plan.") stage_started = time.monotonic() apply_result = apply_saved_state_prepare_copy( base_id, source_table, target_table, copy_file_names, expected_source_rows=len(copy_source_rows), prepared_rows=[ {**saved_state_row_public(row), "file_name": target_name_by_source.get(str(row.get("FileName") or row.get("file_name") or ""), str(row.get("FileName") or row.get("file_name") or ""))} for row in copy_source_rows ], target_name_by_source={name: target_name_by_source[name] for name in copy_file_names}, timeout_seconds=timeout_seconds, ) complete_stage("copy_atomic", stage_started, "ok" if apply_result.get("applied") else "error", inserted_rows=int((apply_result.get("counts") or {}).get("inserted_rows") or 0)) result["apply_result"] = apply_result result["applied"] = bool(apply_result.get("applied")) result["write_mode"]["sql_write_performed"] = bool(apply_result.get("applied")) result["status"] = apply_result.get("status") or "error" if result["applied"]: append_prepared_form_parts(verified=False) if mode == "apply" or not result["applied"]: return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, target_table=target_table, object_card=object_card, ) stage_started = time.monotonic() verify_rows, _verify_config, verify_error = saved_state_copy_row_details(base_id, target_table, target_file_name_list, timeout_seconds=timeout_seconds) complete_stage("verify_copy", stage_started, "error" if verify_error else "ok", rows=len(verify_rows or [])) if verify_error: result["status"] = "verify_error" result["verify_error"] = verify_error return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, target_table=target_table, object_card=object_card, ) # A merge must prove two different invariants: rows inserted by this call # must equal their active source, while pre-existing pending rows must stay # exactly as they were. In particular an extension __configinfo can be a # newer Configurator map than ConfigCAS, so comparing it with active bytes # rejects a correct overlay and leaves a successful insert marked failed. expected = { (target_name_by_source.get(str(row.get("FileName") or ""), str(row.get("FileName") or "")), row.get("PartNo")): {**saved_state_row_public(row), "file_name": target_name_by_source.get(str(row.get("FileName") or ""), str(row.get("FileName") or ""))} for row in copy_source_rows } expected.update({ (str(row.get("FileName") or row.get("file_name") or ""), row.get("PartNo") if "PartNo" in row else row.get("part_no")): saved_state_row_public(row) for row in target_rows }) actual = {(row.get("FileName"), row.get("PartNo")): saved_state_row_public(row) for row in (verify_rows or [])} mismatched = [ {"expected": expected[key], "actual": actual.get(key)} for key in sorted(expected) if actual.get(key) != expected[key] ] result["verification"] = { "status": "ok" if not mismatched and len(actual) == len(expected) else "mismatch", "expected_rows": len(expected), "actual_rows": len(actual), "mismatched": mismatched, } result["status"] = "verified" if result["verification"]["status"] == "ok" else "verification_failed" if result["status"] == "verified": append_prepared_form_parts(verified=True) return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, target_table=target_table, object_card=object_card, ) def metadata_saved_state_ensure(payload: dict[str, Any]) -> dict[str, Any]: """Idempotent public facade for creating a base or extension save layer. ``prepare`` is deliberately low-level enough to expose a copy plan. This facade is the normal agent entry point: it always uses merge-missing semantics, keeps the public selector intact, and makes it explicit that a missing save is an adapter operation, not a Configurator prerequisite. """ method = "metadata.saved_state.ensure" execution_mode = str(payload.get("execution_mode") or "").strip().casefold() if execution_mode: if execution_mode != "job": return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) return adapter_start_job({ "method": method, "payload": {key: value for key, value in payload.items() if key != "execution_mode"}, "timeout_seconds": payload.get("timeout_seconds") or 60, }) prepare_payload = dict(payload) prepare_payload.pop("execution_mode", None) prepare_payload["merge_missing"] = True result = metadata_saved_state_prepare(prepare_payload) status = str(result.get("status") or "error") mode = str(payload.get("mode") or "plan").strip().casefold() ready = status in {"already_prepared", "verified"} next_action: str if ready: next_action = "write_saved_state" elif status in {"plan_ready", "plan_ready_merge"}: next_action = "apply_with_allow_sql_saved_state_prepare" elif status == "blocked_target_collision": next_action = "review_existing_saved_state" else: next_action = "resolve_diagnostics" return { "schema": "onec_saved_state_ensure.v1", "method": method, "status": "ready" if ready else status, "base_id": result.get("base_id") or payload.get("base_id"), "mode": mode, "saved_state": { "ready": ready, "created": bool(result.get("applied")), "next_action": next_action, "manual_configurator_required": False, "manual_configurator_note": "A missing saved-state copy is created by the adapter; do not ask the user to create it in Configurator.", }, "prepare": result, } def metadata_saved_state_ensure_rollback(payload: dict[str, Any]) -> dict[str, Any]: """Rollback only an adapter-created ensure receipt, never active metadata.""" method = "metadata.saved_state.ensure.rollback" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error receipt_id = str(payload.get("prepare_receipt_id") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{32}", receipt_id): return invalid_argument(method, "prepare_receipt_id", "prepare_receipt_id must be the 32-character receipt returned by metadata.saved_state.ensure.") allowed, allowed_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allowed_error: return allowed_error if not allowed: return invalid_argument(method, "allow_sql_saved_state_rollback", "Rollback is opt-in; pass allow_sql_saved_state_rollback=true after verifying the test result.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=240) if timeout_error: return timeout_error result = rollback_saved_state_prepare_receipt(base_id_or_error, receipt_id, timeout_seconds=int(timeout_seconds or 60)) return { "schema": "onec_saved_state_ensure_rollback.v1", "method": method, "base_id": base_id_or_error, "prepare_receipt_id": receipt_id, **result, } def saved_state_diff_public_module_target( payload: dict[str, Any], base_id: str, target_table: str, timeout_seconds: int, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: source_table = SAVED_STATE_SOURCE_BY_TARGET[target_table] last_result: dict[str, Any] | None = None for table in (target_table, source_table): for refresh_cache in (False, True): module_result = read_module( { **payload, "base_id": base_id, "table": table, "include_storage": True, "include_text": False, "mode": "summary", "max_chars": 1, "refresh_cache": refresh_cache, "timeout_seconds": timeout_seconds, } ) last_result = module_result if module_result.get("status") != "ok": continue source = module_result.get("source") if isinstance(module_result.get("source"), dict) else {} module_id = str(module_result.get("module_id") or "").strip() module_table, module_file_name, stream_index = parse_module_id(module_id) file_name = str(source.get("file_name") or module_file_name or "").strip() if not file_name or Path(file_name).name != file_name: continue owner = module_result.get("owner") if isinstance(module_result.get("owner"), dict) else {} module = module_result.get("module") if isinstance(module_result.get("module"), dict) else {} module_ordinal = first_non_empty_arg( payload, "module_ordinal", "module_index", "module_number", default=module.get("module_ordinal") or 1, ) public_ref = str(payload.get("ref") or object_selector_ref(owner.get("kind"), owner.get("name")) or "").strip() selector = { **({"ref": public_ref} if public_ref else {}), **({"kind": owner.get("kind")} if owner.get("kind") else {}), **({"name": owner.get("name")} if owner.get("name") else {}), **({"module_ordinal": module_ordinal} if module else {}), **({"extension": payload.get("extension")} if payload.get("extension") else {}), } public_role = public_module_role( owner_kind=str(owner.get("kind") or ""), suffix=module_suffix_from_module_id(module_id), ordinal=int(module_ordinal or 1), current_name=str(owner.get("name") or "") if canonical_kind(str(owner.get("kind") or "")) == "CommonModule" else None, ) target_module_ref = f"{target_table}:{file_name}" if stream_index is not None: target_module_ref += f"#stream:{stream_index}" return { "file_name": file_name, "module_ref": target_module_ref, "stream_index": stream_index, "resolved_from_table": module_table or table, "cache_refresh_used": refresh_cache, "selector": selector, "object": {key: owner.get(key) for key in ("kind", "name", "synonym", "guid") if owner.get(key) is not None}, "module": { **{key: public_role.get(key) for key in ("kind", "name") if public_role.get(key) is not None}, **({"module_ordinal": module_ordinal} if module else {}), }, }, None return None, { "schema": "onec_saved_state_diff.v1", "method": SAVED_STATE_DIFF_METHOD, "status": str((last_result or {}).get("status") or "not_found"), "error": str((last_result or {}).get("error") or "module_not_found"), "base_id": base_id, "diagnostics": (last_result or {}).get("diagnostics") or {"message": "The named 1C object module was not resolved in saved or active metadata."}, } def bsl_module_text_comparison( active_text: str, saved_text: str, *, routine_name: str = "", include_text_diff: bool = True, max_text_diff_lines: int = 200, ) -> dict[str, Any]: active_normalized = str(active_text or "").replace("\r\n", "\n").replace("\r", "\n") saved_normalized = str(saved_text or "").replace("\r\n", "\n").replace("\r", "\n") scope = "module" selection: dict[str, Any] = {} if routine_name: active_selected, active_selection = _extract_bsl_routine_text_for_code_read(active_normalized, routine_name) saved_selected, saved_selection = _extract_bsl_routine_text_for_code_read(saved_normalized, routine_name) if active_selected and saved_selected: active_normalized = active_selected saved_normalized = saved_selected scope = "routine" selection = { "routine_name": routine_name, "active": active_selection, "saved": saved_selection, } active_sha1 = code_text_sha1(active_normalized) saved_sha1 = code_text_sha1(saved_normalized) diff_lines: list[str] = [] if include_text_diff and active_sha1 != saved_sha1 and int(max_text_diff_lines or 0) > 0: diff_lines = list( difflib.unified_diff( active_normalized.splitlines(), saved_normalized.splitlines(), fromfile="active", tofile="saved", lineterm="", n=3, ) )[: int(max_text_diff_lines or 200)] return { "scope": scope, "same": active_sha1 == saved_sha1, "differs": active_sha1 != saved_sha1, "active_sha1": active_sha1, "saved_sha1": saved_sha1, "active_chars": len(active_normalized), "saved_chars": len(saved_normalized), "diff_lines": diff_lines, "diff_truncated": bool(diff_lines) and len(diff_lines) >= int(max_text_diff_lines or 0), **({"selection": selection} if selection else {}), } def saved_state_module_text_comparison_from_bytes( *, active_bytes: bytes, saved_bytes: bytes, active_module_ref: str, saved_module_ref: str, routine_name: str, bsl_offset: int | None, include_text_diff: bool, max_text_diff_lines: int, ) -> dict[str, Any] | None: active_text, active_extraction = extract_code_index_text_from_payload( active_bytes, module_ref=active_module_ref, bsl_offset=bsl_offset, ) saved_text, saved_extraction = extract_code_index_text_from_payload( saved_bytes, module_ref=saved_module_ref, bsl_offset=bsl_offset, ) if active_extraction.get("status") != "ok" or saved_extraction.get("status") != "ok": return None return { **bsl_module_text_comparison( active_text, saved_text, routine_name=routine_name, include_text_diff=include_text_diff, max_text_diff_lines=max_text_diff_lines, ), "active_extraction": active_extraction, "saved_extraction": saved_extraction, } def saved_state_module_text_comparison_from_live_read( payload: dict[str, Any], *, base_id: str, module_ref: str, timeout_seconds: int, max_text_diff_lines: int, ) -> dict[str, Any] | None: compare_payload = { key: value for key, value in payload.items() if key not in {"table", "target_table", "source_table", "file_name", "configuration_view"} } compare_result = code_read( { **compare_payload, "base_id": base_id, "module_ref": module_ref, "state": "both", "include_text": True, "max_chars": 10_000_000, "timeout_seconds": timeout_seconds, } ) layers = compare_result.get("layers") if isinstance(compare_result.get("layers"), list) else [] saved_layer = next((item for item in layers if isinstance(item, dict) and item.get("source") == "saved_state"), None) active_layer = next((item for item in layers if isinstance(item, dict) and item.get("source") == "active"), None) if not isinstance(saved_layer, dict) or not isinstance(active_layer, dict): return None if not isinstance(saved_layer.get("text"), str) or not isinstance(active_layer.get("text"), str): return None comparison = bsl_module_text_comparison( active_layer["text"], saved_layer["text"], routine_name=str(payload.get("routine_name") or ""), include_text_diff=bool(payload.get("include_text_diff", True)), max_text_diff_lines=max_text_diff_lines, ) comparison["counterpart_resolution"] = active_layer.get("counterpart_resolution") return comparison def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_DIFF_METHOD payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error max_changes, max_changes_error = parse_int_argument(payload, "max_changes", method=method, default=200, minimum=1, maximum=5000) if max_changes_error: return max_changes_error max_text_diff_lines, max_text_diff_lines_error = parse_int_argument(payload, "max_text_diff_lines", method=method, default=200, minimum=0, maximum=5000) if max_text_diff_lines_error: return max_text_diff_lines_error include_payload_diff, include_payload_diff_error = strict_bool_argument(payload, "include_payload_diff", method=method, default=False) if include_payload_diff_error: return include_payload_diff_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error for argument, default in (("include_text_diff", True), ("include_tree_diff", True), ("include_evidence", False)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() module_table = module_file_name = None if module_ref: module_table, module_file_name, _stream_index = parse_module_id(module_ref) if not module_table or not module_file_name: return invalid_argument(method, "module_ref", "Use module_ref in the form
:[#stream:].") table = str(payload.get("table") or payload.get("target_table") or module_table or "").strip() public_selector_used = not module_ref and not str(payload.get("file_name") or "").strip() and has_object_selector(payload) if not table and public_selector_used: table = "ConfigCASSave" if str(payload.get("extension") or "").strip() else "ConfigSave" file_name = str(payload.get("file_name") or module_file_name or "").strip() if table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) resolved_target: dict[str, Any] | None = None if public_selector_used: resolved_target, resolution_error = saved_state_diff_public_module_target( payload, base_id, table, int(timeout_seconds or 30), ) if resolution_error: return resolution_error file_name = str((resolved_target or {}).get("file_name") or "").strip() module_ref = str((resolved_target or {}).get("module_ref") or "").strip() if not file_name or Path(file_name).name != file_name: return invalid_argument( method, "selector", "Pass a 1C object selector plus module_ordinal, or a generated module_ref/saved-state file_name.", ) source_table = str(payload.get("source_table") or SAVED_STATE_SOURCE_BY_TARGET[table]).strip() if source_table != SAVED_STATE_SOURCE_BY_TARGET[table]: return invalid_argument(method, "source_table", f"source_table must be {SAVED_STATE_SOURCE_BY_TARGET[table]} for {table}.") storage_target = {"table": table, "file_name": file_name, **({"module_ref": module_ref} if module_ref else {})} target = storage_target if public_selector_used: target = { "selector": (resolved_target or {}).get("selector") or {}, "object": (resolved_target or {}).get("object") or {}, "module": (resolved_target or {}).get("module") or {}, **({"storage": storage_target} if include_storage else {}), } active_source = {"table": source_table, "file_name": file_name} saved_bytes, config, saved_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if saved_error: status = "not_found" if saved_error.get("status") == "source_missing" else saved_error.get("status") or "error" return { "schema": "onec_saved_state_diff.v1", "method": method, "status": status, "error": "saved_state_not_found" if status == "not_found" else saved_error.get("error", "source_error"), "base_id": base_id, "target": target, "source": ( {"kind": "live_metadata", "active": {"state": "active"}} if public_selector_used and not include_storage else active_source ), "current_state": {"source": "active", "activation_state": "active"}, "needs_prepare": status == "not_found", "prepare_payload": ( { "method": "metadata.saved_state.prepare", "base_id": base_id, "layer": SAVED_STATE_LAYER_BY_TABLE[table], **((resolved_target or {}).get("selector") or {}), "mode": "plan", } if public_selector_used else { "method": "metadata.saved_state.prepare", "base_id": base_id, "target_table": table, "file_name": file_name, "mode": "plan", } ), "diagnostics": saved_error.get("diagnostics") or {"message": "Saved-state target was not found."}, } active_bytes, active_config, active_error = read_storage_file_bytes(base_id, source_table, file_name, timeout_seconds=int(timeout_seconds or 30)) if active_error: live_module_comparison = ( saved_state_module_text_comparison_from_live_read( payload, base_id=base_id, module_ref=module_ref, timeout_seconds=int(timeout_seconds or 30), max_text_diff_lines=int(max_text_diff_lines or 200), ) if module_ref else None ) if live_module_comparison: module_changed = live_module_comparison.get("differs") is True return { "schema": "onec_saved_state_diff.v1", "method": method, "status": "changed" if module_changed else "unchanged", "base_id": base_id, "target": target, "source": { "kind": "live_metadata", "active": {"state": "active"}, "saved": {"state": "saved"}, }, "current_state": { "source": "saved_state" if module_changed else "both", "activation_state": "not_activated" if module_changed else "same_as_active", }, "needs_prepare": False, "comparison": { "differs": module_changed, "text_same": live_module_comparison.get("same"), "module_text_same": live_module_comparison.get("same"), "container_text_same": None, "tree_same": None, "strings_same": None, }, "module_text": live_module_comparison, "freshness": { "source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True, "saved_payload_sha1": hashlib.sha1(saved_bytes or b"").hexdigest(), "active_payload_sha1": None, }, "diagnostics": { "message": "The active payload used a different physical reference; BSL was compared through the resolved logical module counterpart.", }, } status = "not_found" if active_error.get("status") == "source_missing" else active_error.get("status") or "error" return { "schema": "onec_saved_state_diff.v1", "method": method, "status": status, "error": "active_source_not_found" if status == "not_found" else active_error.get("error", "source_error"), "base_id": base_id, "target": target, "source": ( {"kind": "live_metadata", "active": {"state": "active"}} if public_selector_used and not include_storage else active_source ), "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "needs_prepare": False, "diagnostics": active_error.get("diagnostics") or {"message": "Active source payload was not found."}, } diff = payload_diff( { "base_id": base_id, "diagnostic": True, "before": {"payload_base64": base64.b64encode(active_bytes or b"").decode("ascii")}, "after": {"payload_base64": base64.b64encode(saved_bytes or b"").decode("ascii")}, "max_changes": int(max_changes or 200), "max_text_diff_lines": int(max_text_diff_lines or 200), "include_text_diff": bool(payload.get("include_text_diff", True)), "include_tree_diff": bool(payload.get("include_tree_diff", True)), "include_evidence": bool(payload.get("include_evidence", False)), "timeout_seconds": int(timeout_seconds or 30), } ) if diff.get("status") not in {"changed", "unchanged"}: return { "schema": "onec_saved_state_diff.v1", "method": method, "status": diff.get("status") or "error", "error": diff.get("error", "payload_diff_failed"), "base_id": base_id, "target": target, "source": ( {"kind": "live_metadata", "active": {"state": "active"}, "saved": {"state": "saved"}} if public_selector_used and not include_storage else active_source ), "needs_prepare": False, "diagnostics": diff.get("diagnostics") or diff, } module_text_comparison = None if module_ref: active_module_ref = code_read_active_counterpart_module_ref(module_ref) if active_module_ref: module_text_comparison = saved_state_module_text_comparison_from_bytes( active_bytes=active_bytes or b"", saved_bytes=saved_bytes or b"", active_module_ref=active_module_ref, saved_module_ref=module_ref, routine_name=str(payload.get("routine_name") or ""), bsl_offset=int(payload.get("bsl_offset")) if payload.get("bsl_offset") not in {None, ""} else None, include_text_diff=bool(payload.get("include_text_diff", True)), max_text_diff_lines=int(max_text_diff_lines or 200), ) container_text_same = (diff.get("text") or {}).get("same") if isinstance(diff.get("text"), dict) else None result: dict[str, Any] = { "schema": "onec_saved_state_diff.v1", "method": method, "status": diff.get("status"), "base_id": base_id, "source": ( { "kind": "live_metadata", "active": {"state": "active"}, "saved": {"state": "saved"}, **( { "storage": { "database": (config or active_config or {}).get("database"), "active": active_source, "saved": {"table": table, "file_name": file_name}, } } if include_storage else {} ), } if public_selector_used else { "kind": "live_sql", "database": (config or active_config or {}).get("database"), "active": active_source, "saved": {"table": table, "file_name": file_name}, } ), "target": target, **({"resolved_by": "public_object_module_selector"} if public_selector_used else {}), "current_state": { "source": "saved_state" if diff.get("status") == "changed" else "both", "activation_state": "not_activated" if diff.get("status") == "changed" else "same_as_active", }, "needs_prepare": False, "bytes": diff.get("bytes"), "sha1": diff.get("sha1"), "comparison": { "differs": diff.get("status") == "changed", "text_same": module_text_comparison.get("same") if module_text_comparison else container_text_same, "module_text_same": module_text_comparison.get("same") if module_text_comparison else None, "container_text_same": container_text_same, "tree_same": (diff.get("tree") or {}).get("same") if isinstance(diff.get("tree"), dict) else None, "strings_same": (diff.get("strings") or {}).get("same") if isinstance(diff.get("strings"), dict) else None, }, **({"module_text": module_text_comparison} if module_text_comparison else {}), "text": diff.get("text"), "tree": diff.get("tree"), "strings": diff.get("strings"), "counts": diff.get("counts"), "freshness": { "source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True, "active_payload_sha1": hashlib.sha1(active_bytes or b"").hexdigest(), "saved_payload_sha1": hashlib.sha1(saved_bytes or b"").hexdigest(), }, } if include_payload_diff: result["payload_diff"] = diff return result def saved_state_status_rows(base_id: str, table: str, *, prefix: str = "", limit: int = 500, timeout_seconds: int = 30) -> tuple[list[dict[str, Any]] | None, dict[str, str] | None, dict[str, Any] | None]: method = SAVED_STATE_STATUS_METHOD if table not in SAVED_STATE_SOURCE_BY_TARGET: return None, None, invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) conn, config, error = connect_live_sql(base_id, method, timeout_seconds=timeout_seconds) if error: return None, config, error rows: list[dict[str, Any]] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: if prefix: cursor.execute( f""" SELECT TOP ({int(limit)}) FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 FROM dbo.[{table}] WHERE FileName LIKE %s ORDER BY FileName, PartNo """, (f"{prefix}%",), ) else: cursor.execute( f""" SELECT TOP ({int(limit)}) FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 FROM dbo.[{table}] ORDER BY FileName, PartNo """ ) rows.extend({key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()) except Exception as exc: return None, config, { "schema": "onec_adapter_source_error.v1", "method": method, "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table}, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass return rows, config, None def metadata_saved_state_status(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_STATUS_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=500, minimum=1, maximum=5000) if limit_error: return limit_error include_files, include_files_error = strict_bool_argument(payload, "include_files", method=method, default=True) if include_files_error: return include_files_error include_unchanged, include_unchanged_error = strict_bool_argument(payload, "include_unchanged", method=method, default=True) if include_unchanged_error: return include_unchanged_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error include_storage = bool(include_storage) layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) requested_table = str(payload.get("table") or payload.get("target_table") or "").strip() if layer and requested_table and requested_table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with table.") table = requested_table or SAVED_STATE_TABLE_BY_LAYER.get(layer) or "ConfigSave" if table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) layer = SAVED_STATE_LAYER_BY_TABLE[table] prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") source_table = SAVED_STATE_SOURCE_BY_TARGET[table] saved_rows, config, rows_error = saved_state_status_rows(base_id, table, prefix=prefix, limit=int(limit or 500), timeout_seconds=int(timeout_seconds or 30)) if rows_error: return rows_error saved_rows = saved_rows or [] saved_public = [saved_state_row_public(row) for row in saved_rows] file_names = sorted({str(row.get("file_name") or "") for row in saved_public if row.get("file_name")}) active_rows, _active_config, active_error = saved_state_copy_row_details(base_id, source_table, file_names, timeout_seconds=int(timeout_seconds or 30)) if active_error: return active_error active_public = [saved_state_row_public(row) for row in (active_rows or [])] active_by_file: dict[str, list[dict[str, Any]]] = {} saved_by_file: dict[str, list[dict[str, Any]]] = {} for row in active_public: active_by_file.setdefault(str(row.get("file_name") or ""), []).append(row) for row in saved_public: saved_by_file.setdefault(str(row.get("file_name") or ""), []).append(row) files: list[dict[str, Any]] = [] counts = { "saved_rows": len(saved_public), "saved_files": len(saved_by_file), "active_rows": len(active_public), "changed_files": 0, "unchanged_files": 0, "saved_only_files": 0, "changed_rows": 0, } for file_name in sorted(saved_by_file): saved_file_rows = sorted(saved_by_file[file_name], key=lambda row: int(row.get("part_no") or 0)) active_file_rows = sorted(active_by_file.get(file_name, []), key=lambda row: int(row.get("part_no") or 0)) saved_map = {(row.get("part_no"), row.get("binary_sha1"), row.get("binary_bytes"), row.get("data_size")) for row in saved_file_rows} active_map = {(row.get("part_no"), row.get("binary_sha1"), row.get("binary_bytes"), row.get("data_size")) for row in active_file_rows} if not active_file_rows: status = "saved_only" counts["saved_only_files"] += 1 changed_parts = len(saved_file_rows) elif saved_map == active_map: status = "unchanged" counts["unchanged_files"] += 1 changed_parts = 0 else: status = "changed" counts["changed_files"] += 1 changed_parts = len(saved_map.symmetric_difference(active_map)) counts["changed_rows"] += changed_parts if include_files and (include_unchanged or status != "unchanged"): files.append( { "file_name": file_name, "status": status, "saved_rows": len(saved_file_rows), "active_rows": len(active_file_rows), "changed_parts": changed_parts, "saved_sha1": [row.get("binary_sha1") for row in saved_file_rows], "active_sha1": [row.get("binary_sha1") for row in active_file_rows], "diff_selector": { "method": SAVED_STATE_DIFF_METHOD, "base_id": base_id, "table": table, "file_name": file_name, }, } ) status = "empty" if not saved_public else ("changed" if counts["changed_files"] or counts["saved_only_files"] else "unchanged") storage_result = { "schema": "onec_saved_state_status.v1", "method": method, "status": status, "base_id": base_id, "source": { "kind": "live_sql", "database": (config or {}).get("database"), "saved": {"table": table}, "active": {"table": source_table}, }, "query": {"table": table, "source_table": source_table, "prefix": prefix or None, "limit": int(limit or 500)}, "current_state": {"source": "saved_state" if saved_public else "active", "activation_state": "not_activated" if saved_public else "active"}, "counts": counts, "files": files, "freshness": { "source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True, }, } if include_storage: return storage_result return { "schema": "onec_saved_state_status.v1", "method": method, "status": status, "base_id": base_id, "source": {"kind": "saved_state"}, "query": { "layer": layer, "limit": int(limit or 500), "include_storage": False, }, "layer": { "name": layer, "active_source": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[table], }, "current_state": storage_result["current_state"], "counts": { "saved_records": counts["saved_rows"], "saved_objects": counts["saved_files"], "active_records": counts["active_rows"], "changed_objects": counts["changed_files"], "unchanged_objects": counts["unchanged_files"], "saved_only_objects": counts["saved_only_files"], "changed_parts": counts["changed_rows"], }, "freshness": { "source": "saved_state", "status": "live_verified", "verified": True, }, "diagnostics": { "note": "Storage coordinates, file names, hashes, and diff selectors are hidden unless include_storage=true.", "changes_method": SAVED_STATE_CHANGES_LIST_METHOD, }, } def configuration_activation_layers(payload: dict[str, Any]) -> list[str]: layer = str(payload.get("layer") or "all").strip() if layer == "base_saved_state": return ["base_saved_state"] if layer == "extension_saved_state": return ["extension_saved_state"] return ["base_saved_state", "extension_saved_state"] def configuration_activation_status(payload: dict[str, Any]) -> dict[str, Any]: """Report the live saved-state/active boundary without activating anything.""" method = CONFIGURATION_ACTIVATION_STATUS_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=30, minimum=1, ) if timeout_error: return timeout_error limit, limit_error = parse_int_argument( payload, "limit", method=method, default=5000, minimum=1, maximum=5000, ) if limit_error: return limit_error include_files, include_files_error = strict_bool_argument( payload, "include_files", method=method, default=False, ) if include_files_error: return include_files_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error include_storage = bool(include_storage) layers: list[dict[str, Any]] = [] upstream_errors: list[dict[str, Any]] = [] total_changed = 0 total_saved_only = 0 total_changed_parts = 0 scan_complete = True for layer in configuration_activation_layers(payload): status_result = metadata_saved_state_status( { "base_id": base_id, "layer": layer, "limit": int(limit or 5000), "timeout_seconds": int(timeout_seconds or 30), "include_files": bool(include_files and include_storage), "include_unchanged": False, "include_storage": include_storage, } ) result_status = str(status_result.get("status") or "") if result_status not in {"empty", "unchanged", "changed"}: upstream_errors.append( { "layer": layer, "status": result_status or "error", "error": status_result.get("error"), "diagnostics": status_result.get("diagnostics"), } ) continue counts = status_result.get("counts") if isinstance(status_result.get("counts"), dict) else {} changed = int(counts.get("changed_files", counts.get("changed_objects", 0)) or 0) saved_only = int(counts.get("saved_only_files", counts.get("saved_only_objects", 0)) or 0) changed_parts = int(counts.get("changed_rows", counts.get("changed_parts", 0)) or 0) saved_records = int(counts.get("saved_rows", counts.get("saved_records", 0)) or 0) layer_scan_complete = saved_records < int(limit or 5000) pending = changed + saved_only total_changed += changed total_saved_only += saved_only total_changed_parts += changed_parts scan_complete = scan_complete and layer_scan_complete layer_result: dict[str, Any] = { "layer": layer, "active_layer": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[SAVED_STATE_TABLE_BY_LAYER[layer]], "status": "activation_required" if pending else ("up_to_date" if layer_scan_complete else "inconclusive"), "activation_required": bool(pending), "scan_complete": layer_scan_complete, "counts": { "changed_objects": changed, "saved_only_objects": saved_only, "pending_objects": pending, "changed_parts": changed_parts, "saved_records_checked": saved_records, }, "freshness": status_result.get("freshness"), } if include_storage: layer_result["storage"] = { "source": status_result.get("source"), "query": status_result.get("query"), "files": status_result.get("files") if include_files else None, } layers.append(layer_result) if upstream_errors: overall_status = "error" activation_required: bool | None = None elif total_changed or total_saved_only: overall_status = "activation_required" activation_required = True elif not scan_complete: overall_status = "inconclusive" activation_required = None else: overall_status = "up_to_date" activation_required = False return { "schema": "onec_configuration_activation_status.v1", "method": method, "status": overall_status, "base_id": base_id, "activation_required": activation_required, "boundary": { "working": "saved_state", "runtime": "active", "direction": "saved_state_to_active", }, "scope": { "layer": str(payload.get("layer") or "all").strip(), "layers_checked": [item["layer"] for item in layers], }, "counts": { "changed_objects": total_changed, "saved_only_objects": total_saved_only, "pending_objects": total_changed + total_saved_only, "changed_parts": total_changed_parts, }, "layers": layers, "freshness": { "source": "live_sql", "status": "live_verified" if not upstream_errors else "error", "uses_cache": False, "uses_vector_index": False, "scan_complete": scan_complete and not upstream_errors, }, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, "diagnostics": { "read_only": True, "upstream_errors": upstream_errors, "note": ( "This method reports the activation boundary only. It does not update the database " "configuration and does not start 1C Designer." ), }, } def configuration_activation_plan(payload: dict[str, Any]) -> dict[str, Any]: """Build a reviewable handoff plan; never execute 1C Designer.""" method = CONFIGURATION_ACTIVATION_PLAN_METHOD status_payload = dict(payload) status_result = configuration_activation_status(status_payload) if status_result.get("schema") != "onec_configuration_activation_status.v1": return status_result base_id = str(status_result.get("base_id") or "") activation_required = status_result.get("activation_required") status = str(status_result.get("status") or "") layer = str((status_result.get("scope") or {}).get("layer") or "all") verification_payload = { "base_id": base_id, "layer": layer, "limit": int(payload.get("limit") or 5000), } review_payload: dict[str, Any] = { "base_id": base_id, "include_context": True, "group_by_context": True, "context_limit": 100, } if layer in {"base_saved_state", "extension_saved_state"}: review_payload["layer"] = layer if status == "error": plan_status = "blocked" next_step: dict[str, Any] | None = { "kind": "adapter_call", "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, "params": verification_payload, "reason": "live_status_failed", } elif status == "inconclusive": plan_status = "blocked" next_step = { "kind": "adapter_call", "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, "params": verification_payload, "reason": "saved_state_scan_incomplete", } elif activation_required: plan_status = "manual_activation_required" next_step = { "kind": "manual_1c_designer_action", "action": "update_database_configuration", "presentation": "Открыть базу в Конфигураторе и выполнить «Обновить конфигурацию базы данных» (F7).", "base_id": base_id, "scope": status_result.get("scope"), "confirmation_required": True, } else: plan_status = "not_required" next_step = None return { "schema": "onec_configuration_activation_plan.v1", "method": method, "status": plan_status, "base_id": base_id, "activation_required": activation_required, "activation_status": status_result, "bridge": { "mode": "status_plan_and_debug_acceptance", "external_1c_enabled": repository_control.external_1c_enabled(), "debug_acceptance_available": True, "activation_execution_available": False, "reason": "designer_activation_bridge_not_implemented", }, "review_call": { "method": SAVED_STATE_CHANGES_LIST_METHOD, "params": review_payload, } if activation_required else None, "debug_request_call": { "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, "params": { "base_id": base_id, "layer": layer, "limit": int(payload.get("limit") or 5000), }, } if activation_required else None, "next_step": next_step, "verification_call": { "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, "params": verification_payload, "expected_status_after_activation": "up_to_date", } if activation_required else None, "execution": { "performed": False, "allowed": False, "confirmation_required_for_future_execution": True, "active_configuration_changed": False, }, "diagnostics": { "read_only": True, "note": ( "The plan is an explicit handoff boundary. A future execution method must use separate " "confirmation and a dedicated 1C Designer bridge." ), }, } def configuration_activation_state_path() -> Path: """Legacy JSON path used only for one-time migration to local SQLite.""" return Path( os.environ.get("ONEC_CONFIGURATION_ACTIVATION_STATE_FILE") or "/data/onec-configuration-activation-requests.json" ) def configuration_activation_load_legacy_state() -> dict[str, Any]: path = configuration_activation_state_path() if not path.exists(): return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} try: value = json.loads(path.read_text(encoding="utf-8-sig")) except (OSError, json.JSONDecodeError, UnicodeDecodeError): return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} if not isinstance(value, dict): return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} requests = value.get("requests") value["requests"] = requests if isinstance(requests, dict) else {} value["schema"] = "onec_configuration_activation_requests.v1" return value def configuration_activation_sync_state( conn: sqlite3.Connection, state: dict[str, Any], ) -> None: safe_state = adapter_state_safe_value(state) requests = safe_state.get("requests") if isinstance(safe_state, dict) else {} requests = requests if isinstance(requests, dict) else {} conn.execute("DELETE FROM configuration_activation_events") conn.execute("DELETE FROM configuration_activation_requests") for request_id, raw_request in requests.items(): if not isinstance(raw_request, dict): continue request = dict(raw_request) events = request.pop("events", []) created_at = float(request.get("created_at") or adapter_now()) expires_at = float(request.get("expires_at") or created_at) updated_candidates = [ request.get(key) for key in ( "updated_at", "verified_at", "last_verified_at", "changed_since_request_at", "accepted_at", "cancelled_at", "expired_at", "stale_at", "created_at", ) if request.get(key) is not None ] updated_at = max((float(value) for value in updated_candidates), default=created_at) conn.execute( """ INSERT INTO configuration_activation_requests( request_id, base_id, layer, fingerprint, status, created_at, expires_at, updated_at, payload_json ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(request_id), str(request.get("base_id") or ""), str(request.get("layer") or "all"), str(request.get("fingerprint") or ""), str(request.get("status") or "unknown"), created_at, expires_at, updated_at, json.dumps(request, ensure_ascii=False, separators=(",", ":"), default=str), ), ) for event in events if isinstance(events, list) else []: if not isinstance(event, dict): continue occurred_at = float(event.get("at") or updated_at) details = { key: value for key, value in event.items() if key not in {"event", "at"} } conn.execute( """ INSERT INTO configuration_activation_events( request_id, event, occurred_at, details_json ) VALUES(?, ?, ?, ?) """, ( str(request_id), str(event.get("event") or "unknown"), occurred_at, json.dumps(details, ensure_ascii=False, separators=(",", ":"), default=str), ), ) def configuration_activation_read_state_from_connection( conn: sqlite3.Connection, ) -> dict[str, Any]: result = {"schema": "onec_configuration_activation_requests.v1", "requests": {}} migration_row = conn.execute( "SELECT value FROM adapter_state_meta " "WHERE key='legacy_activation_requests_imported'" ).fetchone() if not migration_row: existing = conn.execute( "SELECT COUNT(*) AS count FROM configuration_activation_requests" ).fetchone() if not existing or int(existing["count"] or 0) == 0: legacy = configuration_activation_load_legacy_state() if (legacy.get("requests") or {}): configuration_activation_sync_state(conn, legacy) conn.execute( """ INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) VALUES('legacy_activation_requests_imported', ?, ?) """, ("yes", adapter_now()), ) rows = conn.execute( """ SELECT request_id, payload_json FROM configuration_activation_requests ORDER BY created_at, request_id """ ).fetchall() for row in rows: try: request = json.loads(str(row["payload_json"] or "")) except (json.JSONDecodeError, TypeError): continue if isinstance(request, dict): request["events"] = [] result["requests"][str(row["request_id"])] = request event_rows = conn.execute( """ SELECT request_id, event, occurred_at, details_json FROM configuration_activation_events ORDER BY event_id """ ).fetchall() for row in event_rows: request = result["requests"].get(str(row["request_id"])) if not isinstance(request, dict): continue try: details = json.loads(str(row["details_json"] or "{}")) except (json.JSONDecodeError, TypeError): details = {} event = { "event": str(row["event"] or "unknown"), "at": float(row["occurred_at"] or 0), **(details if isinstance(details, dict) else {}), } request["events"].append(event) return result def configuration_activation_read_state() -> dict[str, Any]: try: with adapter_state_connection() as conn: state = configuration_activation_read_state_from_connection(conn) conn.commit() return state except Exception: return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} def configuration_activation_write_state(state: dict[str, Any]) -> None: with adapter_state_connection() as conn: configuration_activation_sync_state(conn, state) conn.execute( """ INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) VALUES('legacy_activation_requests_imported', ?, ?) """, ("yes", adapter_now()), ) conn.commit() @contextmanager def configuration_activation_state_transaction(): """Serialize one activation read/modify/write cycle across processes.""" with CONFIGURATION_ACTIVATION_STATE_LOCK: with adapter_state_connection() as conn: conn.execute("BEGIN IMMEDIATE") try: state = configuration_activation_read_state_from_connection(conn) yield state configuration_activation_sync_state(conn, state) conn.execute( """ INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) VALUES('legacy_activation_requests_imported', ?, ?) """, ("yes", adapter_now()), ) conn.commit() except Exception: conn.rollback() raise def configuration_activation_status_fingerprint(status_result: dict[str, Any]) -> str: layers = [] for layer in status_result.get("layers") or []: if not isinstance(layer, dict): continue storage = layer.get("storage") if isinstance(layer.get("storage"), dict) else {} files = [] for item in storage.get("files") or []: if not isinstance(item, dict): continue files.append( { "file_name": item.get("file_name"), "status": item.get("status"), "changed_parts": item.get("changed_parts"), "saved_sha1": item.get("saved_sha1"), "active_sha1": item.get("active_sha1"), } ) layers.append( { "layer": layer.get("layer"), "status": layer.get("status"), "counts": layer.get("counts"), "files": sorted(files, key=lambda item: str(item.get("file_name") or "")), } ) evidence = { "base_id": status_result.get("base_id"), "scope": status_result.get("scope"), "counts": status_result.get("counts"), "layers": layers, } encoded = json.dumps( evidence, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def configuration_activation_public_status(status_result: dict[str, Any]) -> dict[str, Any]: public = copy.deepcopy(status_result) for layer in public.get("layers") or []: if isinstance(layer, dict): layer.pop("storage", None) return public def configuration_activation_request(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_REQUEST_METHOD base_id = str(payload.get("base_id") or "").strip() ttl_default = 1800 try: ttl_default = int(os.environ.get("ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS") or 1800) except ValueError: ttl_default = 1800 ttl_default = min(86400, max(60, ttl_default)) ttl_seconds, ttl_error = parse_int_argument( payload, "ttl_seconds", method=method, default=ttl_default, minimum=60, maximum=86400, ) if ttl_error: return ttl_error status_result = configuration_activation_status( { "base_id": base_id, "layer": str(payload.get("layer") or "all"), "limit": int(payload.get("limit") or 5000), "timeout_seconds": int(payload.get("timeout_seconds") or 30), "include_files": True, "include_storage": True, } ) public_status = configuration_activation_public_status(status_result) if status_result.get("status") == "up_to_date": return { "schema": "onec_configuration_activation_request.v1", "method": method, "status": "not_required", "base_id": base_id, "activation_status": public_status, "execution": {"performed": False, "active_configuration_changed": False}, } if status_result.get("status") != "activation_required": return { "schema": "onec_configuration_activation_request.v1", "method": method, "status": "blocked", "base_id": base_id, "error": "activation_status_not_ready", "activation_status": public_status, "execution": {"performed": False, "active_configuration_changed": False}, } now = time.time() request_id = f"actreq-{uuid.uuid4().hex}" fingerprint = configuration_activation_status_fingerprint(status_result) request = { "request_id": request_id, "base_id": base_id, "layer": str(payload.get("layer") or "all"), "fingerprint": fingerprint, "created_at": now, "expires_at": now + int(ttl_seconds or ttl_default), "status": "pending", "events": [{"event": "requested", "at": now}], } with configuration_activation_state_transaction() as state: state["requests"][request_id] = request return { "schema": "onec_configuration_activation_request.v1", "method": method, "status": "pending", "base_id": base_id, "request_id": request_id, "layer": request["layer"], "fingerprint": fingerprint, "created_at": datetime.fromtimestamp(now, timezone.utc).isoformat(), "expires_at": datetime.fromtimestamp(request["expires_at"], timezone.utc).isoformat(), "activation_status": public_status, "next_call": { "method": CONFIGURATION_ACTIVATION_EXECUTE_METHOD, "params": { "base_id": base_id, "request_id": request_id, "mode": "debug", "confirm_activation": True, }, }, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } def configuration_activation_request_status(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_REQUEST_STATUS_METHOD request_id = str(payload.get("request_id") or "").strip() expected_base_id = str(payload.get("base_id") or "").strip() with configuration_activation_state_transaction() as state: request = state["requests"].get(request_id) if not isinstance(request, dict): return { "schema": "onec_configuration_activation_request_status.v1", "method": method, "status": "not_found", "request_id": request_id, } if expected_base_id and expected_base_id != str(request.get("base_id") or ""): return { "schema": "onec_configuration_activation_request_status.v1", "method": method, "status": "blocked", "error": "base_id_mismatch", "request_id": request_id, } if request.get("status") == "pending" and time.time() >= float(request.get("expires_at") or 0): request["status"] = "expired" request["expired_at"] = time.time() request.setdefault("events", []).append({"event": "expired", "at": request["expired_at"]}) public = { key: value for key, value in request.items() if key in { "request_id", "base_id", "layer", "fingerprint", "status", "created_at", "expires_at", "accepted_at", "expired_at", "verified_at", "last_verified_at", "changed_since_request_at", "events", } } for key in ( "created_at", "expires_at", "accepted_at", "expired_at", "verified_at", "last_verified_at", "changed_since_request_at", ): if public.get(key) is not None: public[key] = datetime.fromtimestamp(float(public[key]), timezone.utc).isoformat() public["events"] = [ { **event, "at": datetime.fromtimestamp(float(event["at"]), timezone.utc).isoformat(), } for event in public.get("events") or [] if isinstance(event, dict) and event.get("at") is not None ] return { "schema": "onec_configuration_activation_request_status.v1", "method": method, **public, } def configuration_activation_request_cancel(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_REQUEST_CANCEL_METHOD request_id = str(payload.get("request_id") or "").strip() expected_base_id = str(payload.get("base_id") or "").strip() if payload.get("confirm_cancel") is not True: return { "schema": "onec_configuration_activation_request_cancel.v1", "method": method, "status": "confirmation_required", "error": "confirm_cancel_required", "request_id": request_id, } with configuration_activation_state_transaction() as state: request = state["requests"].get(request_id) if not isinstance(request, dict): return { "schema": "onec_configuration_activation_request_cancel.v1", "method": method, "status": "not_found", "request_id": request_id, } if expected_base_id and expected_base_id != str(request.get("base_id") or ""): return { "schema": "onec_configuration_activation_request_cancel.v1", "method": method, "status": "blocked", "error": "base_id_mismatch", "request_id": request_id, } if request.get("status") == "cancelled": cancelled_at = float(request.get("cancelled_at") or time.time()) else: cancelled_at = time.time() previous_status = str(request.get("status") or "unknown") request["status"] = "cancelled" request["cancelled_at"] = cancelled_at request.setdefault("events", []).append( { "event": "cancelled", "at": cancelled_at, "previous_status": previous_status, } ) return { "schema": "onec_configuration_activation_request_cancel.v1", "method": method, "status": "cancelled", "base_id": request.get("base_id"), "request_id": request_id, "previous_status": previous_status if "previous_status" in locals() else "cancelled", "cancelled_at": datetime.fromtimestamp(cancelled_at, timezone.utc).isoformat(), "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } def configuration_activation_audit(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_AUDIT_METHOD base_id = str(payload.get("base_id") or "").strip() limit = int(payload.get("limit") or 100) status_filter = str(payload.get("status") or "").strip() now = time.time() changed = False with configuration_activation_state_transaction() as state: requests = [] for request in state["requests"].values(): if not isinstance(request, dict) or str(request.get("base_id") or "") != base_id: continue if request.get("status") == "pending" and now >= float(request.get("expires_at") or 0): request["status"] = "expired" request["expired_at"] = now request.setdefault("events", []).append({"event": "expired", "at": now}) changed = True if status_filter and str(request.get("status") or "") != status_filter: continue requests.append(copy.deepcopy(request)) requests.sort(key=lambda item: float(item.get("created_at") or 0), reverse=True) requests = requests[:limit] public_requests = [] for request in requests: public_events = [] for event in request.get("events") or []: if not isinstance(event, dict): continue public_event = { key: value for key, value in event.items() if key in {"event", "at", "previous_status"} } if public_event.get("at") is not None: public_event["at"] = datetime.fromtimestamp( float(public_event["at"]), timezone.utc, ).isoformat() public_events.append(public_event) public_request = { key: value for key, value in request.items() if key in { "request_id", "base_id", "layer", "status", "created_at", "expires_at", "accepted_at", "expired_at", "cancelled_at", "stale_at", "verified_at", "last_verified_at", "changed_since_request_at", } } for key in ( "created_at", "expires_at", "accepted_at", "expired_at", "cancelled_at", "stale_at", "verified_at", "last_verified_at", "changed_since_request_at", ): if public_request.get(key) is not None: public_request[key] = datetime.fromtimestamp( float(public_request[key]), timezone.utc, ).isoformat() public_request["events"] = public_events public_requests.append(public_request) status_counts: dict[str, int] = {} for request in public_requests: request_status = str(request.get("status") or "unknown") status_counts[request_status] = status_counts.get(request_status, 0) + 1 return { "schema": "onec_configuration_activation_audit.v1", "method": method, "status": "ok", "base_id": base_id, "query": { "limit": limit, "status": status_filter or None, }, "count": len(public_requests), "status_counts": status_counts, "requests": public_requests, "diagnostics": { "source": "adapter_local_sqlite", "fingerprint_evidence_exposed": False, "active_configuration_read": False, }, } def configuration_activation_capabilities(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_CAPABILITIES_METHOD base_id = str(payload.get("base_id") or "").strip() layer = str(payload.get("layer") or "all").strip() config, config_error = repository_control.repository_config(base_id, "base") runner = config.get("runner") if isinstance(config, dict) and isinstance(config.get("runner"), dict) else {} infobase = config.get("infobase") if isinstance(config, dict) and isinstance(config.get("infobase"), dict) else {} runner_kind = str(runner.get("kind") or "") if config else None if runner_kind == "local": runner_ready = bool( str(config.get("designer_path") or "").strip() and sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1 ) elif runner_kind == "http": runner_ready = bool(str(runner.get("url") or "").strip()) else: runner_ready = False return { "schema": "onec_configuration_activation_capabilities.v1", "method": method, "status": "ok", "base_id": base_id, "scope": {"layer": layer}, "workflow": { "status": True, "plan": True, "fingerprinted_request": True, "request_status": True, "request_cancel": True, "audit": True, "bridge_probe": True, "debug_acceptance": True, "post_activation_verify": True, "real_execution": False, }, "layers": { "base_saved_state": { "status": "debug_only", "designer_operation": "/UpdateDBCfg", "operation_documented": True, "execution_implemented": False, }, "extension_saved_state": { "status": "manual_only", "designer_operation": None, "operation_documented": False, "execution_implemented": False, "reason": "verified_extension_activation_command_not_implemented", }, }, "runner": { "configured": config_error is None and isinstance(config, dict), "kind": runner_kind, "ready_for_future_base_activation": runner_ready, "external_1c_enabled": repository_control.external_1c_enabled(), "designer_path_configured": bool( isinstance(config, dict) and str(config.get("designer_path") or "").strip() ), "infobase_selector_configured": bool( isinstance(config, dict) and sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1 ), "problem": config_error, }, "bridge_probe_call": { "method": CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD, "params": {"base_id": base_id, "layer": layer}, }, "state_store": { "kind": "local_sqlite", "requests_table": "configuration_activation_requests", "events_table": "configuration_activation_events", "onec_database_used": False, "legacy_json_write_enabled": False, }, "safety": { "credentials_exposed": False, "runner_url_exposed": False, "designer_path_exposed": False, "active_configuration_write_enabled": False, "required_real_execution_gates": [ "dedicated_enable_flag", "fingerprinted_request", "fresh_live_sql_recheck", "explicit_confirmation", "post_execution_live_verification", ], }, } def configuration_activation_bridge_probe(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD base_id = str(payload.get("base_id") or "").strip() layer = str(payload.get("layer") or "all").strip() timeout_seconds = int(payload.get("timeout_seconds") or 10) config, config_error = repository_control.repository_config(base_id, "base") if config_error or not isinstance(config, dict): return { "schema": "onec_configuration_activation_bridge_probe.v1", "method": method, "status": "not_configured", "base_id": base_id, "layer": layer, "problem": { "status": (config_error or {}).get("status") or "not_configured", }, "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, } probe = repository_control.activation_debug_probe( base_id, config, layer=layer, timeout_seconds=timeout_seconds, ) runner = probe.get("runner") if isinstance(probe.get("runner"), dict) else {} operation = probe.get("operation") if isinstance(probe.get("operation"), dict) else {} probe_status = str(probe.get("status") or "runner_error") public_status = probe_status if probe_status in {"ready", "not_ready"} else "runner_error" return { "schema": "onec_configuration_activation_bridge_probe.v1", "method": method, "status": public_status, "base_id": base_id, "layer": layer, "runner": { key: value for key, value in runner.items() if key in { "kind", "reachable", "designer_path_configured", "designer_available", "infobase_selector_configured", } }, "operation": { key: value for key, value in operation.items() if key in {"kind", "execution_supported", "extension_manual_only"} }, "external_1c_enabled": repository_control.external_1c_enabled(), "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, "diagnostics": { "infobase_credentials_read": False, "repository_credentials_read": False, "runner_authentication_used": runner.get("kind") == "http", "credentials_exposed": False, "paths_exposed": False, "urls_exposed": False, "raw_runner_status": probe_status, }, } def configuration_activation_execute(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_EXECUTE_METHOD base_id = str(payload.get("base_id") or "").strip() request_id = str(payload.get("request_id") or "").strip() mode = str(payload.get("mode") or "debug").strip().casefold() if mode != "debug": return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_execution_not_available", "allowed_modes": ["debug"], "execution": {"performed": False, "active_configuration_changed": False}, } if payload.get("confirm_activation") is not True: return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "confirmation_required", "error": "confirm_activation_required", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } with configuration_activation_state_transaction() as state: request = state["requests"].get(request_id) if not isinstance(request, dict): return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "not_found", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } if base_id != str(request.get("base_id") or ""): return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "base_id_mismatch", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } if request.get("status") == "pending" and time.time() >= float(request.get("expires_at") or 0): request["status"] = "expired" request["expired_at"] = time.time() request.setdefault("events", []).append({"event": "expired", "at": request["expired_at"]}) if request.get("status") == "expired": return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_request_expired", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } expected_fingerprint = str(request.get("fingerprint") or "") layer = str(request.get("layer") or "all") status_result = configuration_activation_status( { "base_id": base_id, "layer": layer, "limit": 5000, "timeout_seconds": int(payload.get("timeout_seconds") or 30), "include_files": True, "include_storage": True, } ) public_status = configuration_activation_public_status(status_result) if status_result.get("status") != "activation_required": return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_status_changed", "request_id": request_id, "activation_status": public_status, "next_call": { "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, "params": {"base_id": base_id, "layer": layer}, }, "execution": {"performed": False, "active_configuration_changed": False}, } current_fingerprint = configuration_activation_status_fingerprint(status_result) if not hmac.compare_digest(expected_fingerprint, current_fingerprint): with configuration_activation_state_transaction() as state: stored = state["requests"].get(request_id) if isinstance(stored, dict): stored["status"] = "stale" stored["stale_at"] = time.time() stored.setdefault("events", []).append({"event": "fingerprint_changed", "at": stored["stale_at"]}) return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_fingerprint_changed", "request_id": request_id, "expected_fingerprint": expected_fingerprint, "current_fingerprint": current_fingerprint, "activation_status": public_status, "next_call": { "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, "params": {"base_id": base_id, "layer": layer}, }, "execution": {"performed": False, "active_configuration_changed": False}, } bridge_debug_requested = payload.get("bridge_debug") is True bridge_debug_result = None if bridge_debug_requested: config, config_error = repository_control.repository_config(base_id, "base") if config_error or not isinstance(config, dict): return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_bridge_not_configured", "request_id": request_id, "bridge": { "status": (config_error or {}).get("status") or "not_configured", "debug_requested": True, }, "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, } bridge_probe = repository_control.activation_debug_probe( base_id, config, layer=layer, timeout_seconds=int(payload.get("timeout_seconds") or 30), request_id=request_id, fingerprint=current_fingerprint, ) bridge_acceptance = ( bridge_probe.get("debug_acceptance") if isinstance(bridge_probe.get("debug_acceptance"), dict) else {} ) bridge_receipt = str(bridge_acceptance.get("receipt") or "").strip().casefold() bridge_request_id = str(bridge_acceptance.get("request_id") or "") bridge_fingerprint = str(bridge_acceptance.get("fingerprint") or "").strip().casefold() bridge_ready = str(bridge_probe.get("status") or "") == "ready" bridge_accepted = bridge_acceptance.get("accepted") is True receipt_valid = bool( re.fullmatch(r"[0-9a-f]{64}", bridge_receipt) and hmac.compare_digest(bridge_request_id, request_id) and hmac.compare_digest(bridge_fingerprint, current_fingerprint) ) if not bridge_ready or not bridge_accepted or not receipt_valid: return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "blocked", "error": "activation_bridge_debug_not_accepted", "request_id": request_id, "bridge": { "status": str(bridge_probe.get("status") or "runner_error"), "debug_requested": True, "accepted": bridge_accepted, "receipt_valid": receipt_valid, }, "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, } bridge_runner = ( bridge_probe.get("runner") if isinstance(bridge_probe.get("runner"), dict) else {} ) bridge_debug_result = { "status": "accepted", "runner_kind": bridge_runner.get("kind"), "request_id": request_id, "fingerprint": current_fingerprint, "receipt": bridge_receipt, "execution_performed": False, "designer_started": False, } accepted_at = time.time() with configuration_activation_state_transaction() as state: stored = state["requests"].get(request_id) if isinstance(stored, dict): stored["status"] = "debug_accepted" stored["accepted_at"] = accepted_at stored.setdefault("events", []).append({"event": "debug_accepted", "at": accepted_at}) if bridge_debug_result: stored["bridge_debug_receipt"] = bridge_debug_result["receipt"] stored.setdefault("events", []).append({"event": "bridge_debug_accepted", "at": accepted_at}) extension_in_scope = layer in {"all", "extension_saved_state"} command_preview = { "runner": "1c_designer", "mode": "debug", "operation": "manual_extension_database_configuration_update" if extension_in_scope else "/UpdateDBCfg", "layer": layer, "executable": False, "reason": "extension_activation_cli_not_implemented" if extension_in_scope else "real_execution_disabled", } return { "schema": "onec_configuration_activation_execute.v1", "method": method, "status": "debug_accepted", "base_id": base_id, "request_id": request_id, "layer": layer, "fingerprint": current_fingerprint, "accepted_at": datetime.fromtimestamp(accepted_at, timezone.utc).isoformat(), "activation_status": public_status, "command_preview": command_preview, "bridge_debug": bridge_debug_result if bridge_debug_result else { "status": "not_requested", "execution_performed": False, "designer_started": False, }, "verification_call": { "method": CONFIGURATION_ACTIVATION_VERIFY_METHOD, "params": {"base_id": base_id, "request_id": request_id}, "expected_status_after_real_activation": "verified_up_to_date", }, "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, } def configuration_activation_verify(payload: dict[str, Any]) -> dict[str, Any]: method = CONFIGURATION_ACTIVATION_VERIFY_METHOD base_id = str(payload.get("base_id") or "").strip() request_id = str(payload.get("request_id") or "").strip() timeout_seconds = int(payload.get("timeout_seconds") or 30) with configuration_activation_state_transaction() as state: request = state["requests"].get(request_id) if not isinstance(request, dict): return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "not_found", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } if base_id != str(request.get("base_id") or ""): return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "blocked", "error": "base_id_mismatch", "request_id": request_id, "execution": {"performed": False, "active_configuration_changed": False}, } request_status = str(request.get("status") or "") if request_status in {"cancelled", "expired", "stale", "changed_since_request"}: return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "blocked", "error": f"activation_request_{request_status}", "request_id": request_id, "request_status": request_status, "execution": {"performed": False, "active_configuration_changed": False}, } layer = str(request.get("layer") or "all") expected_fingerprint = str(request.get("fingerprint") or "") status_result = configuration_activation_status( { "base_id": base_id, "layer": layer, "limit": 5000, "timeout_seconds": timeout_seconds, "include_files": True, "include_storage": True, } ) public_status = configuration_activation_public_status(status_result) live_status = str(status_result.get("status") or "") verified_at = time.time() if live_status == "up_to_date": with configuration_activation_state_transaction() as state: stored = state["requests"].get(request_id) if isinstance(stored, dict): if stored.get("status") != "verified_up_to_date": stored["status"] = "verified_up_to_date" stored["verified_at"] = verified_at stored.setdefault("events", []).append( {"event": "verified_up_to_date", "at": verified_at} ) else: verified_at = float(stored.get("verified_at") or verified_at) return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "verified_up_to_date", "base_id": base_id, "request_id": request_id, "layer": layer, "verified_at": datetime.fromtimestamp(verified_at, timezone.utc).isoformat(), "activation_status": public_status, "outcome": { "saved_and_active_aligned": True, "activation_proven": False, "reason": ( "Live SQL proves that saved and active layers align. It cannot distinguish " "Designer activation from another operation that removed or replaced saved changes." ), }, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } if live_status == "activation_required": current_fingerprint = configuration_activation_status_fingerprint(status_result) if hmac.compare_digest(expected_fingerprint, current_fingerprint): with configuration_activation_state_transaction() as state: stored = state["requests"].get(request_id) if isinstance(stored, dict): stored["last_verified_at"] = verified_at stored.setdefault("events", []).append( {"event": "verification_not_activated", "at": verified_at} ) return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "not_activated", "base_id": base_id, "request_id": request_id, "layer": layer, "activation_status": public_status, "next_step": { "kind": "manual_1c_designer_action", "action": "update_database_configuration", "presentation": ( "Открыть базу в Конфигураторе и выполнить " "«Обновить конфигурацию базы данных» (F7)." ), "confirmation_required": True, }, "retry_call": { "method": method, "params": { "base_id": base_id, "request_id": request_id, "timeout_seconds": timeout_seconds, }, }, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } with configuration_activation_state_transaction() as state: stored = state["requests"].get(request_id) if isinstance(stored, dict): stored["status"] = "changed_since_request" stored["changed_since_request_at"] = verified_at stored.setdefault("events", []).append( {"event": "changed_since_request", "at": verified_at} ) return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "changed_since_request", "base_id": base_id, "request_id": request_id, "layer": layer, "activation_status": public_status, "next_call": { "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, "params": {"base_id": base_id, "layer": layer}, }, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } return { "schema": "onec_configuration_activation_verify.v1", "method": method, "status": "blocked", "error": "activation_status_not_verifiable", "base_id": base_id, "request_id": request_id, "layer": layer, "activation_status": public_status, "execution": { "performed": False, "designer_started": False, "active_configuration_changed": False, }, } def metadata_saved_state_change_context( *, base_id: str, table: str, file_name: str, timeout_seconds: int, ) -> dict[str, Any] | None: try: form_row = saved_state_form_search_row( base_id=base_id, table=table, file_name=file_name, payload={"max_targets": 0}, timeout_seconds=timeout_seconds, ) except Exception: form_row = None if isinstance(form_row, dict): form = form_row.get("form") if isinstance(form_row.get("form"), dict) else {} owner = form_row.get("owner") if isinstance(form_row.get("owner"), dict) else {} identity = form.get("identity") if isinstance(form.get("identity"), dict) else {} name = form_row.get("name") or form.get("name") or identity.get("name") synonym = form_row.get("synonym") or form.get("synonym") or identity.get("synonym") if not owner.get("name") and name: related_owner = saved_state_related_descriptor_identity( base_id=base_id, table=table, file_name=file_name, timeout_seconds=timeout_seconds, ) if related_owner and related_owner.get("name"): owner = { "status": "resolved", "kind": related_owner.get("kind") or "Catalog", "name": related_owner.get("name"), "synonym": related_owner.get("synonym"), "guid": related_owner.get("guid"), "source": "saved_state_descriptor", } owner_name = str(owner.get("name") or "").strip() if name or owner_name: return { "kind": "form", "presentation": ".".join(part for part in [owner_name, str(name or ""), "Форма"] if part), **({"owner": owner} if owner else {}), "form": { "name": name, "synonym": synonym, "guid": form.get("guid") or identity.get("guid"), }, "source": {"method": SAVED_STATE_FORMS_SEARCH_METHOD, "status": "resolved_by_file"}, } try: module_row = saved_state_module_search_row( base_id=base_id, table=table, file_name=file_name, file_row={"FileName": file_name}, payload={"preview_chars": 0}, timeout_seconds=timeout_seconds, ) except Exception: module_row = None if isinstance(module_row, dict): owner = module_row.get("owner") if isinstance(module_row.get("owner"), dict) else None form = module_row.get("form") if isinstance(module_row.get("form"), dict) else None module = module_row.get("module") if isinstance(module_row.get("module"), dict) else {} streams = module_row.get("streams") if isinstance(module_row.get("streams"), list) else [] if not str((owner or {}).get("name") or "").strip() and not str((form or {}).get("name") or "").strip(): return None return { "kind": "module", "presentation": module_row.get("display_name") or module_row.get("qualified_name") or module.get("name"), **({"owner": owner} if owner else {}), **({"form": form} if form else {}), "module": module, "streams": [ { "stream_index": stream.get("stream_index"), "module_ref": stream.get("module_ref"), "text_sha1": stream.get("text_sha1"), "write_plan_target": stream.get("write_plan_target"), } for stream in streams[:5] if isinstance(stream, dict) ], "source": {"method": SAVED_STATE_MODULES_SEARCH_METHOD, "status": "resolved_by_file"}, } return None def public_saved_state_change_context(context: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(context, dict): return None owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} form = context.get("form") if isinstance(context.get("form"), dict) else {} module = context.get("module") if isinstance(context.get("module"), dict) else {} owner_public = { key: value for key, value in owner.items() if key in {"status", "kind", "name", "synonym", "source"} and value is not None } owner_ref = object_selector_ref(owner_public.get("kind"), owner_public.get("name")) if owner_ref: owner_public["ref"] = owner_ref form_public = { key: value for key, value in form.items() if key in {"name", "synonym", "source"} and value is not None } module_public = { key: value for key, value in module.items() if key in {"kind", "name"} and value is not None } presentation = str(context.get("presentation") or "").strip() selector = { **({"ref": owner_ref} if owner_ref else {}), **({"form": form_public.get("name")} if form_public.get("name") else {}), **({"module": module_public.get("name")} if module_public.get("name") else {}), **({"qualified_name": presentation} if presentation else {}), } source = context.get("source") if isinstance(context.get("source"), dict) else {} return { "kind": context.get("kind") or ("module" if module_public else ("form" if form_public else "object")), **({"presentation": presentation} if presentation else {}), **({"owner": owner_public} if owner_public else {}), **({"form": form_public} if form_public else {}), **({"module": module_public} if module_public else {}), **({"selector": selector} if selector else {}), **( { "resolution": { key: value for key, value in source.items() if key in {"method", "status"} and value is not None } } if source else {} ), } def public_saved_state_freshness(freshness: dict[str, Any] | None) -> dict[str, Any]: raw = freshness if isinstance(freshness, dict) else {} raw_status = str(raw.get("status") or "").strip() status = raw_status.replace("live_sql", "live") if raw_status else "unknown" verified = bool(raw.get("verified") or raw.get("verified_against_sql") or raw_status == "live_sql_verified") return { "source": "saved_state", "status": status, "verified": verified, } def public_saved_state_layer_counts(counts: dict[str, Any] | None) -> dict[str, Any]: raw = counts if isinstance(counts, dict) else {} return { "saved_records": raw.get("saved_rows", 0), "saved_objects": raw.get("saved_files", 0), "active_records": raw.get("active_rows", 0), "changed": raw.get("changed_files", 0), "unchanged": raw.get("unchanged_files", 0), "saved_only": raw.get("saved_only_files", 0), "changed_parts": raw.get("changed_rows", 0), } def public_saved_state_change_item(item: dict[str, Any]) -> dict[str, Any]: table = str(item.get("table") or "") layer = "base_saved_state" if table == "ConfigSave" else "extension_saved_state" context = public_saved_state_change_context(item.get("context")) return { "layer": layer, "status": item.get("status"), "changed_parts": item.get("changed_parts"), **({"context": context} if context else {}), **({"presentation": context.get("presentation")} if context and context.get("presentation") else {}), **({"selector": context.get("selector")} if context and context.get("selector") else {}), **( { "diagnostics": { "status": "context_unresolved", "message": "Saved-state change is counted, but its 1C object/form/module name was not resolved within context_limit.", } } if not context else {} ), } def public_saved_state_change_group(group: dict[str, Any], index: int) -> dict[str, Any]: unresolved = str(group.get("kind") or "") == "file" context = None if unresolved else public_saved_state_change_context(group) presentation = str((context or {}).get("presentation") or "").strip() public_files = [ { "layer": "base_saved_state" if str(item.get("table") or "") == "ConfigSave" else "extension_saved_state", "status": item.get("status"), "changed_parts": item.get("changed_parts"), } for item in group.get("files") or [] if isinstance(item, dict) ] raw_counts = group.get("counts") if isinstance(group.get("counts"), dict) else {} public_counts = { "changes": raw_counts.get("files", 0), "changed": raw_counts.get("changed_files", 0), "saved_only": raw_counts.get("saved_only_files", 0), "unchanged": raw_counts.get("unchanged_files", 0), } return { "key": f"unresolved_change:{index}" if unresolved else f"{str(group.get('kind') or 'change')}:{presentation or index}", "kind": "unresolved" if unresolved else group.get("kind"), **({"presentation": presentation} if presentation else {}), **({"owner": context.get("owner")} if context and context.get("owner") else {}), **({"form": context.get("form")} if context and context.get("form") else {}), **({"module": context.get("module")} if context and context.get("module") else {}), **({"selector": context.get("selector")} if context and context.get("selector") else {}), "counts": public_counts, "changes": public_files, **( { "diagnostics": { "status": "context_unresolved", "message": "The saved-state group has no resolved 1C object/form/module name.", } } if unresolved else {} ), } def saved_state_change_family_key(file_name: Any) -> str: return re.sub(r"\.\d+$", "", str(file_name or "").strip()) def enrich_saved_state_change_context_families(files: list[dict[str, Any]]) -> None: named_forms: dict[tuple[str, str], dict[str, Any]] = {} named_owners: dict[tuple[str, str], dict[str, Any]] = {} for item in files: context = item.get("context") if isinstance(item.get("context"), dict) else {} key = (str(item.get("table") or ""), saved_state_change_family_key(item.get("file_name"))) form = context.get("form") if isinstance(context.get("form"), dict) else {} owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} if key[1] and form.get("name"): named_forms[key] = form if key[1] and owner.get("name"): named_owners[key] = owner for item in files: context = item.get("context") if isinstance(item.get("context"), dict) else None if not context: continue key = (str(item.get("table") or ""), saved_state_change_family_key(item.get("file_name"))) form = context.get("form") if isinstance(context.get("form"), dict) else {} owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} module = context.get("module") if isinstance(context.get("module"), dict) else {} context_changed = False if not form.get("name") and key in named_forms: form = named_forms[key] context["form"] = form context_changed = True if not owner.get("name") and key in named_owners: owner = named_owners[key] context["owner"] = owner context_changed = True if context_changed and module and (owner.get("name") or form.get("name")): qualified_name = public_code_qualified_name(owner=owner, form=form, module=module) if qualified_name: context["presentation"] = qualified_name elif context_changed and context.get("kind") == "form" and (owner.get("name") or form.get("name")): context["presentation"] = ".".join( part for part in [str(owner.get("name") or ""), str(form.get("name") or ""), "Форма"] if part ) def saved_state_change_group_identity(item: dict[str, Any]) -> tuple[str, dict[str, Any]]: context = item.get("context") if isinstance(item.get("context"), dict) else {} table = str(item.get("table") or "") file_name = str(item.get("file_name") or "") kind = str(context.get("kind") or "file") presentation = str(context.get("presentation") or file_name or table) owner = context.get("owner") if isinstance(context.get("owner"), dict) else None form = context.get("form") if isinstance(context.get("form"), dict) else None module = context.get("module") if isinstance(context.get("module"), dict) else None identity_parts = [kind] if owner: identity_parts.append(str(owner.get("guid") or owner.get("name") or owner.get("type") or "")) if form: identity_parts.append(str(form.get("guid") or form.get("name") or "")) if module: identity_parts.append(str(module.get("kind") or module.get("name") or "")) identity_parts.append(presentation) if kind == "file": identity_parts.extend([table, file_name]) key = "|".join(part for part in identity_parts if part) group = { "key": key, "kind": kind, "presentation": presentation, **({"owner": owner} if owner else {}), **({"form": form} if form else {}), **({"module": module} if module else {}), "counts": {"files": 0, "changed_files": 0, "saved_only_files": 0, "unchanged_files": 0}, "files": [], } return key, group def append_unique_selector(target: list[dict[str, Any]], selector: Any) -> None: if not isinstance(selector, dict) or not selector: return if selector not in target: target.append(selector) def saved_state_change_item_action_selectors(item: dict[str, Any]) -> dict[str, Any]: selectors: dict[str, Any] = {"diff": []} append_unique_selector(selectors["diff"], item.get("diff_selector")) context = item.get("context") if isinstance(item.get("context"), dict) else {} streams = context.get("streams") if isinstance(context.get("streams"), list) else [] module_refs: list[str] = [] write_plan_targets: list[dict[str, Any]] = [] for stream in streams: if not isinstance(stream, dict): continue module_ref = str(stream.get("module_ref") or "").strip() if module_ref and module_ref not in module_refs: module_refs.append(module_ref) append_unique_selector(write_plan_targets, stream.get("write_plan_target")) if module_refs: selectors["module_refs"] = module_refs if write_plan_targets: selectors["write_plan_targets"] = write_plan_targets return selectors def saved_state_change_group_next_actions(base_id: str, group: dict[str, Any]) -> list[dict[str, Any]]: selectors = group.get("selectors") if isinstance(group.get("selectors"), dict) else {} actions: list[dict[str, Any]] = [] for selector in (selectors.get("diff") or [])[:10]: if isinstance(selector, dict): actions.append({"kind": "inspect_diff", "method": SAVED_STATE_DIFF_METHOD, "payload": selector}) for module_ref in (selectors.get("module_refs") or [])[:10]: module_ref_value = str(module_ref or "").strip() if module_ref_value: actions.append( { "kind": "read_module", "method": "code.read", "payload": {"base_id": base_id, "module_ref": module_ref_value, "state": "working"}, } ) for target in (selectors.get("write_plan_targets") or [])[:10]: if isinstance(target, dict) and target: actions.append( { "kind": "preflight_write", "method": METADATA_WRITE_PREFLIGHT_METHOD, "payload": {"base_id": base_id, "target": target, "resolve_origin": False}, } ) return actions def saved_state_change_group_action_summary(actions: list[dict[str, Any]]) -> dict[str, Any]: by_kind: dict[str, int] = {} methods: dict[str, int] = {} for action in actions: if not isinstance(action, dict): continue kind = str(action.get("kind") or "") method = str(action.get("method") or "") if kind: by_kind[kind] = by_kind.get(kind, 0) + 1 if method: methods[method] = methods.get(method, 0) + 1 return { "total": len([action for action in actions if isinstance(action, dict)]), "by_kind": by_kind, "methods": methods, } def saved_state_change_group_recommended_action(actions: list[dict[str, Any]]) -> dict[str, Any] | None: priority = {"inspect_diff": 0, "read_module": 1, "preflight_write": 2} candidates = [action for action in actions if isinstance(action, dict) and str(action.get("kind") or "") in priority] if not candidates: return None return sorted(candidates, key=lambda action: priority[str(action.get("kind") or "")])[0] def saved_state_changes_action_summary(groups: list[dict[str, Any]]) -> dict[str, Any]: actions: list[dict[str, Any]] = [] for group in groups: if isinstance(group, dict) and isinstance(group.get("next_actions"), list): actions.extend(action for action in group.get("next_actions") or [] if isinstance(action, dict)) return saved_state_change_group_action_summary(actions) def saved_state_changes_recommended_action(groups: list[dict[str, Any]]) -> dict[str, Any] | None: group_priority = {"changed": 0, "saved_only": 1, "unchanged": 2} candidates: list[tuple[int, str, dict[str, Any], dict[str, Any]]] = [] for group in groups: if not isinstance(group, dict) or not isinstance(group.get("recommended_next_action"), dict): continue counts = group.get("counts") if isinstance(group.get("counts"), dict) else {} if int(counts.get("changed_files") or 0) > 0: status = "changed" elif int(counts.get("saved_only_files") or 0) > 0: status = "saved_only" else: status = "unchanged" candidates.append((group_priority.get(status, 99), str(group.get("presentation") or ""), group, group["recommended_next_action"])) if not candidates: return None _priority, _presentation, group, action = sorted(candidates, key=lambda item: (item[0], item[1]))[0] return { "group_key": group.get("key"), "group_kind": group.get("kind"), "group_presentation": group.get("presentation"), "action": action, } def build_saved_state_change_groups(base_id: str, files: list[dict[str, Any]]) -> list[dict[str, Any]]: groups_by_key: dict[str, dict[str, Any]] = {} for item in files: key, group_template = saved_state_change_group_identity(item) group = groups_by_key.setdefault(key, group_template) group_selectors = group.setdefault("selectors", {"diff": []}) status = str(item.get("status") or "") group["counts"]["files"] += 1 if status == "changed": group["counts"]["changed_files"] += 1 elif status == "saved_only": group["counts"]["saved_only_files"] += 1 elif status == "unchanged": group["counts"]["unchanged_files"] += 1 group["files"].append( { "table": item.get("table"), "file_name": item.get("file_name"), "status": item.get("status"), "changed_parts": item.get("changed_parts"), "diff_selector": item.get("diff_selector"), } ) item_selectors = saved_state_change_item_action_selectors(item) for selector in item_selectors.get("diff") or []: append_unique_selector(group_selectors.setdefault("diff", []), selector) for module_ref in item_selectors.get("module_refs") or []: module_refs = group_selectors.setdefault("module_refs", []) if module_ref not in module_refs: module_refs.append(module_ref) for write_plan_target in item_selectors.get("write_plan_targets") or []: append_unique_selector(group_selectors.setdefault("write_plan_targets", []), write_plan_target) groups = sorted(groups_by_key.values(), key=lambda group: (str(group.get("kind") or ""), str(group.get("presentation") or ""), str(group.get("key") or ""))) for group in groups: next_actions = saved_state_change_group_next_actions(base_id, group) group["next_actions"] = next_actions group["action_summary"] = saved_state_change_group_action_summary(next_actions) recommended_action = saved_state_change_group_recommended_action(next_actions) if recommended_action: group["recommended_next_action"] = recommended_action return groups def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_CHANGES_LIST_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=500, minimum=1, maximum=5000) if limit_error: return limit_error include_unchanged, include_unchanged_error = strict_bool_argument(payload, "include_unchanged", method=method, default=False) if include_unchanged_error: return include_unchanged_error include_context, include_context_error = strict_bool_argument(payload, "include_context", method=method, default=False) if include_context_error: return include_context_error group_by_context, group_by_context_error = strict_bool_argument(payload, "group_by_context", method=method, default=False) if group_by_context_error: return group_by_context_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error include_storage = bool(include_storage) context_limit, context_limit_error = parse_int_argument(payload, "context_limit", method=method, default=50, minimum=0, maximum=500) if context_limit_error: return context_limit_error layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) requested_table = str(payload.get("table") or payload.get("target_table") or "").strip() if layer and requested_table and requested_table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with table.") table = requested_table or SAVED_STATE_TABLE_BY_LAYER.get(layer, "") tables = [table] if table else ["ConfigSave", "ConfigCASSave"] if any(item not in SAVED_STATE_SOURCE_BY_TARGET for item in tables): return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") files: list[dict[str, Any]] = [] table_summaries: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] counts = { "tables": 0, "files": 0, "changed_files": 0, "saved_only_files": 0, "unchanged_files": 0, "error_tables": 0, } for current_table in tables: status_result = metadata_saved_state_status( { "base_id": base_id, "table": current_table, "prefix": prefix, "limit": int(limit or 500), "include_files": True, "include_unchanged": bool(include_unchanged), "include_storage": True, "timeout_seconds": int(timeout_seconds or 30), } ) if status_result.get("schema") != "onec_saved_state_status.v1": counts["error_tables"] += 1 errors.append({"table": current_table, "status": status_result.get("status"), "error": status_result.get("error"), "diagnostics": status_result.get("diagnostics")}) continue counts["tables"] += 1 table_counts = status_result.get("counts") if isinstance(status_result.get("counts"), dict) else {} table_summaries.append( { "table": current_table, "status": status_result.get("status"), "counts": table_counts, "freshness": status_result.get("freshness"), } ) for item in status_result.get("files") or []: if not isinstance(item, dict): continue item_status = str(item.get("status") or "") if item_status == "unchanged" and not include_unchanged: continue counts["files"] += 1 if item_status == "changed": counts["changed_files"] += 1 elif item_status == "saved_only": counts["saved_only_files"] += 1 elif item_status == "unchanged": counts["unchanged_files"] += 1 files.append({"table": current_table, **item}) context_enrichment = bool(include_context or group_by_context or not include_storage) if context_enrichment and files: enriched = 0 for item in files: if enriched >= int(context_limit or 0): break table_name = str(item.get("table") or "") file_name = str(item.get("file_name") or "") if table_name not in SAVED_STATE_SOURCE_BY_TARGET or not file_name: continue context = metadata_saved_state_change_context( base_id=base_id, table=table_name, file_name=file_name, timeout_seconds=int(timeout_seconds or 30), ) if context: item["context"] = context enriched += 1 enrich_saved_state_change_context_families(files) files.sort(key=lambda item: (str(item.get("table") or ""), str(item.get("status") or ""), str(item.get("file_name") or ""))) groups = build_saved_state_change_groups(base_id, files) if group_by_context else [] if group_by_context: counts["groups"] = len(groups) status = "error" if counts["error_tables"] and not counts["tables"] else ("changed" if counts["changed_files"] or counts["saved_only_files"] else ("unchanged" if counts["unchanged_files"] else "empty")) result = { "schema": "onec_saved_state_changes_list.v1", "method": method, "status": status, "base_id": base_id, "query": { "tables": tables, "prefix": prefix or None, "limit": int(limit or 500), "include_unchanged": bool(include_unchanged), "include_context": bool(include_context), "group_by_context": bool(group_by_context), "context_enrichment": context_enrichment, "context_limit": int(context_limit or 0), "include_storage": include_storage, }, "tables": table_summaries, "counts": counts, "files": files, "errors": errors, "freshness": { "source": "live_sql", "status": "live_sql_verified" if not errors else "partial_live_sql_verified", "verified_against_sql": True, }, } if group_by_context: result["groups"] = groups result["action_summary"] = saved_state_changes_action_summary(groups) recommended_action = saved_state_changes_recommended_action(groups) if recommended_action: result["recommended_next_action"] = recommended_action if include_storage: return result public_layers = [ { "layer": "base_saved_state" if str(summary.get("table") or "") == "ConfigSave" else "extension_saved_state", "status": summary.get("status"), "counts": public_saved_state_layer_counts(summary.get("counts")), "freshness": public_saved_state_freshness(summary.get("freshness")), } for summary in table_summaries ] public_files = [public_saved_state_change_item(item) for item in files] public_errors = [ { "layer": "base_saved_state" if str(error.get("table") or "") == "ConfigSave" else "extension_saved_state", "status": error.get("status"), "error": error.get("error"), "diagnostics": { "status": "layer_unavailable", "message": "Saved-state layer could not be inspected; storage diagnostics are hidden unless include_storage=true.", }, } for error in errors ] public_counts = { "layers": counts.get("tables", 0), "changes": counts.get("files", 0), "changed": counts.get("changed_files", 0), "saved_only": counts.get("saved_only_files", 0), "unchanged": counts.get("unchanged_files", 0), "error_layers": counts.get("error_tables", 0), **({"groups": counts.get("groups")} if counts.get("groups") is not None else {}), } public_result = { "schema": "onec_saved_state_changes_list.v1", "method": method, "status": status, "base_id": base_id, "source": {"kind": "saved_state"}, "query": { "layers": ["base_saved_state" if item == "ConfigSave" else "extension_saved_state" for item in tables], "limit": int(limit or 500), "include_unchanged": bool(include_unchanged), "include_context": True, "group_by_context": bool(group_by_context), "context_enrichment": True, "context_limit": int(context_limit or 0), "include_storage": False, }, "layers": public_layers, "counts": public_counts, "changes": public_files, "errors": public_errors, "freshness": public_saved_state_freshness(result["freshness"]), "diagnostics": { "note": "SQL coordinates, hashes, and low-level actions are hidden unless include_storage=true.", "resolved_contexts": len([item for item in public_files if item.get("context")]), "unresolved_contexts": len([item for item in public_files if not item.get("context")]), }, } if group_by_context: public_groups = [public_saved_state_change_group(group, index) for index, group in enumerate(groups, start=1)] public_result["groups"] = public_groups public_result["counts"] = {**public_counts, "groups": len(public_groups)} return public_result def storage_apply_backup_dir() -> Path: return Path(os.environ.get("ONEC_ADAPTER_BACKUP_DIR") or "/data/adapter-apply-backups") def resolve_storage_apply_backup_path(backup_id: str | None = None, backup_path: str | None = None) -> Path | dict[str, Any]: method = "storage.saved_state.rollback" root = storage_apply_backup_dir().resolve() if backup_path: path = Path(backup_path).resolve() try: path.relative_to(root) except ValueError: return invalid_argument(method, "backup_path", "backup_path must be inside the adapter backup directory.") if not path.is_file(): return invalid_argument(method, "backup_path", "backup_path was not found.") return path if not backup_id: return invalid_argument(method, "backup_id", "Pass backup_id or backup_path.") if not re.fullmatch(r"[0-9a-f]{32}", backup_id): return invalid_argument(method, "backup_id", "backup_id must be a 32-character lowercase hex id.") matches = sorted(root.glob(f"*{backup_id}.json")) if root.is_dir() else [] if not matches: return invalid_argument(method, "backup_id", "Backup id was not found.") if len(matches) > 1: return invalid_argument(method, "backup_id", "Backup id is ambiguous; pass backup_path.") return matches[0] def write_storage_apply_backup( *, base_id: str, config: dict[str, str], table: str, file_name: str, original: bytes, replacement: bytes, proposal: dict[str, Any], ) -> dict[str, Any]: backup_id = uuid.uuid4().hex created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") backup_root = storage_apply_backup_dir() backup_root.mkdir(parents=True, exist_ok=True) path = backup_root / f"{created_at.replace(':', '').replace('-', '')}-{backup_id}.json" evidence = { "schema": "onec_storage_apply_backup.v1", "backup_id": backup_id, "created_at_utc": created_at, "base_id": base_id, "source": { "kind": "live_sql", "server": config.get("server"), "database": config.get("database"), "table": table, "file_name": file_name, }, "original": {"sha1": hashlib.sha1(original).hexdigest(), "bytes": len(original), "payload_hex": original.hex()}, "replacement": {"sha1": hashlib.sha1(replacement).hexdigest(), "bytes": len(replacement)}, "proposal": { "schema": proposal.get("schema"), "method": proposal.get("method"), "status": proposal.get("status"), "source": proposal.get("source"), "original": proposal.get("original"), "encoded": {key: value for key, value in (proposal.get("encoded") or {}).items() if key != "payload_hex"}, "counts": proposal.get("counts"), "edits": proposal.get("edits") or [], }, "rollback": { "method": "storage.saved_state.apply_proposal", "request": { "base_id": base_id, "allow_sql_saved_state_apply": True, "proposal": { "schema": "onec_change_proposal.v1", "status": "accepted_for_review", "source": {"table": table, "file_name": file_name}, "original": {"sha1": hashlib.sha1(replacement).hexdigest(), "bytes": len(replacement)}, "encoded": {"sha1": hashlib.sha1(original).hexdigest(), "bytes": len(original), "payload_hex": original.hex()}, }, }, }, } path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return {"backup_id": backup_id, "path": str(path), "sha1": evidence["original"]["sha1"], "bytes": len(original)} def semantic_verify_saved_state_apply( *, base_id: str, table: str, file_name: str, proposal: dict[str, Any], timeout_seconds: int, ) -> dict[str, Any] | None: if proposal.get("method") != FORM_ELEMENT_WRITE_METHOD or not isinstance(proposal.get("element"), dict): return None element = proposal.get("element") or {} selector: dict[str, Any] = {} if element.get("path"): selector["element_path"] = str(element.get("path")) elif element.get("name"): selector["element"] = str(element.get("name")) elif element.get("id"): selector["element_id"] = str(element.get("id")) else: return {"status": "skipped", "reason": "element_selector_missing"} decoded = metadata_form_decode( { "base_id": base_id, "table": table, "file_name": file_name, "include_storage": True, "include_parameters": True, "max_items": 5000, "timeout_seconds": timeout_seconds, } ) result: dict[str, Any] = { "schema": "onec_saved_state_semantic_verification.v1", "method": "metadata.form.decode", "status": "ok" if decoded.get("status") == "ok" else "error", "selector": selector, "source": decoded.get("source"), "checks": [], } if decoded.get("status") != "ok": result["diagnostics"] = decoded.get("diagnostics") return result profile = decoded.get("profile") if isinstance(decoded.get("profile"), dict) else {} items = filter_form_profile_write_targets(form_profile_write_targets(profile), selector) expected_section = str(element.get("section") or "") if expected_section: items = [item for item in items if str(item.get("_profile_section") or "") == expected_section] if len(items) != 1: result["status"] = "not_found" if not items else "ambiguous" result["counts"] = {"matches": len(items)} return result item = items[0] result["element"] = {key: item.get(key) for key in ("name", "id", "title", "path", "marker", "type_name", "_profile_section")} if "_profile_section" in result["element"]: result["element"]["section"] = result["element"].pop("_profile_section") semantic_edits = proposal.get("form_element_edits") or proposal.get("edits") or [] for edit in semantic_edits: if not isinstance(edit, dict): continue expected = edit.get("value") if "value" in edit else edit.get("new") actual = form_property_current_value(item, edit.get("property")) result["checks"].append( { "property": edit.get("property"), "expected": expected, "actual": actual, "ok": str(actual) == str(expected), } ) if result["checks"]: result["status"] = "ok" if all(check.get("ok") for check in result["checks"]) else "mismatch" else: result["status"] = "skipped" result["reason"] = "no_form_element_edits" return result def saved_state_apply_trace(*, phase: str, base_id: str, table: str, file_name: str) -> None: """Emit coarse apply progress without logging payloads or credentials.""" print( json.dumps( {"event": "saved_state_apply_phase", "phase": phase, "base_id": base_id, "table": table, "file_name": file_name, "time": datetime.now(timezone.utc).isoformat()}, ensure_ascii=False, ), flush=True, ) def saved_state_apply_module_trace(*, phase: str, base_id: str, module_ref: str = "") -> None: job_id = str(getattr(ADAPTER_JOB_CONTEXT, "job_id", "") or "") if job_id: adapter_job_set(job_id, current_step=phase, progress={"current_step": phase, "completed_steps": 0, "total_steps": 7, "percent": 0}) print( json.dumps( {"event": "saved_state_module_apply_phase", "phase": phase, "base_id": base_id, "module_ref": module_ref, "time": datetime.now(timezone.utc).isoformat()}, ensure_ascii=False, ), flush=True, ) def saved_state_configurator_refresh_guidance( *, table: str, file_name: str, object_existed_in_saved_state: bool, ) -> dict[str, Any]: """Describe the minimal Configurator refresh after a SQL saved-state write. The SQL-only adapter can encode a verified saved-state overlay, but it cannot alter the in-memory dirty flags of an already open Configurator session. Keep this guidance factual: it is derived from the layer and the state that existed *before* the write, not guessed from a 1C object name. """ extension_prefix, separator, _suffix = file_name.partition("__") is_extension_file = bool(separator and is_guid_text(extension_prefix.strip().lower())) if is_extension_file: if object_existed_in_saved_state: return { "required": True, "scope": "object", "action": "close_reopen_object", "reason": "The object already belonged to the extension saved-state; reopening the object reloads its saved overlay.", } return { "required": True, "scope": "extension", "action": "close_reopen_extension", "reason": "The adapter created an extension saved-state object; an open Configurator may still use the active extension until it is reopened.", } return { "required": True, "scope": "configuration", "action": "close_reopen_configuration", "reason": "The adapter created or changed a configuration saved-state object outside Configurator memory.", } def apply_storage_file_bytes_single_part( base_id: str, table: str, file_name: str, replacement: bytes, *, expected_sha1: str, proposal: dict[str, Any], timeout_seconds: int = 30, ) -> dict[str, Any]: phase = "connect" conn, config, error = connect_live_sql(base_id, "storage.saved_state.apply_proposal", timeout_seconds=timeout_seconds) if error: return error started = time.time() try: cursor = conn.cursor(as_dict=True) phase = "set_lock_timeout" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) # Connection timeout does not limit SQL Server lock waits. Without a # session LOCK_TIMEOUT an UPDLOCK/HOLDLOCK read can leave a caller # waiting indefinitely behind Configurator or another adapter write. # Keep the requested bound inside SQL Server as well, before opening # the transaction that protects the compare-and-set update. # Pass an explicit empty parameter tuple for DB-API compatibility. # The production SQL driver accepts it and lightweight DB-API # adapters used by controlled tests require the parameters argument. cursor.execute(f"SET LOCK_TIMEOUT {int(timeout_seconds or 30) * 1000}", ()) phase = "acquire_update_lock" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) cursor.execute( f"SELECT PartNo, BinaryData FROM dbo.[{table}] WITH (UPDLOCK, HOLDLOCK) WHERE FileName = %s ORDER BY PartNo", (file_name,), ) rows = cursor.fetchall() phase = "locked_read_complete" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) parts = [bytes(row.get("BinaryData")) for row in rows if isinstance(row.get("BinaryData"), (bytes, bytearray))] current = b"".join(parts) current_sha1 = hashlib.sha1(current).hexdigest() if current else "" if not rows or not current: conn.rollback() return { "schema": "onec_storage_saved_state_apply.v1", "status": "source_missing", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "diagnostics": {"message": "FileName was not found in the requested saved-state storage table."}, } if expected_sha1 and current_sha1 != expected_sha1: conn.rollback() return { "schema": "onec_storage_saved_state_apply.v1", "status": "precondition_failed", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "original": {"expected_sha1": expected_sha1, "actual_sha1": current_sha1, "bytes": len(current)}, "diagnostics": {"message": "Current saved-state payload SHA1 differs from proposal original.sha1."}, } if len(rows) != 1: conn.rollback() return { "schema": "onec_storage_saved_state_apply.v1", "status": "unsupported_part_layout", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "original": {"sha1": current_sha1, "bytes": len(current), "parts": len(rows)}, "diagnostics": {"message": "Apply v1 only updates saved-state payloads stored as one SQL part. Multi-part replace needs a table-schema-aware writer."}, } phase = "write_backup" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) backup = write_storage_apply_backup( base_id=base_id, config=config, table=table, file_name=file_name, original=current, replacement=replacement, proposal=proposal, ) part_no = rows[0].get("PartNo") phase = "update" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) cursor.execute( f"UPDATE dbo.[{table}] SET BinaryData = %s WHERE FileName = %s AND PartNo = %s", (replacement, file_name, part_no), ) if cursor.rowcount != 1: conn.rollback() return { "schema": "onec_storage_saved_state_apply.v1", "status": "error", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "backup": backup, "diagnostics": {"message": f"Expected to update exactly one row, updated {cursor.rowcount}."}, } phase = "commit" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) conn.commit() phase = "committed" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) except Exception as exc: try: conn.rollback() except Exception: pass message = str(exc) is_lock_timeout = "lock request time out" in message.casefold() or "lock timeout" in message.casefold() return { "schema": "onec_storage_saved_state_apply.v1", "status": "lock_timeout" if is_lock_timeout else "error", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table, "file_name": file_name}, "diagnostics": { "message": message, "phase": phase, **( { "code": "saved_state_lock_timeout", "retry_after_seconds": int(timeout_seconds or 30), "message": "Saved-state stream is locked by another SQL/Configurator operation; no write was performed.", } if is_lock_timeout else {} ), }, } finally: try: conn.close() except Exception: pass phase = "readback" saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) readback, _read_config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) readback_sha1 = hashlib.sha1(readback).hexdigest() if readback else None encoded_sha1 = hashlib.sha1(replacement).hexdigest() verified = bool(readback_sha1 == encoded_sha1) result = { "schema": "onec_storage_saved_state_apply.v1", "status": "applied" if verified else "readback_mismatch", "applied": verified, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "configurator_refresh": saved_state_configurator_refresh_guidance( table=table, file_name=file_name, object_existed_in_saved_state=bool(rows), ), "backup": backup, "original": {"sha1": expected_sha1, "bytes": len(current), "parts": 1}, "encoded": {"sha1": encoded_sha1, "bytes": len(replacement)}, "readback": {"sha1": readback_sha1, "bytes": len(readback) if readback else None, "verified": verified, "error": read_error}, "duration_ms": int((time.time() - started) * 1000), } semantic = semantic_verify_saved_state_apply( base_id=base_id, table=table, file_name=file_name, proposal=proposal, timeout_seconds=timeout_seconds, ) if semantic is not None: result["semantic_verification"] = semantic if verified and semantic.get("status") not in {"ok", "skipped"}: result["status"] = "semantic_verification_failed" result["applied"] = False result["cache_invalidation"] = invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_payload_apply", source_table=table, file_names=[file_name], ) return result def read_storage_files_bytes( base_id: str, table: str, file_names: list[str], *, timeout_seconds: int = 30, ) -> tuple[dict[str, bytes] | None, dict[str, str] | None, dict[str, Any] | None]: safe_names = [name for name in file_names if name and Path(name).name == name] if not safe_names: return {}, None, None conn, config, error = connect_live_sql(base_id, "storage.files.get", timeout_seconds=timeout_seconds) if error: return None, config, error grouped: dict[str, list[bytes]] = {name: [] for name in safe_names} try: with conn: with conn.cursor(as_dict=True) as cursor: for start in range(0, len(safe_names), 500): chunk = safe_names[start : start + 500] placeholders = ",".join(["%s"] * len(chunk)) cursor.execute( f"SELECT FileName, BinaryData FROM dbo.[{table}] WHERE FileName IN ({placeholders}) ORDER BY FileName, PartNo", tuple(chunk), ) for row in cursor.fetchall(): file_name = str(row.get("FileName") or "") value = row.get("BinaryData") if file_name in grouped and isinstance(value, (bytes, bytearray)): grouped[file_name].append(bytes(value)) except Exception as exc: return None, config, { "schema": "onec_adapter_source_error.v1", "method": "storage.files.get", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table}, "diagnostics": {"message": str(exc)}, } return {name: b"".join(parts) for name, parts in grouped.items() if parts}, config, None def live_config_file_name_page_after( base_id: str, last_file_name: str = "", *, table: str = "Config", page_size: int = 2000, timeout_seconds: int = 60, ) -> list[str]: conn, _, error = connect_live_sql(base_id, "storage.files.page", timeout_seconds=timeout_seconds) if error: return [] rows: list[str] = [] try: with conn: with conn.cursor(as_dict=True) as cursor: storage_table = str(table or "Config") if storage_table not in STORAGE_TABLES: return [] cursor.execute( f""" SELECT TOP ({max(1, min(int(page_size), 10000))}) FileName FROM dbo.[{storage_table}] WHERE FileName > %s GROUP BY FileName ORDER BY FileName """, (last_file_name,), ) rows = [ str(row.get("FileName") or "").lower() for row in cursor.fetchall() if is_guid_text(str(row.get("FileName") or "")) ] except Exception: return [] return rows def storage_file_get(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "storage.file.get") if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, "storage.file.get") if isinstance(table_or_error, dict): return table_or_error include_payload, include_payload_error = strict_bool_argument(payload, "include_payload", method="storage.file.get", default=False) if include_payload_error: return include_payload_error if "file_name" in payload and not isinstance(payload.get("file_name"), str): return invalid_argument("storage.file.get", "file_name", "file_name must be a JSON string.") file_name = str(payload.get("file_name") or "") if not file_name or Path(file_name).name != file_name: return { "schema": "onec_adapter_request_error.v1", "method": "storage.file.get", "status": "invalid_argument", "error": "invalid_argument", "argument": "file_name", "diagnostics": {"message": "Pass a single safe FileName value from the live SQL storage table."}, } timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="storage.file.get", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "storage.file.get") if diagnostic_error: return diagnostic_error base_id = base_id_or_error table = table_or_error active_route_error = reject_inactive_storage_file( base_id, table, file_name, method="storage.file.get", timeout_seconds=int(timeout_seconds or 30) ) if active_route_error: return active_route_error data, config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if error: return error result: dict[str, Any] = { "schema": "onec_storage_file.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, "file": {"file_name": file_name, "bytes": len(data), "sha1": hashlib.sha1(data).hexdigest()}, } if include_payload: result["file"]["payload_hex"] = data.hex() return result def apply_extension_saved_state_pair( base_id: str, *, module_file_name: str, replacement_module_data: bytes, expected_module_sha1: str, expected_configinfo_sha1: str | None = None, proposal: dict[str, Any], timeout_seconds: int = 30, ) -> dict[str, Any]: """Atomically apply one existing extension stream and its ConfigInfo map. This writer intentionally supports only an existing single-part module stream. Creating a missing overlay object needs the separate preparation route because descriptor rows and the full pending manifest must be copied first. """ extension_guid, separator, suffix = module_file_name.partition("__") extension_guid = extension_guid.strip().lower() if not separator or not suffix or not is_guid_text(extension_guid): return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "invalid_extension_stream", "applied": False, "diagnostics": {"message": "module_file_name must be an extension ConfigCASSave stream."}, } if suffix.casefold() == "configinfo": return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "invalid_extension_stream", "applied": False, "diagnostics": {"message": "__configinfo is the paired index and cannot be edited as the primary stream."}, } configinfo_file_name = f"{extension_guid}__configinfo" conn, config, error = connect_live_sql(base_id, "storage.saved_state.apply_proposal", timeout_seconds=timeout_seconds) if error: return error started = time.time() try: cursor = conn.cursor(as_dict=True) cursor.execute(f"SET LOCK_TIMEOUT {int(timeout_seconds or 30) * 1000}") cursor.execute( "SELECT FileName, PartNo, BinaryData FROM dbo.ConfigCASSave WITH (UPDLOCK, HOLDLOCK) " "WHERE FileName IN (%s, %s) ORDER BY FileName, PartNo", (module_file_name, configinfo_file_name), ) rows_by_name: dict[str, list[dict[str, Any]]] = {module_file_name: [], configinfo_file_name: []} for row in cursor.fetchall(): rows_by_name.setdefault(str(row.get("FileName") or ""), []).append(row) if any(len(rows_by_name.get(name) or []) != 1 for name in (module_file_name, configinfo_file_name)): conn.rollback() return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "unsupported_part_layout", "applied": False, "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, "diagnostics": {"message": "Paired extension apply requires existing single-part module and __configinfo rows."}, } current_module = bytes(rows_by_name[module_file_name][0].get("BinaryData") or b"") current_configinfo = bytes(rows_by_name[configinfo_file_name][0].get("BinaryData") or b"") current_module_sha1 = hashlib.sha1(current_module).hexdigest() if current_module_sha1 != expected_module_sha1: conn.rollback() return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "precondition_failed", "applied": False, "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, "original": {"expected_module_sha1": expected_module_sha1, "actual_module_sha1": current_module_sha1}, } current_configinfo_sha1 = hashlib.sha1(current_configinfo).hexdigest() if expected_configinfo_sha1 and current_configinfo_sha1 != expected_configinfo_sha1: conn.rollback() return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "precondition_failed", "applied": False, "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, "configinfo": {"expected_sha1": expected_configinfo_sha1, "actual_sha1": current_configinfo_sha1}, } pair = build_extension_saved_state_pair_plan( logical_file_name=suffix, current_file_data=current_module, replacement_file_data=replacement_module_data, current_configinfo_data=current_configinfo, ) if pair.get("status") != "ok": conn.rollback() return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": pair.get("status"), "applied": False, "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, "pair_plan": {key: value for key, value in pair.items() if key != "payload"}, } module_backup = write_storage_apply_backup( base_id=base_id, config=config, table="ConfigCASSave", file_name=module_file_name, original=current_module, replacement=replacement_module_data, proposal=proposal, ) configinfo_backup = write_storage_apply_backup( base_id=base_id, config=config, table="ConfigCASSave", file_name=configinfo_file_name, original=current_configinfo, replacement=bytes(pair["configinfo"]["payload"]), proposal={"kind": "extension_configinfo_pair", "module_file_name": module_file_name, "proposal": proposal}, ) cursor.execute( "UPDATE dbo.ConfigCASSave SET BinaryData=%s, DataSize=%s WHERE FileName=%s AND PartNo=%s", (replacement_module_data, len(replacement_module_data), module_file_name, rows_by_name[module_file_name][0].get("PartNo")), ) cursor.execute( "UPDATE dbo.ConfigCASSave SET BinaryData=%s, DataSize=%s WHERE FileName=%s AND PartNo=%s", (pair["configinfo"]["payload"], len(pair["configinfo"]["payload"]), configinfo_file_name, rows_by_name[configinfo_file_name][0].get("PartNo")), ) conn.commit() except Exception as exc: try: conn.rollback() except Exception: pass return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "error", "applied": False, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": "ConfigCASSave", "file_name": module_file_name}, "diagnostics": {"message": str(exc)}, } finally: try: conn.close() except Exception: pass module_after, _config, module_error = read_storage_file_bytes(base_id, "ConfigCASSave", module_file_name, timeout_seconds=timeout_seconds) configinfo_after, _config, configinfo_error = read_storage_file_bytes(base_id, "ConfigCASSave", configinfo_file_name, timeout_seconds=timeout_seconds) verified = bool( not module_error and not configinfo_error and module_after == replacement_module_data and configinfo_after == pair["configinfo"]["payload"] ) return { "schema": "onec_extension_saved_state_pair_apply.v1", "status": "applied" if verified else "readback_mismatch", "applied": verified, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, "paired_file": {"file_name": configinfo_file_name, "service_atoms": "preserved_unmodified"}, "backups": {"module": module_backup, "configinfo": configinfo_backup}, "module": {"original_sha1": expected_module_sha1, "replacement_sha1": hashlib.sha1(replacement_module_data).hexdigest()}, "configinfo": {"original_sha1": pair["configinfo"]["original_sha1"], "replacement_sha1": pair["configinfo"]["replacement_sha1"]}, "readback": {"verified": verified, "module_error": module_error, "configinfo_error": configinfo_error}, "configurator_refresh": saved_state_configurator_refresh_guidance( table="ConfigCASSave", file_name=module_file_name, object_existed_in_saved_state=True, ), "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( base_id, reason="extension_saved_state_pair_apply", source_table="ConfigCASSave", file_names=[module_file_name, configinfo_file_name], ), "duration_ms": int((time.time() - started) * 1000), } def rollback_extension_saved_state_pair(base_id: str, apply_result: dict[str, Any], *, timeout_seconds: int) -> dict[str, Any]: backups = apply_result.get("backups") if isinstance(apply_result.get("backups"), dict) else {} module_backup = backups.get("module") if isinstance(backups.get("module"), dict) else {} configinfo_backup = backups.get("configinfo") if isinstance(backups.get("configinfo"), dict) else {} module_path = resolve_storage_apply_backup_path(str(module_backup.get("backup_id") or "") or None, None) configinfo_path = resolve_storage_apply_backup_path(str(configinfo_backup.get("backup_id") or "") or None, None) if isinstance(module_path, dict) or isinstance(configinfo_path, dict): return {"status": "rollback_unavailable", "applied": False, "diagnostics": {"message": "Paired write backups are unavailable."}} try: module_evidence = json.loads(module_path.read_text(encoding="utf-8-sig")) configinfo_evidence = json.loads(configinfo_path.read_text(encoding="utf-8-sig")) source = module_evidence.get("source") if isinstance(module_evidence.get("source"), dict) else {} original = module_evidence.get("original") if isinstance(module_evidence.get("original"), dict) else {} replacement = module_evidence.get("replacement") if isinstance(module_evidence.get("replacement"), dict) else {} configinfo_replacement = (configinfo_evidence.get("replacement") if isinstance(configinfo_evidence.get("replacement"), dict) else {}) original_data = bytes.fromhex(str(original.get("payload_hex") or "")) file_name = str(source.get("file_name") or "") expected_module_sha1 = str(replacement.get("sha1") or "") expected_configinfo_sha1 = str(configinfo_replacement.get("sha1") or "") except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError): return {"status": "rollback_unavailable", "applied": False, "diagnostics": {"message": "Paired write backup evidence is invalid."}} return apply_extension_saved_state_pair( base_id, module_file_name=file_name, replacement_module_data=original_data, expected_module_sha1=expected_module_sha1, expected_configinfo_sha1=expected_configinfo_sha1, proposal={"method": "metadata.module.write_apply.rollback"}, timeout_seconds=timeout_seconds, ) def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.apply_proposal" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error repository_error = repository_apply_gate(payload, method, "apply") if repository_error: return repository_error allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if not allow_apply: return invalid_argument( method, "allow_sql_saved_state_apply", "Saved-state SQL apply is opt-in; pass allow_sql_saved_state_apply=true after reviewing the proposal and backup policy.", ) timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error proposal = payload.get("proposal") if not isinstance(proposal, dict): return invalid_argument(method, "proposal", "proposal must be a JSON object returned by changes.propose or metadata.form.element.write.") source = proposal.get("source") if isinstance(proposal.get("source"), dict) else {} encoded = proposal.get("encoded") if isinstance(proposal.get("encoded"), dict) else {} original = proposal.get("original") if isinstance(proposal.get("original"), dict) else {} table = str(source.get("table") or payload.get("table") or "") file_name = str(source.get("file_name") or payload.get("file_name") or "") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "proposal.source.table", "Only saved-state tables may be applied.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) if not file_name or Path(file_name).name != file_name: return invalid_argument(method, "proposal.source.file_name", "Proposal source.file_name must be a safe storage FileName.") extension_guid, extension_separator, _extension_suffix = file_name.partition("__") extension_pair_required = bool( table == "ConfigCASSave" and extension_separator and is_guid_text(extension_guid.strip().lower()) ) for edit in proposal.get("edits") or []: if not isinstance(edit, dict): continue if str(edit.get("path") or "") == "2" and str(edit.get("mode") or "") != "path_preserve_format": return { "schema": "onec_storage_saved_state_apply.v1", "status": "blocked", "applied": False, "base_id": base_id_or_error, "error": "unsafe_form_module_payload_write", "source": {"table": table, "file_name": file_name}, "diagnostics": { "message": "Saved-state form module payload edits at path 2 must use path_preserve_format; canonical form payload serialization is blocked.", }, } payload_hex = encoded.get("payload_hex") if not isinstance(payload_hex, str) or not payload_hex: return invalid_argument(method, "proposal.encoded.payload_hex", "Proposal must include encoded.payload_hex. Re-run changes.propose with include_payload=true.") try: replacement = bytes.fromhex(payload_hex) except ValueError: return invalid_argument(method, "proposal.encoded.payload_hex", "encoded.payload_hex is not valid hex.") expected_encoded_sha1 = str(encoded.get("sha1") or "").lower() actual_encoded_sha1 = hashlib.sha1(replacement).hexdigest() if expected_encoded_sha1 and expected_encoded_sha1 != actual_encoded_sha1: return { "schema": "onec_storage_saved_state_apply.v1", "status": "precondition_failed", "applied": False, "base_id": base_id_or_error, "source": {"kind": "live_sql", "table": table, "file_name": file_name}, "encoded": {"expected_sha1": expected_encoded_sha1, "actual_sha1": actual_encoded_sha1, "bytes": len(replacement)}, "diagnostics": {"message": "encoded.payload_hex SHA1 differs from proposal encoded.sha1."}, } allow_unsafe_form_payload_apply, unsafe_apply_error = strict_bool_argument(payload, "allow_unsafe_form_payload_apply", method=method, default=False) if unsafe_apply_error: return unsafe_apply_error if proposal.get("method") == FORM_ELEMENT_WRITE_METHOD and not allow_unsafe_form_payload_apply: original_bytes = original.get("bytes") encoded_bytes = encoded.get("bytes") encoded_validation = proposal.get("validation") if isinstance(proposal.get("validation"), dict) else {} if encoded_validation.get("mode") == "path" and original_bytes != encoded_bytes: return { "schema": "onec_storage_saved_state_apply.v1", "status": "unsafe_form_payload_rewrite", "applied": False, "base_id": base_id_or_error, "source": {"kind": "live_sql", "table": table, "file_name": file_name}, "original": {"sha1": original.get("sha1"), "bytes": original_bytes}, "encoded": {"sha1": actual_encoded_sha1, "bytes": encoded_bytes}, "diagnostics": { "message": "Saved-state form write proposal rewrites the serialized form payload length. This can corrupt 1C form streams; apply is blocked until the codec preserves the original byte layout.", "override": "Pass allow_unsafe_form_payload_apply=true only for disposable test bases.", }, } expected_original_sha1 = str(original.get("sha1") or payload.get("expected_sha1") or "").lower() if not expected_original_sha1: return invalid_argument(method, "proposal.original.sha1", "Proposal must include original.sha1 for the write precondition.") if extension_pair_required: return apply_extension_saved_state_pair( base_id_or_error, module_file_name=file_name, replacement_module_data=replacement, expected_module_sha1=expected_original_sha1, proposal=proposal, timeout_seconds=int(timeout_seconds or 30), ) return apply_storage_file_bytes_single_part( base_id_or_error, table, file_name, replacement, expected_sha1=expected_original_sha1, proposal=proposal, timeout_seconds=int(timeout_seconds or 30), ) WRITE_CONTEXT_KEYS = ( "lock_session_id", "repository_object", "layer_id", "support_object_guid", "extension_guid", "owner_resolution", "development_layer_resolution", "module_ref", "module_id", "table", "file_name", "stream_index", "expected_sha1", "expected_text_sha1", ) def repository_write_context(payload: dict[str, Any]) -> dict[str, Any]: """Carry resolved repository/support coordination through nested writes.""" return { key: payload[key] for key in WRITE_CONTEXT_KEYS if payload.get(key) is not None } REPOSITORY_WRITE_CONTEXT_KEYS = WRITE_CONTEXT_KEYS def normalize_repository_write_context(payload: dict[str, Any], method: str) -> tuple[dict[str, Any], dict[str, Any] | None]: """Accept the confirmation write_context directly or under repository_lock. Top-level fields remain the canonical transport. Nested forms exist so a client can forward repository.lock.confirm.write_context verbatim. """ normalized = dict(payload) for container_name in ("write_context", "repository_lock"): context = payload.get(container_name) if context is None: continue if not isinstance(context, dict): return payload, { "status": "blocked", "error": "invalid_repository_lock_context", "argument": container_name, "message": f"{container_name} must be an object returned by repository.lock.confirm.write_context.", } for key in REPOSITORY_WRITE_CONTEXT_KEYS: nested_value = context.get(key) if nested_value is None: continue current_value = normalized.get(key) if current_value is not None and str(current_value).strip() != str(nested_value).strip(): return payload, { "status": "blocked", "error": "repository_lock_context_conflict", "argument": key, "message": f"Top-level {key} conflicts with {container_name}.{key}.", "top_level": current_value, "nested": nested_value, } normalized[key] = nested_value return normalized, None def storage_saved_state_rollback(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.rollback" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error repository_error = repository_apply_gate(payload, method, "apply_and_rollback") if repository_error: return repository_error allow_rollback, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_error: return allow_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "Saved-state rollback is opt-in; pass allow_sql_saved_state_rollback=true.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error backup_id = str(payload.get("backup_id") or "").strip() backup_path = str(payload.get("backup_path") or "").strip() resolved = resolve_storage_apply_backup_path(backup_id or None, backup_path or None) if isinstance(resolved, dict): return resolved try: evidence = json.loads(resolved.read_text(encoding="utf-8-sig")) except Exception as exc: return { "schema": "onec_storage_saved_state_rollback.v1", "status": "error", "applied": False, "base_id": base_id_or_error, "backup": {"path": str(resolved)}, "diagnostics": {"message": f"Could not read backup evidence: {exc}"}, } rollback = evidence.get("rollback") if isinstance(evidence.get("rollback"), dict) else {} # v1 backups store a complete request. Accept the short-lived payload # spelling too, so existing backup evidence remains recoverable. rollback_payload = ( rollback.get("request") if isinstance(rollback.get("request"), dict) else rollback.get("payload") if isinstance(rollback.get("payload"), dict) else None ) if not rollback_payload: return { "schema": "onec_storage_saved_state_rollback.v1", "status": "invalid_backup", "applied": False, "base_id": base_id_or_error, "backup": {"path": str(resolved), "backup_id": evidence.get("backup_id")}, "diagnostics": {"message": "Backup evidence does not contain rollback.request or rollback.payload."}, } rollback_payload = dict(rollback_payload) rollback_payload["base_id"] = base_id_or_error rollback_payload["allow_sql_saved_state_apply"] = True rollback_payload["timeout_seconds"] = int(timeout_seconds or 30) rollback_payload.update(repository_write_context(payload)) for selector in ("ref", "kind", "name", "guid", "extension", "extension_guid", "layer_id"): if payload.get(selector) not in {None, ""}: rollback_payload[selector] = payload[selector] # This is an internal continuation of a rollback that has already passed # the public gate. Rebind the proof after loading the backup request, # whose storage-only fields are intentionally not part of the caller API. if adapter_write_authorization_is_valid(payload): rollback_payload["_adapter_write_authorization"] = adapter_write_authorization_proof(rollback_payload) apply_result = storage_saved_state_apply_proposal(rollback_payload) return { "schema": "onec_storage_saved_state_rollback.v1", "status": apply_result.get("status"), "applied": bool(apply_result.get("applied")), "base_id": base_id_or_error, "backup": { "backup_id": evidence.get("backup_id"), "path": str(resolved), "source": evidence.get("source"), "original": {key: value for key, value in (evidence.get("original") or {}).items() if key != "payload_hex"}, "replacement": evidence.get("replacement"), }, "apply_result": apply_result, } def storage_saved_state_backups_list(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.backups.list" base_id = str(payload.get("base_id") or "").strip() table_filter = str(payload.get("table") or "").strip() file_filter = str(payload.get("file_name") or "").strip() limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=500) if limit_error: return limit_error root = storage_apply_backup_dir() backups: list[dict[str, Any]] = [] if root.exists(): for path in sorted(root.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True): if len(backups) >= int(limit or 50): break try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: continue source = data.get("source") if isinstance(data.get("source"), dict) else {} if base_id and str(data.get("base_id") or "") != base_id: continue if table_filter and str(source.get("table") or "") != table_filter: continue if file_filter and str(source.get("file_name") or "") != file_filter: continue backups.append( { "backup_id": data.get("backup_id"), "created_at_utc": data.get("created_at_utc"), "base_id": data.get("base_id"), "source": {key: source.get(key) for key in ("database", "table", "file_name") if source.get(key) is not None}, "original": {key: (data.get("original") or {}).get(key) for key in ("sha1", "bytes")}, "replacement": {key: (data.get("replacement") or {}).get(key) for key in ("sha1", "bytes")}, "path": str(path), } ) return { "schema": "onec_saved_state_backups.v1", "method": method, "status": "ok", "backup_dir": str(root), "backups": backups, "counts": {"returned": len(backups)}, } def storage_saved_state_backups_prune(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.backups.prune" base_id = str(payload.get("base_id") or "").strip() table_filter = str(payload.get("table") or "").strip() file_filter = str(payload.get("file_name") or "").strip() older_than_days = int(payload.get("older_than_days") or 30) keep_latest = int(payload.get("keep_latest") if payload.get("keep_latest") is not None else 20) limit = int(payload.get("limit") or 500) dry_run = payload.get("dry_run") is not False confirm_delete = payload.get("confirm_delete") is True if not dry_run and not confirm_delete: return { "schema": "onec_saved_state_backups_prune.v1", "method": method, "status": "confirmation_required", "error": "confirm_delete_required", "base_id": base_id, "dry_run": False, "deleted": False, } root = storage_apply_backup_dir().resolve() cutoff = time.time() - (older_than_days * 86400) matching: list[tuple[Path, dict[str, Any], float]] = [] if root.is_dir(): for candidate in root.glob("*.json"): try: path = candidate.resolve() path.relative_to(root) stat = path.stat() evidence = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError): continue if not isinstance(evidence, dict) or evidence.get("schema") != "onec_storage_apply_backup.v1": continue source = evidence.get("source") if isinstance(evidence.get("source"), dict) else {} if base_id and str(evidence.get("base_id") or "") != base_id: continue if table_filter and str(source.get("table") or "") != table_filter: continue if file_filter and str(source.get("file_name") or "") != file_filter: continue matching.append((path, evidence, float(stat.st_mtime))) matching.sort(key=lambda item: item[2], reverse=True) protected_latest = {path for path, _evidence, _mtime in matching[:keep_latest]} matching_base_ids = { str(evidence.get("base_id") or "").strip() for _path, evidence, _mtime in matching if str(evidence.get("base_id") or "").strip() } history_backup_ids: set[str] = set() history_unavailable_base_ids: set[str] = set() for matching_base_id in matching_base_ids: referenced_ids = metadata_write_history_referenced_backup_ids(matching_base_id) if referenced_ids is None: history_unavailable_base_ids.add(matching_base_id) else: history_backup_ids.update(referenced_ids) protected_history = { path for path, evidence, _mtime in matching if str(evidence.get("backup_id") or "") in history_backup_ids } protected_history_unavailable = { path for path, evidence, _mtime in matching if not str(evidence.get("base_id") or "").strip() or str(evidence.get("base_id") or "").strip() in history_unavailable_base_ids } protected = protected_latest | protected_history | protected_history_unavailable selected = [ item for item in matching if item[0] not in protected and item[2] < cutoff ][:limit] deleted: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] for path, evidence, modified_at in selected: item = { "backup_id": evidence.get("backup_id"), "created_at_utc": evidence.get("created_at_utc"), "modified_at": datetime.fromtimestamp(modified_at, timezone.utc).isoformat(), "path": str(path), } if not dry_run: try: path.unlink() item["deleted"] = True except OSError as exc: item["deleted"] = False errors.append( { "backup_id": evidence.get("backup_id"), "error": "backup_delete_failed", "message": str(exc), } ) else: item["deleted"] = False deleted.append(item) return { "schema": "onec_saved_state_backups_prune.v1", "method": method, "status": "ok" if not errors else "partial", "base_id": base_id, "dry_run": dry_run, "deleted": bool(not dry_run and any(item.get("deleted") for item in deleted)), "query": { "table": table_filter or None, "file_name": file_filter or None, "older_than_days": older_than_days, "keep_latest": keep_latest, "limit": limit, }, "backup_dir": str(root), "backups": deleted, "counts": { "matching": len(matching), "protected": len(protected), "protected_latest": len(protected_latest), "protected_by_write_history": len(protected_history), "protected_history_unavailable": len(protected_history_unavailable), "selected": len(selected), "deleted": sum(1 for item in deleted if item.get("deleted")), "errors": len(errors), }, "errors": errors, "diagnostics": { "onec_database_used": False, "confirmation_required_for_delete": True, "write_history_backups_protected": True, "write_history_unavailable_base_ids": sorted(history_unavailable_base_ids), }, } def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.dbnames.summary") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error prefix_error = validate_optional_string_arguments(payload, "metadata.dbnames.summary", ["prefix"]) if prefix_error: return prefix_error limit, limit_error = parse_int_argument(payload, "limit", method="metadata.dbnames.summary", default=50, minimum=1, maximum=5000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.dbnames.summary", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "metadata.dbnames.summary") if diagnostic_error: return diagnostic_error prefix = str(payload.get("prefix") or "DBNames") files = storage_files_list({"base_id": base_id, "table": "Params", "prefix": prefix, "limit": limit, "timeout_seconds": timeout_seconds, "_internal": True}) if files.get("status") != "ok": result = dict(files) result["method"] = "metadata.dbnames.summary" return result try: from parser.dbnames import parse_dbnames_bytes, parse_dbnames_version_bytes except Exception as exc: return { "schema": "onec_metadata_dbnames_summary.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "table": "Params"}, "diagnostics": {"message": f"DBNames parser is unavailable: {exc}"}, "dbnames": [], "counts": {"files": 0, "records": 0}, } summaries = [] total_records = 0 role_counts: dict[str, int] = {} main_dbnames_ok = False version_seen = False version_token: str | None = None fallback_tokens: list[str] = [] for file_row in files.get("files") or []: file_name = str(file_row.get("FileName") or "") if not file_name.startswith("DBNames") or file_name.startswith("DBNamesVersion-"): continue data, config, error = read_storage_file_bytes(base_id, "Params", file_name, timeout_seconds=int(timeout_seconds or 30)) if error: summaries.append({"file_name": file_name, "status": "error", "diagnostics": error.get("diagnostics")}) continue try: parsed = ( parse_dbnames_version_bytes(data, source=file_name) if file_name == "DBNamesVersion" else parse_dbnames_bytes(data, source=file_name) ) except Exception as exc: summaries.append({"file_name": file_name, "status": "error", "diagnostics": {"message": str(exc)}}) continue if file_name == "DBNamesVersion": version_seen = True version_token = str(parsed.get("version") or "") or None summaries.append( { "file_name": file_name, "status": "ok", "bytes": len(data), "sha1": hashlib.sha1(data).hexdigest(), "compression": parsed.get("compression"), "encoding": parsed.get("encoding"), "marker": parsed.get("marker"), "version": parsed.get("version"), } ) continue records = parsed.get("records") or [] main_dbnames_ok = True fallback_tokens.append(hashlib.sha1(data).hexdigest()) total_records += len(records) local_roles: dict[str, int] = {} for record in records: role = getattr(record, "storage_role", "") local_roles[role] = local_roles.get(role, 0) + 1 role_counts[role] = role_counts.get(role, 0) + 1 summaries.append( { "file_name": file_name, "status": "ok", "bytes": len(data), "sha1": hashlib.sha1(data).hexdigest(), "compression": parsed.get("compression"), "encoding": parsed.get("encoding"), "declared_count": parsed.get("declared_count"), "records": len(records), "role_counts": dict(sorted(local_roles.items())), } ) has_errors = any(item.get("status") == "error" for item in summaries) status = "ok" if main_dbnames_ok and version_seen and not has_errors else ("degraded" if main_dbnames_ok else "error") fallback_token = None if len(fallback_tokens) == 1: fallback_token = fallback_tokens[0] elif fallback_tokens: fallback_token = hashlib.sha1("|".join(sorted(fallback_tokens)).encode("ascii")).hexdigest() freshness_token = version_token or fallback_token return { "schema": "onec_metadata_dbnames_summary.v1", "status": status, "base_id": base_id, "source": {"kind": "live_sql", "table": "Params"}, "dbnames": summaries, "counts": {"files": len(summaries), "records": total_records}, "role_counts": dict(sorted(role_counts.items())), "freshness": { "token": freshness_token, "source": "DBNamesVersion" if version_token else "dbnames_sha1", "degraded": status == "degraded", }, } def live_dbnames_records(base_id: str, *, limit_files: int = 200, timeout_seconds: int = 30) -> tuple[list[Any] | None, dict[str, Any] | None]: files = storage_files_list({"base_id": base_id, "table": "Params", "prefix": "DBNames", "limit": limit_files, "timeout_seconds": timeout_seconds, "_internal": True}) if files.get("status") != "ok": error = dict(files) error["method"] = "metadata.dbnames.records" return None, error try: from parser.dbnames import parse_dbnames_bytes except Exception as exc: return None, { "schema": "onec_adapter_error.v1", "status": "error", "base_id": base_id, "diagnostics": {"message": f"DBNames parser is unavailable: {exc}"}, } records = [] for file_row in files.get("files") or []: file_name = str(file_row.get("FileName") or "") if ( not file_name.startswith("DBNames") or file_name == "DBNamesVersion" or file_name.startswith("DBNamesVersion-") ): continue data, _, error = read_storage_file_bytes(base_id, "Params", file_name, timeout_seconds=timeout_seconds) if error: return None, error try: parsed = parse_dbnames_bytes(data, source=file_name) except Exception as exc: return None, { "schema": "onec_adapter_source_error.v1", "method": "metadata.dbnames.records", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "table": "Params", "file_name": file_name}, "diagnostics": {"message": str(exc)}, } records.extend(parsed.get("records") or []) return records, None def config_identity_from_bytes(data: bytes) -> dict[str, Any] | None: try: from parser.config_object import find_identity from parser.payload import parse_brace_text, payload_to_text except Exception: return None decoded = payload_to_text(data) text = decoded.get("text") if not text or "{" not in text: return None try: identity = find_identity(parse_brace_text(text)) except Exception: return None if not identity: return None result = identity.to_dict() if result.get("name"): variants = text_variants(result.get("name")) result["name"] = best_text_variant(result.get("name")) if len(variants) > 1: result["name_variants"] = variants synonyms = result.get("synonyms") if isinstance(synonyms, dict): synonym_variants: dict[str, list[str]] = {} for key, value in list(synonyms.items()): if not value: continue variants = text_variants(value) synonyms[key] = best_text_variant(value) if len(variants) > 1: synonym_variants[key] = variants if synonym_variants: result["synonym_variants"] = synonym_variants return result def parse_config_tree_from_bytes(data: bytes) -> Any | None: try: from parser.payload import decode_payload_lossless, parse_brace_text except Exception: return None decoded = decode_payload_lossless(data) text = decoded.get("text") if not text or "{" not in text: return None try: return parse_brace_text(text) except Exception: return None def payload_text_from_bytes(data: bytes) -> dict[str, Any]: try: from parser.payload import payload_to_text except Exception as exc: return {"status": "error", "diagnostics": {"message": f"Payload parser is unavailable: {exc}"}} decoded = payload_to_text(data) return { "status": "ok" if decoded.get("text") is not None else "undecodable", "compression": decoded.get("compression"), "encoding": decoded.get("encoding"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes"), "text": decoded.get("text"), } def decode_payload_full(data: bytes, *, include_text: bool = True, include_tree: bool = False) -> dict[str, Any]: try: from parser.payload import decode_payload_lossless, parse_brace_text, root_signature except Exception as exc: return {"status": "error", "diagnostics": {"message": f"Payload parser is unavailable: {exc}"}} decoded = decode_payload_lossless(data) text = decoded.get("text") result: dict[str, Any] = { "status": "ok" if text is not None else "undecodable", "compression": decoded.get("compression"), "encoding": decoded.get("encoding"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes"), "sha1": hashlib.sha1(data).hexdigest(), } tree = None if text and "{" in text: try: tree = parse_brace_text(text) result["root"] = root_signature(tree) except Exception as exc: result["tree_error"] = str(exc) if include_text: result["text"] = text if include_tree: result["tree"] = tree return result def payload_source_bytes(source: dict[str, Any], *, default_base_id: str | None = None, timeout_seconds: int = 30) -> tuple[bytes | None, dict[str, Any], dict[str, Any] | None]: if not isinstance(source, dict): return None, {}, invalid_argument("payload.diff", "source", "source must be a JSON object.") if source.get("payload_base64") not in {None, ""}: try: data = base64.b64decode(str(source.get("payload_base64") or ""), validate=True) except Exception as exc: return None, {}, invalid_argument("payload.diff", "payload_base64", f"payload_base64 is not valid base64: {exc}") return data, {"kind": "inline", "encoding": "base64", "bytes": len(data)}, None if source.get("payload_hex") not in {None, ""}: try: data = bytes.fromhex(str(source.get("payload_hex") or "")) except Exception as exc: return None, {}, invalid_argument("payload.diff", "payload_hex", f"payload_hex is not valid hex: {exc}") return data, {"kind": "inline", "encoding": "hex", "bytes": len(data)}, None if source.get("text") is not None: if not isinstance(source.get("text"), str): return None, {}, invalid_argument("payload.diff", "text", "text must be a JSON string.") encoding = str(source.get("encoding") or "utf-8-sig") try: data = str(source.get("text") or "").encode(encoding) except Exception as exc: return None, {}, invalid_argument("payload.diff", "encoding", f"text cannot be encoded with {encoding}: {exc}") return data, {"kind": "inline", "encoding": encoding, "bytes": len(data)}, None base_id = str(source.get("base_id") or default_base_id or "").strip() table = str(source.get("table") or "").strip() file_name = str(source.get("file_name") or "").strip() if not base_id: return None, {}, base_id_required("payload.diff") if table not in STORAGE_TABLES: return None, {}, invalid_argument("payload.diff", "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) if not file_name or Path(file_name).name != file_name: return None, {}, invalid_argument("payload.diff", "file_name", "Pass a single safe FileName value from the live SQL storage table.") data, config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) if error: error["method"] = "payload.diff" return None, {}, error return data, {"kind": "live_sql", "database": (config or {}).get("database"), "table": table, "file_name": file_name}, None def payload_diff_node_summary(value: Any) -> dict[str, Any]: if isinstance(value, dict): if value.get("type") == "list": items = value.get("items") if isinstance(value.get("items"), list) else [] head = items[0].get("value") if items and isinstance(items[0], dict) else None return {"type": "list", "items": len(items), "head": head} return {"type": "dict", "keys": len(value)} if isinstance(value, list): return {"type": "list", "items": len(value)} return {"type": type(value).__name__, "value": value} def payload_diff_scalar(value: Any) -> Any: if isinstance(value, dict) and value.get("type") in {"string", "atom"}: return value.get("value") if isinstance(value, (str, int, float, bool)) or value is None: return value return None def payload_tree_scalar_changes(before: Any, after: Any, *, max_changes: int = 200, path: str = "$") -> list[dict[str, Any]]: changes: list[dict[str, Any]] = [] def walk(left: Any, right: Any, current_path: str) -> None: if len(changes) >= max_changes: return left_scalar = payload_diff_scalar(left) right_scalar = payload_diff_scalar(right) if left_scalar is not None or right_scalar is not None: if left_scalar != right_scalar: changes.append({"path": current_path, "old": left_scalar, "new": right_scalar}) return if isinstance(left, dict) and left.get("type") == "list": left_items = left.get("items") if isinstance(left.get("items"), list) else [] right_items = right.get("items") if isinstance(right, dict) and right.get("type") == "list" and isinstance(right.get("items"), list) else [] max_len = max(len(left_items), len(right_items)) for index in range(max_len): next_path = f"{current_path}.{index}" if index >= len(left_items): changes.append({"path": next_path, "old": None, "new": payload_diff_node_summary(right_items[index]), "kind": "added_node"}) elif index >= len(right_items): changes.append({"path": next_path, "old": payload_diff_node_summary(left_items[index]), "new": None, "kind": "removed_node"}) else: walk(left_items[index], right_items[index], next_path) if len(changes) >= max_changes: break return if isinstance(left, dict) and isinstance(right, dict): keys = sorted(set(left) | set(right)) for key in keys: walk(left.get(key), right.get(key), f"{current_path}.{key}") if len(changes) >= max_changes: break return if payload_diff_node_summary(left) != payload_diff_node_summary(right): changes.append({"path": current_path, "old": payload_diff_node_summary(left), "new": payload_diff_node_summary(right), "kind": "changed_node"}) walk(before, after, path) return changes def payload_strings(value: Any, *, limit: int = 10000) -> list[str]: strings: list[str] = [] def walk(node: Any) -> None: if len(strings) >= limit: return if isinstance(node, dict): if node.get("type") == "string": strings.append(str(node.get("value") or "")) return for child in node.values(): walk(child) elif isinstance(node, list): for child in node: walk(child) walk(value) return strings def payload_diff(payload: dict[str, Any]) -> dict[str, Any]: method = "payload.diff" base_id = str(payload.get("base_id") or "").strip() or None timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error max_changes, max_changes_error = parse_int_argument(payload, "max_changes", method=method, default=200, minimum=1, maximum=5000) if max_changes_error: return max_changes_error max_text_diff_lines, max_text_diff_lines_error = parse_int_argument(payload, "max_text_diff_lines", method=method, default=200, minimum=0, maximum=5000) if max_text_diff_lines_error: return max_text_diff_lines_error for argument, default in (("include_text_diff", True), ("include_tree_diff", True), ("include_evidence", True)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error diagnostic_error = require_diagnostic_mode(payload, method) if diagnostic_error: return diagnostic_error before_source = payload.get("before") after_source = payload.get("after") if not isinstance(before_source, dict): return invalid_argument(method, "before", "before must be a JSON object source.") if not isinstance(after_source, dict): return invalid_argument(method, "after", "after must be a JSON object source.") before_bytes, before_public, before_error = payload_source_bytes(before_source, default_base_id=base_id, timeout_seconds=int(timeout_seconds or 30)) if before_error: return before_error after_bytes, after_public, after_error = payload_source_bytes(after_source, default_base_id=base_id, timeout_seconds=int(timeout_seconds or 30)) if after_error: return after_error before_bytes = before_bytes or b"" after_bytes = after_bytes or b"" before_decoded = decode_payload_full(before_bytes, include_text=True, include_tree=bool(payload.get("include_tree_diff", True))) after_decoded = decode_payload_full(after_bytes, include_text=True, include_tree=bool(payload.get("include_tree_diff", True))) before_text = before_decoded.get("text") if isinstance(before_decoded.get("text"), str) else "" after_text = after_decoded.get("text") if isinstance(after_decoded.get("text"), str) else "" text_diff_lines: list[str] = [] if bool(payload.get("include_text_diff", True)) and before_text != after_text and int(max_text_diff_lines or 0) > 0: text_diff_lines = list( difflib.unified_diff( before_text.splitlines(), after_text.splitlines(), fromfile="before", tofile="after", lineterm="", n=3, ) )[: int(max_text_diff_lines or 200)] tree_changes: list[dict[str, Any]] = [] string_changes: list[dict[str, Any]] = [] if bool(payload.get("include_tree_diff", True)): before_tree = before_decoded.get("tree") after_tree = after_decoded.get("tree") if before_tree is not None and after_tree is not None: tree_changes = payload_tree_scalar_changes(before_tree, after_tree, max_changes=int(max_changes or 200)) before_strings = payload_strings(before_tree) after_strings = payload_strings(after_tree) for index in range(max(len(before_strings), len(after_strings))): if len(string_changes) >= int(max_changes or 200): break old = before_strings[index] if index < len(before_strings) else None new = after_strings[index] if index < len(after_strings) else None if old != new: string_changes.append({"index": index, "old": old, "new": new}) result: dict[str, Any] = { "schema": "onec_payload_diff.v1", "method": method, "status": "unchanged" if hashlib.sha1(before_bytes).hexdigest() == hashlib.sha1(after_bytes).hexdigest() else "changed", "source": {"before": before_public, "after": after_public}, "bytes": { "before": len(before_bytes), "after": len(after_bytes), "delta": len(after_bytes) - len(before_bytes), "same": before_bytes == after_bytes, }, "sha1": { "before": hashlib.sha1(before_bytes).hexdigest(), "after": hashlib.sha1(after_bytes).hexdigest(), "same": hashlib.sha1(before_bytes).hexdigest() == hashlib.sha1(after_bytes).hexdigest(), }, "decoded": { "before": {key: before_decoded.get(key) for key in ("status", "compression", "encoding", "raw_bytes", "payload_bytes", "root", "tree_error") if key in before_decoded}, "after": {key: after_decoded.get(key) for key in ("status", "compression", "encoding", "raw_bytes", "payload_bytes", "root", "tree_error") if key in after_decoded}, }, "text": { "same": before_text == after_text, "before_chars": len(before_text), "after_chars": len(after_text), "diff_lines": text_diff_lines, "diff_truncated": bool(text_diff_lines) and len(text_diff_lines) >= int(max_text_diff_lines or 0), }, "tree": { "same": not tree_changes, "changes": tree_changes, "changes_truncated": len(tree_changes) >= int(max_changes or 200), }, "strings": { "same": not string_changes, "changes": string_changes, "changes_truncated": len(string_changes) >= int(max_changes or 200), }, "counts": { "tree_changes": len(tree_changes), "string_changes": len(string_changes), "text_diff_lines": len(text_diff_lines), }, } if bool(payload.get("include_evidence", True)): try: from parser.cas_payload import classify_payload before_classified = classify_payload(before_bytes, include_text=False, include_tree=False) after_classified = classify_payload(after_bytes, include_text=False, include_tree=False) result["evidence"] = { "before": payload_public_undecoded_evidence(before_classified, mode="summary"), "after": payload_public_undecoded_evidence(after_classified, mode="summary"), } except Exception as exc: result["evidence"] = {"status": "error", "diagnostics": {"message": str(exc)}} return result def decode_config_object_full( data: bytes, *, kind: str | None = None, dbnames_records: list[Any] | None = None, include_text: bool = False, include_tree: bool = False, max_depth: int = 3, semantic_include_generic: bool = True, semantic_categories: set[str] | list[str] | tuple[str, ...] | None = None, semantic_lightweight: bool = False, ) -> dict[str, Any]: try: from parser.config_semantic import decode_config_semantic from parser.payload import decode_payload_lossless, parse_brace_text, root_signature except Exception as exc: return {"status": "error", "diagnostics": {"message": f"Config semantic decoder is unavailable: {exc}"}} decoded = decode_payload_lossless(data) text = decoded.get("text") result: dict[str, Any] = { "status": "ok" if text is not None else "undecodable", "compression": decoded.get("compression"), "encoding": decoded.get("encoding"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes"), "sha1": hashlib.sha1(data).hexdigest(), } if not text or "{" not in text: return result try: tree = parse_brace_text(text) result["root"] = root_signature(tree) result["semantic"] = decode_config_semantic( tree, kind=kind, dbnames_records=dbnames_records, max_depth=max_depth, include_generic=semantic_include_generic, categories=semantic_categories, lightweight=semantic_lightweight, ) if include_tree: result["tree"] = tree if include_text: result["text"] = text except Exception as exc: result["status"] = "error" result["diagnostics"] = {"message": str(exc)} return result def text_snippet(text: str, query: str, *, radius: int = 160) -> dict[str, Any]: lower = text.casefold() wanted = query.casefold() index = lower.find(wanted) if index < 0: return {"offset": None, "text": text[: radius * 2]} start = max(0, index - radius) end = min(len(text), index + len(query) + radius) return {"offset": index, "text": text[start:end]} def extract_bsl_text_from_container(text: str, *, bsl_offset: int | None = None) -> tuple[str, dict[str, Any]]: if not text: return "", {"status": "empty"} candidates = [] if bsl_offset is not None and bsl_offset >= 0: candidates.append(bsl_offset) patterns = [ r"(?m)^[ \t]*&[А-Яа-яA-Za-z]", r"(?im)^[ \t]*(Процедура|Функция)\s+[A-Za-zА-Яа-яЁё_]", ] for pattern in patterns: match = re.search(pattern, text) if match: candidates.append(match.start()) if not candidates: return text, {"status": "not_found", "diagnostics": {"message": "BSL start marker was not found; returning container text."}} start = max(0, min(candidates)) return text[start:], {"status": "ok", "bsl_offset": start, "container_chars": len(text)} def live_config_identity( base_id: str, guid: str, *, timeout_seconds: int = 30, table: str = "Config", ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: data, _, error = read_storage_file_bytes(base_id, table, guid.lower(), timeout_seconds=timeout_seconds) if error: if error.get("status") == "source_missing": return None, None return None, error return config_identity_from_bytes(data), None def dbnames_kind_counts(records: list[Any]) -> dict[str, int]: seen: set[tuple[str, str]] = set() counts: dict[str, int] = {} for record in records: role = getattr(record, "storage_role", "") kind = DBNAMES_ROLE_KIND.get(role) guid = str(getattr(record, "guid", "") or "").lower() if not kind or not guid: continue key = (kind, guid) if key in seen: continue seen.add(key) public = PUBLIC_KIND.get(kind, "other") counts[public] = counts.get(public, 0) + 1 return counts def generated_type_records_from_tree(tree: Any, *, kind: str | None, name: str | None, guid: str | None) -> list[dict[str, Any]]: try: from parser.payload import GUID_RE as PAYLOAD_GUID_RE, scalar except Exception: return [] if not isinstance(tree, dict): return [] root_items = tree.get("items") or [] if len(root_items) < 2 or not isinstance(root_items[1], dict): return [] items = root_items[1].get("items") or [] if len(items) < 4: return [] categories = GENERATED_TYPE_CATEGORIES.get(str(kind or ""), []) prefix = GENERATED_TYPE_PREFIX.get(str(kind or ""), str(kind or "")) result = [] zero_guid = "00000000-0000-0000-0000-000000000000" # Current 8.3 payloads commonly place an identity list before the generated # type/value GUID pairs. Find the longest direct-child GUID run instead of # assuming that pairs always start at body index 1. runs: list[tuple[int, int]] = [] run_start: int | None = None for item_index, item in enumerate(items): value = str(scalar(item) or "").lower() is_generated_guid = bool(PAYLOAD_GUID_RE.fullmatch(value)) and value != zero_guid if is_generated_guid and run_start is None: run_start = item_index elif not is_generated_guid and run_start is not None: runs.append((run_start, item_index)) run_start = None if run_start is not None: runs.append((run_start, len(items))) even_runs = [(start, end - ((end - start) % 2)) for start, end in runs if end - start >= 2] start_index, end_index = max(even_runs, key=lambda run: (run[1] - run[0], -run[0]), default=(1, 1)) pair_index = 0 index = start_index while index + 1 < end_index: type_id = str(scalar(items[index]) or "") value_id = str(scalar(items[index + 1]) or "") category = categories[pair_index] if pair_index < len(categories) else f"Generated{pair_index + 1}" if str(kind or "") == "DefinedType" and name: generated_name = f"DefinedType.{name}" else: generated_name = f"{prefix}{category}.{name}" if prefix and category and name else None result.append( { "type_guid": type_id.lower(), "value_guid": value_id.lower(), "category": category, "name": generated_name, "owner": { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(str(kind or ""), kind), "name": name, }, "presentation": f"cfg:{generated_name}" if generated_name else "", } ) pair_index += 1 index += 2 # Object payloads on current 8.3 builds keep the Manager type/value pair # later in the header, after kind-specific scalar properties. It is not # necessarily contiguous with Object/Ref/Selection/List pairs above. if "Manager" in categories and not any(record.get("category") == "Manager" for record in result): late_pairs: list[tuple[str, str]] = [] for late_index in range(max(end_index, 9), len(items) - 1): type_id = scalar(items[late_index]).lower() value_id = scalar(items[late_index + 1]).lower() if ( PAYLOAD_GUID_RE.fullmatch(type_id) and PAYLOAD_GUID_RE.fullmatch(value_id) and type_id != zero_guid and value_id != zero_guid ): late_pairs.append((type_id, value_id)) if late_pairs: type_id, value_id = late_pairs[-1] generated_name = f"{prefix}Manager.{name}" if prefix and name else None result.append( { "type_guid": type_id, "value_guid": value_id, "category": "Manager", "name": generated_name, "owner": { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(str(kind or ""), kind), "name": name, }, "presentation": f"cfg:{generated_name}" if generated_name else "", } ) return result def looks_like_defined_type_tree(tree: Any) -> bool: values = tree_ordered_scalars(tree, limit=40) return ( len(values) > 17 and values[0] == "1" and values[1] == "0" and is_guid_text(values[2]) and is_guid_text(values[3]) and values[4] == "3" and is_guid_text(values[7]) and values[16] == "Pattern" ) def generated_type_records_from_bytes(data: bytes, *, kind: str | None, guid: str | None) -> list[dict[str, Any]]: tree = parse_config_tree_from_bytes(data) if tree is None: return [] if str(kind or "") == "DefinedType" and not looks_like_defined_type_tree(tree): return [] identity = config_identity_from_bytes(data) or {} records = generated_type_records_from_tree(tree, kind=kind, name=identity.get("name"), guid=guid) if str(kind or "") == "DefinedType": raw_type = public_pattern_type_from_tree(tree, {}, raw=True) if raw_type: for record in records: record["value_type"] = raw_type return records def live_generated_type_map( base_id: str, type_guids: set[str], *, dbnames_records: list[Any] | None = None, timeout_seconds: int = 60, table: str = "Config", ) -> dict[str, dict[str, Any]]: if not type_guids: return {} wanted = {guid.lower() for guid in type_guids} candidates: dict[str, str] = {} for record in dbnames_records or []: role = getattr(record, "storage_role", "") kind = DBNAMES_ROLE_KIND.get(role) guid = str(getattr(record, "guid", "") or "").lower() if not kind or not guid or kind not in GENERATED_TYPE_CATEGORIES: continue candidates.setdefault(guid, kind) resolved: dict[str, dict[str, Any]] = {} items = list(candidates.items()) for start in range(0, len(items), 300): if wanted.issubset(resolved): break chunk = items[start : start + 300] payloads, _, error = read_storage_files_bytes( base_id, table, [guid for guid, _ in chunk], timeout_seconds=timeout_seconds, ) if error: break for guid, kind in chunk: data = (payloads or {}).get(guid) if not data: continue for generated in generated_type_records_from_bytes(data, kind=kind, guid=guid): type_guid = str(generated.get("type_guid") or "").lower() if type_guid in wanted: resolved[type_guid] = generated if wanted.issubset(resolved): break return resolved def live_defined_type_map( base_id: str, type_guids: set[str], *, known_config_guids: set[str] | None = None, timeout_seconds: int = 60, table: str = "Config", ) -> dict[str, dict[str, Any]]: if not type_guids: return {} wanted = {guid.lower() for guid in type_guids} known = known_config_guids or set() resolved: dict[str, dict[str, Any]] = {} last_file_name = "" while True: if wanted.issubset(resolved): break page = live_config_file_name_page_after( base_id, last_file_name, table=table, page_size=2000, timeout_seconds=timeout_seconds, ) if not page: break last_file_name = page[-1] candidates = [file_name for file_name in page if file_name not in known] if not candidates: continue payloads, _, error = read_storage_files_bytes(base_id, table, candidates, timeout_seconds=timeout_seconds) if error: continue for guid in candidates: data = (payloads or {}).get(guid) if not data: continue for generated in generated_type_records_from_bytes(data, kind="DefinedType", guid=guid): type_guid = str(generated.get("type_guid") or "").lower() if type_guid in wanted: resolved[type_guid] = generated if wanted.issubset(resolved): break return resolved def resolved_type_from_generated( base_id: str, type_guid: str, generated: dict[str, Any], *, timeout_seconds: int = 60, depth: int = 0, table: str = "Config", ) -> dict[str, Any]: owner = generated.get("owner") or {} raw_value_type = generated.get("value_type") if isinstance(generated.get("value_type"), dict) else None public_value_type = None if raw_value_type and raw_value_type.get("type_guid") and depth < 2: nested = resolve_type_guids( base_id, {str(raw_value_type.get("type_guid")).lower()}, timeout_seconds=timeout_seconds, _depth=depth + 1, table=table, ) public_value_type = public_type_info(raw_value_type, nested, include_storage=False) return { "guid": str(type_guid or "").lower(), "status": "ok", "guid_role": "generated_type", "generated_category": generated.get("category"), "generated_name": generated.get("name"), "value_guid": generated.get("value_guid"), "kind": owner.get("kind"), "kind_ru": owner.get("kind_ru"), "name": owner.get("name"), "owner_guid": owner.get("guid"), "presentation": generated.get("presentation") or "", **({"value_type": public_value_type} if public_value_type else {}), } def resolve_type_guids( base_id: str, type_guids: set[str], *, timeout_seconds: int = 60, _depth: int = 0, resolve_generated_live: bool = True, table: str = "Config", ) -> dict[str, dict[str, Any]]: if not type_guids: return {} requested = {str(guid or "").lower() for guid in type_guids if is_guid_text(str(guid or ""))} config, _ = sql_config_for_base(base_id) cached_types = metadata_guid_index_lookup_types(config, requested) if config else {} if config: legacy_cached = metadata_type_cache_lookup_many(config, requested - set(cached_types)) cached_types.update(legacy_cached) for legacy_guid, legacy_payload in legacy_cached.items(): metadata_type_cache_upsert(config, legacy_guid, legacy_payload) result: dict[str, dict[str, Any]] = dict(cached_types) for guid in sorted(requested): if guid in BUILTIN_TYPE_GUIDS: builtin = BUILTIN_TYPE_GUIDS[guid] resolved_builtin = { "guid": guid, "status": "ok", "guid_role": "builtin_type", "kind": "Builtin", "kind_ru": "ВстроенныйТип", "name": builtin.get("name"), "bsl_type": builtin.get("bsl_type"), "presentation": builtin.get("presentation") or "", } result[guid] = resolved_builtin unresolved_request = {guid for guid in requested if guid not in result or result[guid].get("status") != "ok"} if not unresolved_request: return result records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: return result kind_by_guid: dict[str, str] = {} for record in records or []: role = getattr(record, "storage_role", "") kind = DBNAMES_ROLE_KIND.get(role) guid = str(getattr(record, "guid", "") or "").lower() if kind and guid in unresolved_request and guid not in kind_by_guid: kind_by_guid[guid] = kind generated_candidates = { guid.lower() for guid in unresolved_request if guid.lower() not in kind_by_guid and guid.lower() not in BUILTIN_TYPE_GUIDS } generated_map = ( live_generated_type_map( base_id, generated_candidates, dbnames_records=records, timeout_seconds=timeout_seconds, table=table, ) if resolve_generated_live else {} ) identities_by_guid: dict[str, dict[str, Any]] = {} direct_identity_guids = sorted(guid for guid in unresolved_request if guid in kind_by_guid and guid not in generated_map) if direct_identity_guids: payloads, _, _identity_batch_error = read_storage_files_bytes( base_id, table, direct_identity_guids, timeout_seconds=timeout_seconds, ) for guid in direct_identity_guids: data = (payloads or {}).get(guid) if not data: continue identity = config_identity_from_bytes(data) if identity: identities_by_guid[guid] = identity unresolved_generated = {guid.lower() for guid in generated_candidates if guid.lower() not in generated_map} if unresolved_generated: known_config_guids = {str(getattr(record, "guid", "") or "").lower() for record in records or []} if resolve_generated_live: generated_map.update( live_defined_type_map( base_id, unresolved_generated, known_config_guids=known_config_guids, timeout_seconds=timeout_seconds, table=table, ) ) for guid in sorted(unresolved_request): if guid in generated_map: result[guid] = resolved_type_from_generated( base_id, guid, generated_map[guid], timeout_seconds=timeout_seconds, depth=_depth, table=table, ) if config: metadata_type_cache_upsert(config, guid, result[guid]) continue identity_error = None identity = identities_by_guid.get(guid) if identity is None and guid not in identities_by_guid: identity, identity_error = live_config_identity(base_id, guid, timeout_seconds=timeout_seconds, table=table) kind = kind_by_guid.get(guid) synonyms = (identity or {}).get("synonyms") or {} synonym = next(iter(synonyms.values()), None) if isinstance(synonyms, dict) else None name = (identity or {}).get("name") status = "ok" if identity or kind else "generated_type_unresolved" result[guid] = { "guid": guid, "status": status, "guid_role": "metadata_object" if status == "ok" else "generated_type", "kind": kind, "kind_ru": RU_KIND.get(kind or "", kind), "name": name, "synonym": synonym, "presentation": ".".join(part for part in [RU_KIND.get(kind or "", kind), name] if part), **( { "diagnostics": { "message": ( "Pattern reference points to a generated 1C type GUID, not directly to a metadata object GUID. " "Live generated-type mapping is not decoded yet." ) } } if status != "ok" else {} ), **({"diagnostics": identity_error.get("diagnostics")} if identity_error else {}), } if config and result[guid].get("status") == "ok": metadata_type_cache_upsert(config, guid, result[guid]) return result def collect_reference_type_guids_from_sections(sections: list[Any]) -> set[str]: type_guids: set[str] = set() for section in sections: if not isinstance(section, dict): continue for record in section.get("records") or []: if not isinstance(record, dict): continue record_type = record.get("type") or {} if isinstance(record_type, dict) and record_type.get("kind") == "reference" and record_type.get("type_guid"): type_guids.add(str(record_type.get("type_guid")).lower()) for column in record.get("columns") or []: if not isinstance(column, dict): continue column_type = column.get("type") or {} if isinstance(column_type, dict) and column_type.get("kind") == "reference" and column_type.get("type_guid"): type_guids.add(str(column_type.get("type_guid")).lower()) return type_guids def with_resolved_type(type_info: Any, resolved_types: dict[str, dict[str, Any]]) -> Any: if not isinstance(type_info, dict): return type_info result = dict(type_info) type_guid = str(result.get("type_guid") or "").lower() if type_guid and type_guid in resolved_types: result["resolved"] = resolved_types[type_guid] return result def resolved_type_presentation(resolved: dict[str, Any]) -> str: category = str(resolved.get("generated_category") or "") kind = str(resolved.get("kind") or "") name = str(resolved.get("name") or "") if not name: generated_name = str(resolved.get("generated_name") or "") if "." in generated_name: name = generated_name.rsplit(".", 1)[-1] if not name: return str(resolved.get("presentation") or "").removeprefix("cfg:") if category == "DefinedType": return f"ОпределяемыйТип.{name}" if category == "Ref": prefix = REF_TYPE_PRESENTATION_PREFIX.get(kind) elif category == "Object": prefix = OBJECT_TYPE_PRESENTATION_PREFIX.get(kind) elif category == "List": prefix = LIST_TYPE_PRESENTATION_PREFIX.get(kind) else: prefix = None if prefix: return f"{prefix}.{name}" if kind in RU_KIND: return f"{RU_KIND[kind]}.{name}" return str(resolved.get("presentation") or "").removeprefix("cfg:") or name def public_type_info(type_info: Any, resolved_types: dict[str, dict[str, Any]], *, include_storage: bool = False) -> Any: enriched = with_resolved_type(type_info, resolved_types) if not isinstance(enriched, dict) or include_storage: return enriched public = {key: value for key, value in enriched.items() if key not in {"type_guid", "resolved", "code"}} resolved = enriched.get("resolved") if isinstance(resolved, dict) and resolved.get("status") == "ok": presentation = resolved_type_presentation(resolved) if presentation: public["presentation"] = presentation if isinstance(resolved.get("value_type"), dict): public["value_type"] = resolved.get("value_type") elif enriched.get("kind") == "reference" and str(public.get("presentation") or "").strip() in {"", "Ссылка", "Reference"}: public["presentation"] = "Ссылка(тип ссылки не определен)" public["diagnostics"] = { "message": "Не удалось определить конкретный объект метаданных для ссылочного типа. Для служебной диагностики используйте include_storage=true.", } return public def platform_reference_type_fallback(owner_kind: str | None, field_name: str | None, public_type: Any) -> dict[str, Any] | None: if not isinstance(public_type, dict) or public_type.get("kind") != "reference": return None presentation = str(public_type.get("presentation") or "").strip() if presentation not in {"", "Ссылка", "Reference", "Ссылка(тип ссылки не определен)"}: return None normalized_name = normalize(field_name or "") if owner_kind == "Task" and normalized_name == normalize("Предмет"): return { "kind": "reference", "presentation": "ЛюбаяСсылка", "allowed_types": ["СправочникСсылка", "ДокументСсылка", "БизнесПроцессСсылка", "ЗадачаСсылка"], } if owner_kind == "BusinessProcess" and normalized_name == normalize("ЗадачаИсточник"): return { "kind": "reference", "presentation": "ЗадачаСсылка", } return None def public_reference_targets(type_info: Any) -> list[dict[str, str]]: """Return only metadata targets explicitly declared by a public reference type.""" if not isinstance(type_info, dict) or type_info.get("kind") != "reference": return [] presentation = str(type_info.get("presentation") or "").strip() for kind, prefix in REF_TYPE_PRESENTATION_PREFIX.items(): marker = f"{prefix}." if not presentation.startswith(marker): continue name = presentation[len(marker):].strip() if name: return [{"kind": kind, "name": name, "ref": f"{kind}.{name}", "presentation": presentation}] return [] def public_member_type_evidence( attributes: list[dict[str, Any]], tabular_sections: list[dict[str, Any]], dimensions: list[dict[str, Any]], resources: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Expose declared type evidence without inferring a business relationship.""" evidence: list[dict[str, Any]] = [] def append_item(item: dict[str, Any], member_kind: str, *, table: str | None = None) -> None: type_info = item.get("type") targets = public_reference_targets(type_info) entry: dict[str, Any] = { "member": item.get("name"), "member_kind": member_kind, "declared_type": type_info, "evidence": "metadata_declared_type", **({"table": table} if table else {}), } if targets: entry["status"] = "confirmed_target_type" entry["targets"] = targets elif isinstance(type_info, dict) and type_info.get("kind") == "reference": allowed_types = type_info.get("allowed_types") entry["status"] = "ambiguous_reference_type" if isinstance(allowed_types, list) and allowed_types: entry["allowed_type_categories"] = list(allowed_types) else: entry["status"] = "not_reference" evidence.append(entry) for item in attributes: if isinstance(item, dict): append_item(item, "attribute") for item in dimensions: if isinstance(item, dict): append_item(item, "dimension") for item in resources: if isinstance(item, dict): append_item(item, "resource") for section in tabular_sections: if not isinstance(section, dict): continue section_name = str(section.get("name") or "") append_item(section, "tabular_section") for column in section.get("columns") or []: if isinstance(column, dict): append_item(column, "tabular_section_column", table=section_name or None) return evidence def public_metadata_item( record: dict[str, Any], resolved_types: dict[str, dict[str, Any]], *, include_storage: bool = False, owner_kind: str | None = None, extensions_by_guid: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: name = best_text_variant(record.get("likely_name")) public_type = public_type_info(record.get("type"), resolved_types, include_storage=include_storage) if not include_storage: fallback_type = platform_reference_type_fallback(owner_kind, str(name or ""), public_type) if fallback_type: public_type = fallback_type item = { "name": name, "type": public_type, } identity = record.get("identity") if isinstance(record.get("identity"), dict) else {} synonyms = identity.get("synonyms") if isinstance(identity, dict) else None if isinstance(synonyms, dict) and synonyms: item["synonym"] = best_text_variant(next(iter(synonyms.values()))) origin = public_origin_from_storage_routes(record.get("storage_routes"), extensions_by_guid) if origin: item["origin"] = origin if include_storage: item.update( { "identity": record.get("identity"), "path": record.get("path"), "index": record.get("index"), "strings_sample": record.get("strings_sample"), "guids_sample": record.get("guids_sample"), } ) if "storage_routes" in record: item["storage_routes"] = record.get("storage_routes") return item def public_reference_type_counts(*groups: list[dict[str, Any]]) -> dict[str, int]: resolved = 0 unresolved = 0 def inspect_type(type_info: Any) -> None: nonlocal resolved, unresolved if not isinstance(type_info, dict) or type_info.get("kind") != "reference": return presentation = str(type_info.get("presentation") or "").strip() resolved_info = type_info.get("resolved") if isinstance(type_info.get("resolved"), dict) else None if ( "тип ссылки не определен" in presentation or presentation in {"", "Ссылка", "Reference"} or (resolved_info is not None and resolved_info.get("status") != "ok") ): unresolved += 1 else: resolved += 1 for group in groups: for item in group or []: inspect_type((item or {}).get("type")) for column in (item or {}).get("columns") or []: inspect_type((column or {}).get("type")) return {"resolved_reference_types": resolved, "unresolved_reference_types": unresolved} def enrich_semantic_types(semantic: dict[str, Any] | None, resolved_types: dict[str, dict[str, Any]]) -> dict[str, Any] | None: if not isinstance(semantic, dict) or not resolved_types: return semantic enriched = dict(semantic) sections = [] for section in enriched.get("sections") or []: if not isinstance(section, dict): sections.append(section) continue enriched_section = dict(section) records = [] for record in enriched_section.get("records") or []: if not isinstance(record, dict): records.append(record) continue enriched_record = dict(record) enriched_record["type"] = with_resolved_type(enriched_record.get("type"), resolved_types) columns = [] for column in enriched_record.get("columns") or []: if not isinstance(column, dict): columns.append(column) continue enriched_column = dict(column) enriched_column["type"] = with_resolved_type(enriched_column.get("type"), resolved_types) columns.append(enriched_column) if "columns" in enriched_record: enriched_record["columns"] = columns records.append(enriched_record) enriched_section["records"] = records sections.append(enriched_section) enriched["sections"] = sections return enriched def live_extensions_from_sql(base_id: str, *, include_storage: bool = False) -> dict[str, Any] | None: config, config_error = sql_config_for_base(base_id) if not config: return { "schema": "onec_extensions_list.v1", "status": "source_missing", "base_id": base_id, "source": {"kind": "live_metadata", "status": (config_error or {}).get("status", "not_configured")}, "extensions": [], "counts": {"extensions": 0}, "diagnostics": config_error or {}, } try: import pymssql # type: ignore except Exception as exc: return { "schema": "onec_extensions_list.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_metadata", "status": "driver_unavailable"}, "extensions": [], "counts": {"extensions": 0}, "diagnostics": {"message": str(exc)}, } database = config["database"] rows = [] try: with pymssql.connect( server=config["server"], user=config["user"], password=config["password"], database=database, login_timeout=5, timeout=20, ) as conn: with conn.cursor(as_dict=True) as cursor: cursor.execute( """ SELECT [_IDRRef], [_ExtensionOrder], [_ExtName], [_UpdateTime], [_ExtensionUsePurpose], [_ExtensionScope], DATALENGTH([_ExtensionZippedInfo]) AS [_ExtensionZippedInfoBytes], CONVERT(varchar(2), SUBSTRING([_ExtensionZippedInfo], DATALENGTH([_ExtensionZippedInfo]) - 2, 1), 2) AS [_ExtensionActivityByteHex] FROM dbo.[_ExtensionsInfo] ORDER BY [_ExtensionOrder], [_ExtName] """ ) for index, row in enumerate(cursor.fetchall(), start=1): idrref = row.get("_IDRRef") activity_byte_hex = str(row.get("_ExtensionActivityByteHex") or "").strip().upper() # Controlled live experiments in `upo` toggled three # independent extensions through Configurator. In every # observed container, the third byte from the end (not a # fixed absolute offset) was 82 when the UI checkbox was # enabled and 81 when it was disabled. Unknown values are # deliberately not interpreted. if activity_byte_hex == "82": active = True active_inference = "extension_zipped_info_trailing_activity_byte_82" elif activity_byte_hex == "81": active = False active_inference = "extension_zipped_info_trailing_activity_byte_81" else: active = None active_inference = "unresolved_extension_zipped_info_trailing_activity_byte" extension = { "name": jsonable(row.get("_ExtName")), "order": jsonable(row.get("_ExtensionOrder")), "update_time": jsonable(row.get("_UpdateTime")), "guid": dbnames_ext_guid_from_idrref(idrref), "active": active, } if include_storage: extension.update( { "row_index": index, "extension_order": jsonable(row.get("_ExtensionOrder")), "use_purpose": jsonable(row.get("_ExtensionUsePurpose")), "scope": jsonable(row.get("_ExtensionScope")), "dbnames_ext_guid": dbnames_ext_guid_from_idrref(idrref), "dbnames_ext_file": None, "dbnames_ext_file_bytes": None, "extension_zipped_info": {"type": "binary", "bytes": jsonable(row.get("_ExtensionZippedInfoBytes"))}, "activity_byte_hex": activity_byte_hex or None, "active_inference": active_inference, } ) rows.append(extension) except Exception as exc: return { "schema": "onec_extensions_list.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_metadata", "status": "error"}, "extensions": [], "counts": {"extensions": 0}, "diagnostics": {"message": str(exc)}, } return { "schema": "onec_extensions_list.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": database, "table": "_ExtensionsInfo"} if include_storage else {"kind": "live_metadata"}, "extensions": rows, "counts": {"extensions": len(rows), "extension_row_count": len(rows)}, } def extension_guid_from_dbnames_source(source: Any) -> str | None: match = re.fullmatch(r"DBNames-Ext-([0-9a-fA-F-]{36})", str(source or "").strip()) return match.group(1).lower() if match else None def registered_extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: """Return registered extensions only for internal activity gating. This is deliberately not a routing catalogue: an inactive or unresolved extension is not part of the configuration composition exposed to agents. """ result = live_extensions_from_sql(base_id, include_storage=False) or {} mapping: dict[str, dict[str, Any]] = {} for extension in result.get("extensions") or []: if not isinstance(extension, dict): continue guid = str(extension.get("guid") or "").strip().lower() if guid: mapping[guid] = { "name": extension.get("name"), "guid": guid, "order": extension.get("order"), "active": extension.get("active"), } return mapping def extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: """Return only SQL-proven active extensions for all normal adapter work.""" return { guid: extension for guid, extension in registered_extension_map_by_guid(base_id).items() if extension.get("active") is True } def public_origin_from_storage_routes(routes: Any, extensions_by_guid: dict[str, dict[str, Any]] | None = None) -> dict[str, Any] | None: if not isinstance(routes, list) or not routes: return None extensions_by_guid = extensions_by_guid or {} extension_guids: list[str] = [] has_base_route = False for route in routes: if not isinstance(route, dict): continue source = str(route.get("source") or "") extension_guid = extension_guid_from_dbnames_source(source) if extension_guid and extension_guid not in extension_guids: extension_guids.append(extension_guid) elif source == "DBNames": has_base_route = True if extension_guids: extensions = [extensions_by_guid.get(guid) or {"guid": guid, "name": None, "active": None} for guid in extension_guids] return { "source": "extension", "presentation": "Расширение", "extension": extensions[0] if len(extensions) == 1 else None, **({"extensions": extensions} if len(extensions) > 1 else {}), "status": "ok" if all(item.get("name") for item in extensions) else "extension_unresolved", **( { "diagnostics": { "message": "Определение связано с DBNames расширения, но имя расширения не найдено в _ExtensionsInfo.", } } if any(not item.get("name") for item in extensions) else {} ), } if has_base_route: return {"source": "configuration", "presentation": "Конфигурация", "extension": None, "status": "ok"} return None def list_extensions(payload: dict[str, Any] | None = None) -> dict[str, Any]: payload = payload or {} base_id_or_error = require_base_id(payload, "extensions.list") if isinstance(base_id_or_error, dict): return base_id_or_error include_storage, include_storage_error = strict_include_storage(payload, "extensions.list") if include_storage_error: return include_storage_error if "limit" in payload: parsed_limit, limit_error = parse_int_argument(payload, "limit", method="extensions.list", default=200, minimum=1) if limit_error: return limit_error else: parsed_limit = None if "offset" in payload: parsed_offset, offset_error = parse_int_argument(payload, "offset", method="extensions.list", default=0, minimum=0) if offset_error: return offset_error else: parsed_offset = 0 timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="extensions.list", default=30, minimum=1) if timeout_error: return timeout_error base_id = base_id_or_error raw_extensions_result = live_extensions_from_sql(base_id, include_storage=bool(include_storage)) if not raw_extensions_result: return live_source_unavailable("extensions.list", base_id, None) # The public configuration composition is only the active extension set. # Registered but inactive SQL rows are intentionally not exposed as objects # the adapter can read, search, route, or write. extensions = [ extension for extension in (raw_extensions_result.get("extensions") or []) if isinstance(extension, dict) and extension.get("active") is True ] for extension in extensions: if not isinstance(extension, dict): continue extension["load_order"] = extension.get("order") extension["depends_on"] = extension.get("depends_on") or [] if "is_forbid_conflict" not in extension: extension["is_forbid_conflict"] = False if extension.get("name"): extension["presentation"] = str(extension.get("name") or extension.get("guid") or "") extensions = sorted( extensions, key=lambda item: item.get("load_order") if isinstance(item, dict) and item.get("load_order") is not None else 10**9, ) total_extensions = len(extensions) page = extensions[parsed_offset:] if parsed_limit is None else extensions[parsed_offset : parsed_offset + parsed_limit] return { **raw_extensions_result, "extensions": page, "counts": {"extensions": len(page), "extension_row_count": total_extensions}, "query": {"limit": parsed_limit, "offset": parsed_offset, "include_storage": bool(include_storage)}, } def strip_sql_comments(query: str) -> str: query = re.sub(r"/\*.*?\*/", " ", query, flags=re.DOTALL) query = re.sub(r"--[^\r\n]*", " ", query) return query def sql_without_literals(query: str) -> str: """Return SQL with quoted literals removed for conservative token checks.""" return re.sub(r"N?'(?:''|[^'])*'", "''", query, flags=re.IGNORECASE) def mask_sensitive_query_rows(rows: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: masked_fields = sorted({str(key) for row in rows for key in row if SENSITIVE_RESULT_FIELD_RE.search(str(key))}) if not masked_fields: return rows, [] masked = [] for row in rows: masked.append({key: ("***" if str(key) in masked_fields and value is not None else value) for key, value in row.items()}) return masked, masked_fields def validate_query(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "query.validate") if isinstance(base_id_or_error, dict): return base_id_or_error if "query" not in payload: return invalid_argument("query.validate", "query", "query is required and must be a non-empty JSON string.") if not isinstance(payload.get("query"), str): return invalid_argument("query.validate", "query", "query must be a JSON string.") if not str(payload.get("query") or "").strip(): return invalid_argument("query.validate", "query", "query is required and must be a non-empty JSON string.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="query.validate", default=30, minimum=1) if timeout_error: return timeout_error query = str(payload.get("query") or "") cleaned = strip_sql_comments(query).strip() checked_sql = sql_without_literals(cleaned) forbidden = re.compile( r"\b(insert|update|delete|drop|alter|truncate|merge|exec|execute|create|grant|revoke|deny|backup|restore|dbcc|use|set)\b", re.IGNORECASE, ) unsafe_read_features = re.compile( r"\b(openrowset|opendatasource|openquery|next\s+value\s+for|xp_[a-z0-9_]+|sp_[a-z0-9_]+)\b", re.IGNORECASE, ) starts_readonly = bool(re.match(r"^\s*(select|with)\b", cleaned, flags=re.IGNORECASE)) select_into = bool(re.search(r"\bselect\b.+\binto\b", checked_sql, flags=re.IGNORECASE | re.DOTALL)) statements = [part.strip() for part in cleaned.split(";") if part.strip()] ok = bool(cleaned) and starts_readonly and not forbidden.search(checked_sql) and not unsafe_read_features.search(checked_sql) and not select_into and len(statements) <= 1 reason = "ok" if not cleaned: reason = "empty" elif not starts_readonly: reason = "only_select_or_with_allowed" elif forbidden.search(checked_sql): reason = "forbidden_keyword" elif unsafe_read_features.search(checked_sql): reason = "unsafe_read_feature" elif select_into: reason = "select_into_forbidden" elif len(statements) > 1: reason = "multiple_statements_forbidden" return { "schema": "onec_query_validation.v1", "status": "ok", "valid": ok, "read_only": ok, "reason": reason, } def run_readonly_query(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "query.run") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if "query" not in payload: return invalid_argument("query.run", "query", "query is required and must be a non-empty JSON string.") if not isinstance(payload.get("query"), str): return invalid_argument("query.run", "query", "query must be a JSON string.") if not str(payload.get("query") or "").strip(): return invalid_argument("query.run", "query", "query is required and must be a non-empty JSON string.") limit, limit_error = parse_int_argument(payload, "limit", method="query.run", default=100, minimum=1, maximum=1000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="query.run", default=30, minimum=1) if timeout_error: return timeout_error validation = validate_query(payload) if validation.get("status") == "invalid_argument": validation = dict(validation) validation["method"] = "query.run" return validation diagnostic_error = require_diagnostic_mode(payload, "query.run") if diagnostic_error: return diagnostic_error if not validation.get("valid"): return { "schema": "onec_query_result.v1", "status": "rejected", "base_id": base_id, "validation": validation, "rows": [], "counts": {"rows": 0}, } config, config_error = sql_config_for_base(base_id) if not config: return live_source_unavailable("query.run", base_id, config_error) try: import pymssql # type: ignore except Exception as exc: return { "schema": "onec_query_result.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "status": "driver_unavailable"}, "diagnostics": {"message": str(exc)}, "rows": [], "counts": {"rows": 0}, } params = payload.get("params") or {} started = time.time() try: with pymssql.connect( server=config["server"], user=config["user"], password=config["password"], database=config["database"], login_timeout=min(timeout_seconds, 15), timeout=timeout_seconds, ) as conn: with conn.cursor(as_dict=True) as cursor: cursor.execute(str(payload.get("query") or ""), params) rows = cursor.fetchmany(limit + 1) truncated = len(rows) > limit rows = rows[:limit] columns = [column[0] for column in (cursor.description or [])] except Exception as exc: return { "schema": "onec_query_result.v1", "status": "error", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"]}, "validation": validation, "diagnostics": {"message": str(exc)}, "rows": [], "counts": {"rows": 0}, } public_rows = [{key: jsonable(value) for key, value in row.items()} for row in rows] public_rows, masked_fields = mask_sensitive_query_rows(public_rows) return { "schema": "onec_query_result.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"]}, "validation": validation, "columns": columns, "rows": public_rows, "counts": {"rows": len(rows), "limit": limit, "truncated": truncated}, "masking": {"enabled": True, "masked_fields": masked_fields}, "duration_ms": int((time.time() - started) * 1000), } DATA_TABLE_PREFIXES = { "Catalog": "_Reference", "Document": "_Document", "InformationRegister": "_InfoRg", "AccumulationRegister": "_AccumRg", "AccountingRegister": "_AccRg", "CalculationRegister": "_CalcRg", "BusinessProcess": "_BPr", "Task": "_Task", "ChartOfAccounts": "_Acc", "ChartOfCalculationTypes": "_CKinds", "ChartOfCharacteristicTypes": "_Chrc", "ExchangePlan": "_Node", "Sequence": "_Sequence", "Constant": "_Const", "Enum": "_Enum", } DATA_SYSTEM_COLUMNS = { "_IDRRef": "ref", "_Version": "version", "_Marked": "marked_for_deletion", "_Code": "code", "_Description": "description", "_Date_Time": "date", "_Number": "number", "_Posted": "posted", "_Period": "period", "_Active": "active", "_LineNo": "line_no", "_RecorderTRef": "recorder_type", "_RecorderRRef": "recorder_ref", "_PredefinedID": "predefined_ref", "_Folder": "is_folder", "_ParentIDRRef": "parent_ref", "_OwnerIDRRef": "owner_ref", "_EnumOrder": "enum_order", "_DescriptionHash": "description_hash", "_RecordKey": "record_key", "_Completed": "completed", "_Started": "started", "_HeadTaskRRef": "head_task_ref", "_BusinessProcess_TYPE": "business_process", "_BusinessProcess_RTRef": "business_process", "_BusinessProcess_RRRef": "business_process", "_Point_TYPE": "route_point", "_Point_RTRef": "route_point", "_Point_RRRef": "route_point", "_Name": "name", "_Executed": "executed", } def data_sql_rows(base_id: str, query: str, params: Any = None, *, timeout_seconds: int = 30) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: config, config_error = sql_config_for_base(base_id) if not config: return [], live_source_unavailable("data.read", base_id, config_error) try: import pymssql # type: ignore with pymssql.connect( server=config["server"], user=config["user"], password=config["password"], database=config["database"], login_timeout=min(timeout_seconds, 15), timeout=timeout_seconds, ) as conn: with conn.cursor(as_dict=True) as cursor: cursor.execute(query, params or ()) return [dict(row) for row in cursor.fetchall()], None except Exception as exc: return [], { "schema": "onec_data_error.v1", "status": "error", "base_id": base_id, "error": "data_sql_error", "diagnostics": {"message": str(exc)}, } def data_fallback_field_routes(base_id: str, physical_names: list[str], *, timeout_seconds: int = 60) -> dict[str, dict[str, Any]]: """Resolve remaining _Fld/_Dim/_Resource columns through DBNames and descriptor identities.""" requested: dict[tuple[str, int], list[str]] = {} role_by_prefix = {"Fld": "attributes", "Dim": "dimensions", "Resource": "resources"} for physical in physical_names: match = re.match(r"^_(Fld|Dim|Resource)(\d+)(?:$|[A-Za-z_])", physical) if match: requested.setdefault((match.group(1), int(match.group(2))), []).append(physical) if not requested: return {} records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: return {} selected: dict[tuple[str, int], Any] = {} for record in records or []: key = (str(getattr(record, "storage_role", "") or ""), int(getattr(record, "sql_number", 0) or 0)) if key not in requested: continue current = selected.get(key) if current is None or str(getattr(current, "source", "") or "") != "DBNames": selected[key] = record guids = sorted({str(getattr(record, "guid", "") or "").lower() for record in selected.values() if is_guid_text(str(getattr(record, "guid", "") or ""))}) payloads, _, payload_error = read_storage_files_bytes(base_id, "Config", guids, timeout_seconds=timeout_seconds) if payload_error: payloads = {} root_rows, _ = live_base_root_metadata_index(base_id, table="Config", timeout_seconds=timeout_seconds) common_attribute_guids = {str(row.get("guid") or "").lower() for row in root_rows if row.get("kind") == "CommonAttribute"} result: dict[str, dict[str, Any]] = {} for key, record in selected.items(): guid = str(getattr(record, "guid", "") or "").lower() identity = config_identity_from_bytes((payloads or {}).get(guid) or b"") or {} name = str(identity.get("name") or "").strip() if not name: continue section = "common_attributes" if guid in common_attribute_guids else role_by_prefix.get(key[0], "attributes") for physical in requested.get(key) or []: result[physical] = {"name": name, "section": section, "guid": guid} return result def data_chart_populated_variant_table( base_id: str, table: str, base_columns: list[dict[str, Any]], *, timeout_seconds: int, ) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]: """Select the evidenced X1 storage variant of a chart when its base is empty. Some 1C installations retain a zero-row ``_Chrc`` table next to the populated ``_ChrcX1`` variant. This is not a name-based fallback: the variant is accepted only after SQL proves that it has chart identity/value columns and the base has no rows. The selected route remains visible to callers so a diagnostic never looks like an ordinary base-table read. """ required = {"_IDRRef", "_Description", "_Type"} base_names = {str(row.get("name") or "") for row in base_columns} if not required.issubset(base_names): return table, base_columns, None variant = f"{table}X1" counts, error = data_sql_rows( base_id, "SELECT b.name, SUM(p.rows) AS row_count " "FROM sys.tables b JOIN sys.partitions p ON p.object_id=b.object_id AND p.index_id IN (0,1) " "WHERE b.name IN (%s, %s) GROUP BY b.name", (table, variant), timeout_seconds=timeout_seconds, ) if error: return table, base_columns, None count_by_name = {str(row.get("name") or ""): int(row.get("row_count") or 0) for row in counts} if int(count_by_name.get(table) or 0) != 0 or int(count_by_name.get(variant) or 0) <= 0: return table, base_columns, None variant_columns, error = data_sql_rows( base_id, "SELECT c.name, t.name AS type_name, c.max_length, c.precision, c.scale, c.is_nullable " "FROM sys.columns c JOIN sys.types t ON t.user_type_id=c.user_type_id " "JOIN sys.tables b ON b.object_id=c.object_id WHERE b.name=%s ORDER BY c.column_id", (variant,), timeout_seconds=timeout_seconds, ) if error or not required.issubset({str(row.get("name") or "") for row in variant_columns}): return table, base_columns, None return variant, variant_columns, { "kind": "populated_x1_variant", "base_table": table, "base_row_count": int(count_by_name.get(table) or 0), "variant_row_count": int(count_by_name.get(variant) or 0), "required_columns": sorted(required), } def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: method = "data.schema" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error normalized = normalize_object_selector_aliases(payload, method) if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": return normalized if not has_object_selector(normalized): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) requested_kind = canonical_kind(str(normalized.get("kind") or "")) requested_name = str(normalized.get("name") or normalized.get("guid") or "") # Fast path for the high-volume chart reader: the identity cache is enough # to get its DBNames route and avoids the broad effective-metadata scan. cached_chart = metadata_cache_lookup_row(base_id_or_error, requested_kind, requested_name) if requested_kind == "ChartOfCharacteristicTypes" else None if cached_chart: object_card = {key: cached_chart.get(key) for key in ("guid", "kind", "kind_ru", "public_kind", "name", "synonym", "source")} object_card["status"] = "ok" object_card["ref"] = f"ChartOfCharacteristicTypes.{object_card.get('name')}" else: card_result = get_object( normalized.get("kind"), requested_name, base_id=base_id_or_error, include_storage=True, limit=20, timeout_seconds=int(normalized.get("timeout_seconds") or 60), ) if card_result.get("status") != "ok": card_result["method"] = method return card_result object_card = card_result.get("object") if isinstance(card_result.get("object"), dict) else card_result kind = str(object_card.get("kind") or "") storage = object_card.get("storage") if isinstance(object_card.get("storage"), dict) else {} dbname = next((row for row in storage.get("dbnames") or [] if isinstance(row, dict) and row.get("sql_number") is not None), None) if kind == "ChartOfCharacteristicTypes" and not dbname: records, _records_error = live_dbnames_records(base_id_or_error, timeout_seconds=min(int(normalized.get("timeout_seconds") or 60), 15)) dbname_record = next((record for record in records or [] if str(getattr(record, "guid", "") or "").lower() == str(object_card.get("guid") or "").lower() and str(getattr(record, "storage_role", "") or "") == "Chrc"), None) if dbname_record: dbname = {"sql_number": int(getattr(dbname_record, "sql_number", 0) or 0)} if not dbname and kind: live_rows = list_objects( kind, base_id=base_id_or_error, limit=100, offset=0, include_storage=True, refresh_cache=True, name_filter=str(object_card.get("name") or normalized.get("name") or ""), ) wanted_guid = str(object_card.get("guid") or "").casefold() live_card = next( ( row for row in live_rows.get("objects") or [] if isinstance(row, dict) and ( (wanted_guid and str(row.get("guid") or "").casefold() == wanted_guid) or normalize(str(row.get("name") or "")) == normalize(str(object_card.get("name") or "")) ) ), None, ) if live_card: object_card = {**object_card, **live_card} storage = object_card.get("storage") if isinstance(object_card.get("storage"), dict) else {} dbname = next((row for row in storage.get("dbnames") or [] if isinstance(row, dict) and row.get("sql_number") is not None), None) prefix = DATA_TABLE_PREFIXES.get(kind) if not prefix or not dbname: return { "schema": "onec_data_schema.v1", "status": "unsupported_kind", "base_id": base_id_or_error, "object": public_metadata_row(object_card), "diagnostics": {"message": "This metadata kind has no universal SQL data-table route yet."}, } table = f"{prefix}{int(dbname['sql_number'])}" rows, error = data_sql_rows( base_id_or_error, "SELECT c.name, t.name AS type_name, c.max_length, c.precision, c.scale, c.is_nullable " "FROM sys.columns c JOIN sys.types t ON t.user_type_id=c.user_type_id " "JOIN sys.tables b ON b.object_id=c.object_id WHERE b.name=%s ORDER BY c.column_id", (table,), timeout_seconds=int(normalized.get("timeout_seconds") or 60), ) if error: error["method"] = method return error if not rows: return {"schema": "onec_data_schema.v1", "status": "source_missing", "base_id": base_id_or_error, "object": public_metadata_row(object_card), "diagnostics": {"message": "Physical data table was not found."}} table_variant: dict[str, Any] | None = None if kind == "ChartOfCharacteristicTypes": table, rows, table_variant = data_chart_populated_variant_table( base_id_or_error, table, rows, timeout_seconds=int(normalized.get("timeout_seconds") or 60), ) # A chart's record-level Type pattern is decoded from its rows. Calling # the generic member resolver here is both unhelpful (standard chart # fields are not ordinary attributes) and can recursively resolve every # generated type before LIMIT is applied. attributes_result = ( {"dimensions": [], "resources": [], "attributes": []} if kind == "ChartOfCharacteristicTypes" else metadata_object_attributes({**normalized, "base_id": base_id_or_error, "include_storage": True, "only": "all"}) ) logical_by_physical: dict[str, dict[str, Any]] = {} for section in ("dimensions", "resources", "attributes"): for item in attributes_result.get(section) or []: if not isinstance(item, dict): continue for route in item.get("storage_routes") or []: physical = str((route or {}).get("physical_name_candidate") or "") if physical: logical_by_physical[physical] = {"name": item.get("name"), "section": section, "type": item.get("type")} physical_names = [str(row.get("name") or "") for row in rows] unresolved_physical = [ physical for physical in physical_names if not DATA_SYSTEM_COLUMNS.get(physical) and not any(re.match(rf"^{re.escape(candidate)}(?:$|[A-Za-z_])", physical) for candidate in logical_by_physical) ] fallback_by_physical = ( data_fallback_field_routes( base_id_or_error, unresolved_physical, timeout_seconds=min(int(normalized.get("timeout_seconds") or 60), 20), ) if kind != "ChartOfCharacteristicTypes" or truthy(normalized.get("include_chart_field_semantics")) else {} ) constant_value_type: dict[str, Any] | None = None if kind == "Constant": special = metadata_object_special_details( { "base_id": base_id_or_error, "kind": kind, "name": object_card.get("name"), "guid": object_card.get("guid"), "timeout_seconds": int(normalized.get("timeout_seconds") or 60), } ) details = special.get("details") if isinstance(special.get("details"), dict) else {} value_type = details.get("value_type") if isinstance(value_type, dict) and value_type.get("kind"): constant_value_type = value_type fields = [] constant_value_prefix = f"_Fld{int(dbname['sql_number']) + 1}" if kind == "Constant" else None for row in rows: physical = str(row.get("name") or "") logical = DATA_SYSTEM_COLUMNS.get(physical) descriptor = logical_by_physical.get(physical) if constant_value_prefix and re.match(rf"^{re.escape(constant_value_prefix)}(?:$|[A-Za-z_])", physical): logical = "value" descriptor = {"name": "value", "section": "value", "type": constant_value_type} if descriptor: logical = str(descriptor.get("name") or physical) if not logical: base = next( ( candidate for candidate in logical_by_physical if re.match(rf"^{re.escape(candidate)}(?:$|[A-Za-z_])", physical) ), None, ) if base: descriptor = logical_by_physical[base] logical = str(descriptor.get("name") or base) if not logical and physical in fallback_by_physical: descriptor = fallback_by_physical[physical] logical = str(descriptor.get("name") or physical) fields.append( { "name": logical or physical, "physical_name": physical, "section": (descriptor or {}).get("section") or "system", "type": (descriptor or {}).get("type") or {"kind": str(row.get("type_name") or "sql")}, "storage": {key: jsonable(row.get(key)) for key in ("type_name", "max_length", "precision", "scale", "is_nullable")}, } ) result = { "schema": "onec_data_schema.v1", "status": "ok", "base_id": base_id_or_error, "object": public_metadata_row(object_card), "table": {"name": table, "row_kind": kind, **({"variant": table_variant} if table_variant else {})}, "fields": fields, "counts": {"fields": len(fields), "physical_columns": len(physical_names)}, } if kind == "ChartOfCharacteristicTypes" and truthy(normalized.get("include_chart_semantics")): chart_details = metadata_object_special_details( { "base_id": base_id_or_error, "kind": kind, "name": object_card.get("name"), "guid": object_card.get("guid"), "timeout_seconds": int(normalized.get("timeout_seconds") or 60), } ) if chart_details.get("status") in {"ok", "partial"}: result["chart_semantics"] = chart_details.get("details") return result def data_schema_cache_key(payload: dict[str, Any]) -> str: """Use one cache entry for equivalent public selectors of the same object.""" base_id = str(payload.get("base_id") or "").strip().casefold() public_ref = str(payload.get("object_ref") or payload.get("ref") or "").strip() if public_ref and not re.fullmatch(r"[0-9a-fA-F-]{32,36}", public_ref): head, separator, tail = public_ref.partition(".") canonical_ref = f"{canonical_kind(head) or head}.{tail}" if separator else public_ref identity = {"ref": canonical_ref.casefold()} else: kind = canonical_kind(str(payload.get("kind") or payload.get("object_type") or "")) name = str(payload.get("name") or payload.get("object_name") or "").strip() guid = str(payload.get("guid") or payload.get("object_guid") or "").strip().casefold() if kind and name: identity = {"ref": f"{kind}.{name}".casefold()} elif guid: identity = {"guid": guid} else: identity = {"selector": "unresolved"} return json.dumps( { "base_id": base_id, **identity, # Semantic chart decoding changes the returned contract and must # not reuse a compact-schema cache entry that deliberately omitted # those details. "include_chart_semantics": truthy(payload.get("include_chart_semantics")), "include_chart_field_semantics": truthy(payload.get("include_chart_field_semantics")), }, ensure_ascii=False, sort_keys=True, ) def data_object_schema(payload: dict[str, Any]) -> dict[str, Any]: cache_key = data_schema_cache_key(payload) if not truthy(payload.get("refresh_cache")): with DATA_SCHEMA_CACHE_LOCK: cached = DATA_SCHEMA_CACHE.get(cache_key) if cached and time.time() - float(cached.get("cached_at") or 0) <= DATA_SCHEMA_CACHE_TTL_SECONDS: result = copy.deepcopy(cached.get("result") or {}) result["cache"] = {"status": "hit", "ttl_seconds": DATA_SCHEMA_CACHE_TTL_SECONDS} return result result = data_object_schema_uncached(payload) if result.get("status") == "ok": with DATA_SCHEMA_CACHE_LOCK: DATA_SCHEMA_CACHE[cache_key] = {"cached_at": time.time(), "result": copy.deepcopy(result)} result["cache"] = {"status": "miss", "ttl_seconds": DATA_SCHEMA_CACHE_TTL_SECONDS} return result def onec_data_value(value: Any, *, logical_name: str = "") -> Any: if isinstance(value, Decimal): return int(value) if value == value.to_integral_value() else float(value) if isinstance(value, datetime): if value.year >= 4000: try: value = value.replace(year=value.year - 2000) except ValueError: pass return value.isoformat() if isinstance(value, (bytes, bytearray)): raw = bytes(value) if len(raw) == 1 and logical_name in {"marked_for_deletion", "posted", "active", "completed", "started", "executed"}: return raw != b"\x00" if len(raw) == 16: return {"type": "reference", "hex": raw.hex().upper(), "guid_variants": access_identifier_guid_variants(f"00000000:{raw.hex()}")} return {"type": "binary", "bytes": len(raw), "hex": raw.hex() if len(raw) <= 64 else None} return value def onec_data_filter_value(value: Any, physical_name: str) -> Any: if isinstance(value, str) and (physical_name.endswith("RRef") or physical_name == "_IDRRef"): compact = value.replace("-", "").strip() if re.fullmatch(r"[0-9a-fA-F]{32}", compact): return bytes.fromhex(compact) if isinstance(value, bool): return b"\x01" if value else b"\x00" return value def data_record_ref(payload: dict[str, Any]) -> str: explicit = str(payload.get("record_ref") or "").replace("-", "").strip() if explicit: return explicit legacy = str(payload.get("ref") or "").replace("-", "").strip() return legacy if re.fullmatch(r"[0-9a-fA-F]{32}", legacy) else "" def data_write_version_bytes(value: Any) -> bytes | None: """Parse the opaque version token returned by ``data.get``. It is deliberately not interpreted as a number: different 1C platform versions use different binary version layouts. It is only used as a byte-for-byte compare-and-set guard in the SQL WHERE clause. """ if isinstance(value, (bytes, bytearray)): return bytes(value) or None if isinstance(value, dict): value = value.get("hex") if not isinstance(value, str): return None compact = value.replace("-", "").replace(" ", "").strip() if not compact or not re.fullmatch(r"[0-9a-fA-F]+", compact) or len(compact) % 2: return None return bytes.fromhex(compact) def data_write_value(value: Any, physical_name: str) -> Any: """Convert only public scalar/reference values to DB-Library values.""" if isinstance(value, dict): if set(value) <= {"hex", "type"} and value.get("hex") is not None: value = value.get("hex") else: raise ValueError("Only scalar values or a reference/binary {hex} value are writable.") if isinstance(value, (list, tuple)): raise ValueError("Arrays are not writable through direct SQL.") return onec_data_filter_value(value, physical_name) def data_write(payload: dict[str, Any]) -> dict[str, Any]: """Safely update one *existing* catalog row in an isolated SQL data plane. This is intentionally narrower than a generic SQL executor. Raw DML cannot safely create/post 1C objects because it bypasses object modules, transactions, numbering, register movements and platform locks. The runtime bridge is the authoritative future route for those operations. """ method = "data.write" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error mode = str(payload.get("execution_mode") or "plan").strip().casefold() if mode not in {"plan", "apply", "apply_and_verify"}: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=["plan", "apply", "apply_and_verify"]) record_ref = data_record_ref(payload) if not re.fullmatch(r"[0-9a-fA-F]{32}", record_ref): return invalid_argument(method, "record_ref", "record_ref must be a 32-character hexadecimal 1C reference id.") values = payload.get("values") if not isinstance(values, dict) or not values: return invalid_argument(method, "values", "values must be a non-empty object of logical scalar fields.") timeout, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": schema["method"] = method return schema obj = schema.get("object") if isinstance(schema.get("object"), dict) else {} kind = str(obj.get("kind") or "") table = str((schema.get("table") or {}).get("name") or "") # The first direct-SQL data capability is deliberately catalog-only. It # makes the support boundary explicit rather than silently corrupting a # document/register by bypassing its 1C posting or movement logic. if kind != "Catalog": return { "schema": "onec_data_write.v1", "method": method, "status": "runtime_bridge_required", "base_id": base_id, "object": obj, "reason": "Direct SQL updates are currently supported only for existing catalog rows. Documents, registers, constants and creates must use the 1C runtime bridge so platform business logic runs.", } if not re.fullmatch(r"_[A-Za-z0-9_]+", table): return {"schema": "onec_data_write.v1", "method": method, "status": "route_unavailable", "base_id": base_id, "diagnostics": {"message": "Resolved catalog table is unsafe."}} by_logical: dict[str, list[dict[str, Any]]] = {} for field in schema.get("fields") or []: if isinstance(field, dict): by_logical.setdefault(str(field.get("name") or ""), []).append(field) forbidden = {"", "ref", "version", "date", "number", "posted", "period", "recorder_ref", "recorder_type", "line_no", "predefined_ref", "is_folder", "parent_ref", "owner_ref"} assignments: list[tuple[str, str, Any]] = [] for logical, value in values.items(): if not isinstance(logical, str) or logical in forbidden: return invalid_argument(method, "values", f"Field `{logical}` is an identity/platform-managed field and cannot be directly written.") candidates = by_logical.get(logical) or [] if len(candidates) != 1: return invalid_argument(method, "values", f"Field `{logical}` is unknown or composite and cannot be directly written.") physical = str(candidates[0].get("physical_name") or "") # Do not permit 1C type discriminators or storage-system columns. if not re.fullmatch(r"_(?:Fld\d+(?:_[A-Za-z0-9]+)?|Code|Description|Marked)", physical, flags=re.IGNORECASE): return invalid_argument(method, "values", f"Field `{logical}` has an unsupported physical storage role.") try: assignments.append((logical, physical, data_write_value(value, physical))) except ValueError as exc: return invalid_argument(method, "values", f"Field `{logical}`: {exc}") expected_version = data_write_version_bytes(payload.get("expected_version")) plan = { "schema": "onec_data_write.v1", "method": method, "status": "planned", "base_id": base_id, "object": obj, "record_ref": record_ref.upper(), "operation": "update_existing_catalog_row", "values": {name: value for name, _, value in assignments}, "guards": {"record_ref": "required", "expected_version": "required_for_apply", "allow_sql_data_write": "required_for_apply", "readback": "apply_and_verify"}, "limitations": ["No INSERT/DELETE/posting/register movement is performed by direct SQL.", "Direct SQL bypasses 1C object-module business logic; use the runtime bridge when that logic is required."], } if mode == "plan": return plan allowed, allowed_error = strict_bool_argument(payload, "allow_sql_data_write", method=method, default=False) if allowed_error: return allowed_error if not allowed: return invalid_argument(method, "allow_sql_data_write", "Direct SQL data write is opt-in; review the plan and pass allow_sql_data_write=true.") if not expected_version: return invalid_argument(method, "expected_version", "Apply requires the opaque version token returned by data.get.") conn, config, error = connect_live_sql(base_id, method, timeout_seconds=int(timeout or 30)) if error: return error select_fields = ", ".join(f"[{physical}] AS [{logical}]" for logical, physical, _ in assignments) set_sql = ", ".join(f"[{physical}]=%s" for _, physical, _ in assignments) ref_bytes = bytes.fromhex(record_ref) before: dict[str, Any] | None = None after: dict[str, Any] | None = None try: with conn: with conn.cursor(as_dict=True) as cursor: cursor.execute("SET LOCK_TIMEOUT %s", (int(timeout or 30) * 1000,)) cursor.execute(f"SELECT [ _IDRRef ]".replace("[ _IDRRef ]", "[_IDRRef]") + f", [_Version], {select_fields} FROM dbo.[{table}] WITH (UPDLOCK, ROWLOCK) WHERE [_IDRRef]=%s", (ref_bytes,)) before = cursor.fetchone() if not before: return {**plan, "status": "not_found"} actual_version = bytes(before.get("_Version") or b"") if actual_version != expected_version: return {**plan, "status": "conflict", "error": "expected_version_mismatch", "actual_version": actual_version.hex().upper()} cursor.execute(f"UPDATE dbo.[{table}] SET {set_sql} WHERE [_IDRRef]=%s AND [_Version]=%s", tuple([*(value for _, _, value in assignments), ref_bytes, expected_version])) if cursor.rowcount != 1: return {**plan, "status": "conflict", "error": "compare_and_set_failed"} cursor.execute(f"SELECT [_Version], {select_fields} FROM dbo.[{table}] WHERE [_IDRRef]=%s", (ref_bytes,)) after = cursor.fetchone() except Exception as exc: return {**plan, "status": "error", "error": "data_sql_write_error", "diagnostics": {"message": str(exc)}} before_public = decode_data_rows([before or {}], {"_Version": "version", **{logical: logical for logical, _, _ in assignments}})[0] after_public = decode_data_rows([after or {}], {"_Version": "version", **{logical: logical for logical, _, _ in assignments}})[0] result = {**plan, "status": "applied", "before": before_public, "after": after_public, "verification": {"status": "verified" if mode == "apply_and_verify" else "written", "rows_affected": 1}} return result def data_schema_selector_payload(payload: dict[str, Any]) -> dict[str, Any]: selector = dict(payload) object_ref = selector.pop("object_ref", None) selector.pop("record_ref", None) selector.pop("recorder_ref", None) if object_ref not in {None, ""}: selector["ref"] = object_ref elif re.fullmatch(r"[0-9a-fA-F]{32}", str(payload.get("ref") or "").replace("-", "").strip()): selector.pop("ref", None) return selector def enum_value_public_map(base_id: str, selector: dict[str, Any], *, timeout_seconds: int) -> dict[str, dict[str, Any]]: properties = metadata_object_properties({**selector, "base_id": base_id, "timeout_seconds": timeout_seconds}) semantic = properties.get("properties") if isinstance(properties.get("properties"), dict) else {} result: dict[str, dict[str, Any]] = {} for section in semantic.get("sections") or []: if not isinstance(section, dict) or section.get("category") != "EnumValue": continue for record in section.get("records") or []: identity = record.get("identity") if isinstance(record, dict) and isinstance(record.get("identity"), dict) else {} guid = str(identity.get("guid") or "").lower() name = str(identity.get("name") or record.get("likely_name") or "") synonyms = identity.get("synonyms") if isinstance(identity.get("synonyms"), dict) else {} if guid and name: result[guid] = { "name": name, "synonym": next(iter(synonyms.values()), None), "value_ref": f"Enum.{(properties.get('object') or {}).get('name')}.EnumValue.{name}", } return result def enrich_enum_data_rows(rows: list[dict[str, Any]], values: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: for row in rows: reference = row.get("ref") if isinstance(row, dict) and isinstance(row.get("ref"), dict) else {} identity = next( ( values.get(str(candidate or "").lower()) for candidate in reference.get("guid_variants") or [] if values.get(str(candidate or "").lower()) ), None, ) if identity: row.update({key: value for key, value in identity.items() if value is not None}) return rows def additional_attributes_find(payload: dict[str, Any]) -> dict[str, Any]: """Find a property definition without exposing or guessing storage columns. The generic data reader remains the source of rows. This facade exists so a missing property is distinguishable from an empty/misrouted chart and so callers do not have to manufacture SQL predicates. """ method = "additional_attributes.find" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error chart_ref = str(payload.get("chart_ref") or payload.get("chart") or "ChartOfCharacteristicTypes.ДополнительныеРеквизитыИСведения").strip() query = str(payload.get("query") or payload.get("description") or payload.get("name") or payload.get("identifier_for_formula") or "").strip() limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error read_payload: dict[str, Any] = { "base_id": base_id_or_error, "ref": chart_ref, "limit": int(limit or 50), # Resolve candidates first. The record Type pattern is deliberately # excluded here: decoding it for every fuzzy match must not prevent a # caller from learning whether the property exists at all. "fields": ["ref", "description", "marked_for_deletion"], "include_deleted": payload.get("include_deleted") is True, "include_route_diagnostics": True, } if query: read_payload["search"] = query result = data_read(read_payload, method=method) if result.get("status") != "ok": return result rows = result.get("rows") if isinstance(result.get("rows"), list) else [] source = result.get("route_diagnostics") if isinstance(result.get("route_diagnostics"), dict) else {} owner_object_ref = str(payload.get("owner_object_ref") or payload.get("owner_ref") or "").strip() # For a narrow owner-scoped result, attach confirmed value-storage evidence # directly. This keeps an agent from guessing a value type or joining a # similarly named property from a different property set. if owner_object_ref and len(rows) <= 5: for row in rows: reference = row.get("ref") if isinstance(row, dict) else None property_ref = str(reference.get("hex") or "") if isinstance(reference, dict) else str(reference or "") if not re.fullmatch(r"[0-9A-Fa-f]{32}", property_ref): continue route = additional_attributes_storage_resolve( { "base_id": base_id_or_error, "property_ref": property_ref, "owner_object_ref": owner_object_ref, "timeout_seconds": min(int(payload.get("timeout_seconds") or 30), 20), } ) if route.get("status") == "confirmed": storage = route.get("storage") if isinstance(route.get("storage"), dict) else {} row["value_type"] = storage.get("value_type") or {"status": "unresolved"} row["storage_route"] = { "status": "confirmed", "source_ref": storage.get("source_ref"), "source_kind": storage.get("source_kind"), "fields": storage.get("fields"), "scd_join": route.get("scd_join"), } else: row["value_type"] = {"status": "unresolved"} row["storage_route"] = {"status": route.get("status") or "unresolved", "diagnostics": route.get("diagnostics")} status = "found" if rows else "not_found" if not rows and source.get("status") == "empty_source": status = "empty_source" return { "schema": "onec_additional_attributes_find.v1", "status": status, "base_id": base_id_or_error, "chart": result.get("object"), "chart_semantics": result.get("chart_semantics"), "query": {"text": query or None, "limit": int(limit or 50)}, "properties": rows, "counts": result.get("counts"), "diagnostics": { "message": ( "The chart data source contains no records in this base; a property cannot be confirmed." if status == "empty_source" else "No property matched the supplied public search text." if status == "not_found" else "Property definitions were found through the public chart route." ), "route": source, "semantic_fields": ["ref", "description", "marked_for_deletion", "value_type", "storage_route"], "unresolved_fields": ["name", "identifier_for_formula", "property_set"], }, } def enrich_chart_value_types( base_id: str, rows: list[dict[str, Any]], raw_rows: list[dict[str, Any]], *, timeout_seconds: int, ) -> list[dict[str, Any]]: """Decode each characteristic's stored Type pattern into a public type. ``_Type`` is a platform pattern blob, not an SQL type. Its parser is the same SQL payload decoder used for configuration metadata; this deliberately avoids a field-number profile or a runtime 1C call. """ for item, raw_row in zip(rows, raw_rows): raw_type = raw_row.get("_Type") if not isinstance(raw_type, (bytes, bytearray)): continue cache_key = (base_id.casefold(), hashlib.sha1(bytes(raw_type)).hexdigest()) with CHART_VALUE_TYPE_CACHE_LOCK: cached = CHART_VALUE_TYPE_CACHE.get(cache_key) value_type = copy.deepcopy(cached.get("value_type")) if cached and time.time() - float(cached.get("cached_at") or 0) <= CHART_VALUE_TYPE_CACHE_TTL_SECONDS else None if value_type is None: tree = parse_config_tree_from_bytes(bytes(raw_type)) if tree is None: continue try: value_type = public_pattern_value_type(base_id, tree, table="Config", timeout_seconds=min(timeout_seconds, 15)) except Exception: continue if isinstance(value_type, dict) and value_type.get("kind"): with CHART_VALUE_TYPE_CACHE_LOCK: CHART_VALUE_TYPE_CACHE[cache_key] = {"cached_at": time.time(), "value_type": copy.deepcopy(value_type)} if isinstance(value_type, dict) and value_type.get("kind"): item["value_type"] = value_type return rows def additional_attributes_storage_resolve(payload: dict[str, Any]) -> dict[str, Any]: """Resolve only evidenced value-storage routes for an additional property. A generic 1C configuration can use a different register than BSP. Until the property and Object/Property/Value roles are proven by metadata, return ``unresolved`` rather than a plausible-looking SQL join. """ method = "additional_attributes.storage.resolve" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error raw_property_ref = payload.get("property_ref") or payload.get("ref") or "" if isinstance(raw_property_ref, dict): property_ref = str(raw_property_ref.get("hex") or raw_property_ref.get("ref") or "").replace("-", "").strip() else: property_ref = str(raw_property_ref).replace("-", "").strip() owner_ref = str(payload.get("owner_object_ref") or payload.get("owner_ref") or payload.get("owner") or "").strip() if not re.fullmatch(r"[0-9a-fA-F]{32}", property_ref): return invalid_argument(method, "property_ref", "property_ref must be the 32-character reference returned by additional_attributes.find.") if not owner_ref: return invalid_argument(method, "owner_ref", "owner_ref is required and must be a public owner selector, for example Catalog.СтруктурныеЕдиницы.") # BSP-style additional requisites are commonly a tabular section of the # owner, rather than an information register. Resolve this from the owner # metadata and DBNames routes: no field number is supplied by the caller. owner_attributes = metadata_object_attributes({"base_id": base_id_or_error, "ref": owner_ref, "only": "all", "include_storage": True}) owner_card = owner_attributes.get("object") if isinstance(owner_attributes.get("object"), dict) else {} if owner_attributes.get("status") == "ok": for section in owner_attributes.get("tabular_sections") or []: if not isinstance(section, dict): continue columns = section.get("columns") if isinstance(section.get("columns"), list) else [] by_name = {normalize(str(column.get("name") or "")): column for column in columns if isinstance(column, dict)} property_column = by_name.get(normalize("Свойство")) value_column = by_name.get(normalize("Значение")) text_column = by_name.get(normalize("ТекстоваяСтрока")) section_route = next((route for route in section.get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "VT"), None) property_route = next((route for route in (property_column or {}).get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "Fld"), None) value_route = next((route for route in (value_column or {}).get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "Fld"), None) if not (property_column and value_column and section_route and property_route and value_route): continue section_number = int(section_route.get("sql_number") or 0) property_number = int(property_route.get("sql_number") or 0) value_number = int(value_route.get("sql_number") or 0) if not (section_number and property_number and value_number): continue owner_guid = str(owner_card.get("guid") or "").lower() owner_kind = str(owner_card.get("kind") or "") owner_role = str(DATA_TABLE_PREFIXES.get(owner_kind) or "").lstrip("_") records, records_error = live_dbnames_records(base_id_or_error) owner_record = next( (record for record in records or [] if str(getattr(record, "guid", "") or "").lower() == owner_guid and str(getattr(record, "storage_role", "") or "") == owner_role), None, ) if not records_error else None owner_number = int(getattr(owner_record, "sql_number", 0) or 0) if not owner_role or not owner_number: continue table_base = f"_{owner_role}{owner_number}_VT{section_number}" property_prefix, value_prefix = f"_Fld{property_number}", f"_Fld{value_number}" proven_rows: list[dict[str, Any]] = [] selected_table = "" selected_owner_physical = "" selected_property_physical = "" selected_value_physical = "" for table_name in (table_base, f"{table_base}X1"): columns_rows, columns_error = data_sql_rows( base_id_or_error, "SELECT c.name FROM sys.columns c JOIN sys.tables t ON t.object_id=c.object_id WHERE t.name=%s ORDER BY c.column_id", (table_name,), ) if columns_error: continue physical_columns = {str(row.get("name") or "") for row in columns_rows} owner_physical = f"_{owner_role}{owner_number}_IDRRef" property_physical = next((name for name in physical_columns if re.match(rf"^{re.escape(property_prefix)}(?:$|[A-Za-z_])", name)), "") value_physical = next((name for name in physical_columns if re.match(rf"^{re.escape(value_prefix)}(?:$|[A-Za-z_])", name)), "") if owner_physical not in physical_columns or not property_physical or not value_physical: continue candidate_rows, proven_error = data_sql_rows( base_id_or_error, f"SELECT COUNT_BIG(*) AS row_count FROM dbo.[{table_name}] WHERE [{property_physical}]=%s", (bytes.fromhex(property_ref),), ) if proven_error or not candidate_rows or int((candidate_rows[0] or {}).get("row_count") or 0) <= 0: continue selected_table, selected_owner_physical = table_name, owner_physical selected_property_physical, selected_value_physical = property_physical, value_physical proven_rows = candidate_rows break if not selected_table: continue owner_name = str(owner_card.get("name") or "").strip() source_ref = f"Справочник.{owner_name}.{section.get('name')}" if owner_name else f"{owner_ref}.{section.get('name')}" alias = "ДополнительныеРеквизиты" result = { "schema": "onec_additional_attributes_storage_route.v1", "status": "confirmed", "base_id": base_id_or_error, "property_ref": property_ref.upper(), "owner_ref": owner_ref, "storage": { "source_ref": source_ref, "source_kind": "TabularSection", "fields": {"object": "Ссылка", "property": property_column.get("name"), "value": value_column.get("name"), **({"text": text_column.get("name")} if text_column else {})}, "value_type": value_column.get("type"), }, "scd_join": { "source": source_ref, "alias": alias, "condition": f"{alias}.Ссылка = СтруктурнаяЕдиница.Ссылка И {alias}.Свойство = &СвойствоОтветственноеНаправление", "value_expression": f"{alias}.Значение", "parameters": {"СвойствоОтветственноеНаправление": property_ref.upper()}, }, "evidence": { "owner_tabular_section": section.get("name"), "matched_roles": ["Ссылка", "Свойство", "Значение"], "property_row_count": int((proven_rows[0] or {}).get("row_count") or 0), "metadata_routes": {"tabular_section": section_route, "property": property_route, "value": value_route}, "sql_route": {"table": selected_table, "owner": selected_owner_physical, "property": selected_property_physical, "value": selected_value_physical}, }, } return result requested_register = str(payload.get("register_ref") or "").strip() if requested_register: candidate_refs = [requested_register] else: listed = list_objects( "InformationRegister", base_id=base_id_or_error, limit=100, offset=0, include_storage=False, name_filter="ДополнительныеСведения", ) candidate_refs = [str(item.get("ref") or "") for item in listed.get("objects") or [] if isinstance(item, dict) and item.get("ref")] evidence: list[dict[str, Any]] = [] for register_ref in candidate_refs: attributes = metadata_object_attributes({"base_id": base_id_or_error, "ref": register_ref, "only": "all"}) if attributes.get("status") != "ok": continue dimensions = attributes.get("dimensions") if isinstance(attributes.get("dimensions"), list) else [] resources = attributes.get("resources") if isinstance(attributes.get("resources"), list) else [] by_name = {normalize(str(row.get("name") or "")): row for row in dimensions if isinstance(row, dict)} object_field = by_name.get(normalize("Объект")) property_field = by_name.get(normalize("Свойство")) value_field = next((row for row in resources if isinstance(row, dict) and normalize(str(row.get("name") or "")) == normalize("Значение")), None) resolved_type = ((property_field or {}).get("type") or {}).get("resolved") if isinstance((property_field or {}).get("type"), dict) else {} if not isinstance(resolved_type, dict): resolved_type = {} property_type = (property_field or {}).get("type") if isinstance((property_field or {}).get("type"), dict) else {} chart_match = ( str(resolved_type.get("kind") or "") == "ChartOfCharacteristicTypes" and str(resolved_type.get("name") or "") == "ДополнительныеРеквизитыИСведения" ) or str((property_type or {}).get("presentation") or "") == "ПланВидовХарактеристикСсылка.ДополнительныеРеквизитыИСведения" if object_field and property_field and value_field and chart_match: return { "schema": "onec_additional_attributes_storage_route.v1", "status": "confirmed", "base_id": base_id_or_error, "property_ref": property_ref.upper(), "owner_ref": owner_ref, "storage": { "source_ref": register_ref, "source_kind": "InformationRegister", "fields": {"object": object_field.get("name"), "property": property_field.get("name"), "value": value_field.get("name")}, "value_type": value_field.get("type"), }, "scd_join": { "source": register_ref, "alias": "ДополнительныеСведения", "condition": "ДополнительныеСведения.Объект = СтруктурнаяЕдиница.Ссылка И ДополнительныеСведения.Свойство = &Свойство", "value_expression": "ДополнительныеСведения.Значение", "parameters": {"Свойство": property_ref.upper()}, }, "evidence": { "property_dimension_type": resolved_type, "matched_roles": ["Объект", "Свойство", "Значение"], }, } evidence.append({"source_ref": register_ref, "object": bool(object_field), "property": bool(property_field), "value": bool(value_field), "property_chart_match": chart_match}) return { "schema": "onec_additional_attributes_storage_route.v1", "status": "unresolved", "base_id": base_id_or_error, "property_ref": property_ref.upper(), "owner_ref": owner_ref, "storage": None, "diagnostics": { "message": "No confirmed Object/Property/Value route is available yet. The adapter will not infer a register or _Fld columns from names.", "required_evidence": ["confirmed_property_definition", "register_object_dimension", "register_property_dimension", "register_value_resource"], "candidates_checked": evidence, }, } def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str | None = None) -> dict[str, Any]: """Read data under one wall-clock budget, including metadata decoding. SQL command timeouts alone cannot bound resolver/decode work performed after a query returns. The outer guard therefore applies to the complete public call. The worker is allowed to finish in the background so we never abort a DB-Library call unsafely, while the MCP caller receives a final result. """ method = method or ("data.count" if count_only else "data.list") if not payload.get("_data_read_deadline_guard"): timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) if timeout_error: return timeout_error guarded_payload = dict(payload) guarded_payload["_data_read_deadline_guard"] = True executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) future = executor.submit(data_read, guarded_payload, count_only=count_only, method=method) started = time.monotonic() try: return future.result(timeout=int(timeout_seconds or 30)) except concurrent.futures.TimeoutError: return { "schema": "onec_data_error.v1", "method": method, "status": "error", "error": "query_timeout", "base_id": payload.get("base_id"), "diagnostics": {"stage": "additional_attributes.find.chart_read" if method == "additional_attributes.find" else "data.read", "elapsed_seconds": int(timeout_seconds or 30), "object_ref": payload.get("object_ref") or payload.get("ref"), "query_route": "sql_metadata_and_chart_read"}, } finally: executor.shutdown(wait=False, cancel_futures=False) method = method or ("data.count" if count_only else "data.list") schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": schema["method"] = method return schema base_id = str(schema.get("base_id") or "") table = str((schema.get("table") or {}).get("name") or "") all_fields = schema.get("fields") or [] by_logical: dict[str, list[dict[str, Any]]] = {} for field in all_fields: by_logical.setdefault(str(field.get("name") or ""), []).append(field) kind = str((schema.get("object") or {}).get("kind") or "") requested_fields = payload.get("fields") if requested_fields is None: # Chart type patterns are binary metadata payloads, not ordinary list # columns. Selecting every physical field made a harmless # ``data.list(..., limit=5)`` decode several Type patterns and could # consume the whole request timeout. List calls therefore default to # the stable public identity fields; callers that truly need types use # the dedicated property finder/explicit fields path. if kind == "ChartOfCharacteristicTypes": requested_names = [ name for name in ("ref", "description", "name", "marked_for_deletion") if name in by_logical ] else: requested_names = list(by_logical) elif isinstance(requested_fields, list) and all(isinstance(item, str) for item in requested_fields): requested_names = list(dict.fromkeys(requested_fields)) else: return invalid_argument(method, "fields", "fields must be an array of logical field names.") unknown = [name for name in requested_names if name not in by_logical] if unknown: return invalid_argument(method, "fields", f"Unknown logical fields: {', '.join(unknown)}.", allowed_values=sorted(by_logical)) filters = payload.get("filters") or {} if not isinstance(filters, dict): return invalid_argument(method, "filters", "filters must be a JSON object with exact-match logical field values.") where = [] params: list[Any] = [] record_ref = data_record_ref(payload) if record_ref: ref = record_ref if not re.fullmatch(r"[0-9a-fA-F]{32}", ref): return invalid_argument(method, "record_ref", "record_ref must be a 32-character hexadecimal 1C reference id.") where.append("[_IDRRef]=%s") params.append(bytes.fromhex(ref)) for name, value in filters.items(): candidates = by_logical.get(str(name)) or [] if len(candidates) != 1: return invalid_argument(method, "filters", f"Field `{name}` is unknown or composite; exact scalar filtering is not available.") physical = str(candidates[0].get("physical_name") or "") if not re.fullmatch(r"_[A-Za-z0-9_]+", physical): return invalid_argument(method, "filters", f"Unsafe physical route for `{name}`.") if isinstance(value, dict): operator = str(value.get("operator") or "").strip().casefold() operand = value.get("value") if operator != "contains" or kind != "ChartOfCharacteristicTypes" or str(name) != "description": return invalid_argument(method, "filters", f"Operator `{operator or 'missing'}` is unsupported for `{name}`.") if not isinstance(operand, str): return invalid_argument(method, "filters", "contains requires a string value.") where.append(f"[{physical}] LIKE %s") params.append(f"%{operand}%") continue where.append(f"[{physical}]=%s") params.append(onec_data_filter_value(value, physical)) search = payload.get("search") if search not in {None, ""}: if not isinstance(search, str): return invalid_argument(method, "search", "search must be a JSON string.") if kind != "ChartOfCharacteristicTypes": return invalid_argument(method, "search", "search is currently available for ChartOfCharacteristicTypes only.") description_fields = by_logical.get("description") or [] description_physical = str((description_fields[0] or {}).get("physical_name") or "") if len(description_fields) == 1 else "" if not re.fullmatch(r"_[A-Za-z0-9_]+", description_physical): return {"schema": "onec_data_error.v1", "status": "route_unavailable", "base_id": base_id, "diagnostics": {"message": "The chart has no safe semantic description route for search."}} where.append(f"[{description_physical}] LIKE %s") params.append(f"%{search}%") if not payload.get("include_deleted") and "marked_for_deletion" in by_logical: where.append("[_Marked]=0x00") where_sql = " WHERE " + " AND ".join(where) if where else "" timeout = int(payload.get("timeout_seconds") or 30) if count_only: rows, error = data_sql_rows(base_id, f"SELECT COUNT_BIG(*) AS row_count FROM dbo.[{table}]{where_sql}", tuple(params), timeout_seconds=timeout) if error: return error return {"schema": "onec_data_count.v1", "status": "ok", "base_id": base_id, "object": schema.get("object"), "count": int((rows[0] or {}).get("row_count") or 0)} limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error offset, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0, maximum=1000000) if offset_error: return offset_error select_parts = [] alias_map: dict[str, str] = {} alias_types: dict[str, dict[str, Any]] = {} for logical in requested_names: for index, field in enumerate(by_logical[logical]): physical = str(field.get("physical_name") or "") if not re.fullmatch(r"_[A-Za-z0-9_]+", physical): continue alias = logical if len(by_logical[logical]) == 1 else f"{logical}__{physical.rsplit('_', 1)[-1]}" select_parts.append(f"[{physical}] AS [{alias}]") alias_map[alias] = logical if isinstance(field.get("type"), dict): alias_types[alias] = field["type"] order_field = str(payload.get("order_by") or ("date" if "date" in by_logical else "ref" if "ref" in by_logical else requested_names[0])) order_candidates = by_logical.get(order_field) or [] order_physical = str((order_candidates[0] or {}).get("physical_name") or "") if len(order_candidates) == 1 else "" if not re.fullmatch(r"_[A-Za-z0-9_]+", order_physical): return invalid_argument(method, "order_by", "order_by must name one scalar logical field.", allowed_values=sorted(name for name, rows in by_logical.items() if len(rows) == 1)) direction = str(payload.get("order") or "asc").strip().casefold() if direction not in {"asc", "desc"}: return invalid_argument(method, "order", "order must be asc or desc.", allowed_values=["asc", "desc"]) sql = f"SELECT {', '.join(select_parts)} FROM dbo.[{table}]{where_sql} ORDER BY [{order_physical}] {direction.upper()} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY" rows, error = data_sql_rows(base_id, sql, tuple([*params, int(offset or 0), int(limit or 100)]), timeout_seconds=timeout) if error: return error decoded = decode_data_rows(rows, alias_map, alias_types) if kind == "ChartOfCharacteristicTypes" and truthy(payload.get("include_value_types")): decoded = enrich_chart_value_types(base_id, decoded, rows, timeout_seconds=timeout) if str((schema.get("object") or {}).get("kind") or "") == "Enum": decoded = enrich_enum_data_rows( decoded, enum_value_public_map( base_id, data_schema_selector_payload(payload), timeout_seconds=timeout, ), ) result = { "schema": "onec_data_result.v1", "status": "ok", "base_id": base_id, "object": schema.get("object"), "rows": decoded, "counts": {"rows": len(decoded), "limit": int(limit or 100), "offset": int(offset or 0)}, "query": {"fields": requested_names, "filters": filters, "search": search or None, "order_by": order_field, "order": direction}, } if kind == "ChartOfCharacteristicTypes" and schema.get("chart_semantics"): result["chart_semantics"] = schema.get("chart_semantics") if kind == "ChartOfCharacteristicTypes" and truthy(payload.get("include_route_diagnostics")): diagnostic_rows, diagnostic_error = data_sql_rows( base_id, f"SELECT COUNT_BIG(*) AS row_count FROM dbo.[{table}]", timeout_seconds=timeout, ) if diagnostic_error: result["route_diagnostics"] = {"status": "diagnostic_unavailable", "table": {"row_kind": kind}, "message": (diagnostic_error.get("diagnostics") or {}).get("message")} else: source_count = int((diagnostic_rows[0] or {}).get("row_count") or 0) result["route_diagnostics"] = { "status": "empty_source" if source_count == 0 else "ok", "table": {"row_kind": kind}, "source_count": source_count, "message": "The resolved chart table has no rows." if source_count == 0 else "The resolved chart table contains rows.", } return result def decode_data_rows( rows: list[dict[str, Any]], alias_map: dict[str, str], alias_types: dict[str, dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: alias_types = alias_types or {} decoded = [] for row in rows: item: dict[str, Any] = {} composite: dict[str, dict[str, Any]] = {} for alias, value in row.items(): logical = alias_map.get(alias, alias) type_info = alias_types.get(alias) or {} if type_info.get("kind") == "boolean" and isinstance(value, (bytes, bytearray)) and len(value) == 1: value = bytes(value) != b"\x00" if alias != logical and "__" in alias: composite.setdefault(logical, {})[alias.split("__", 1)[1]] = onec_data_value(value, logical_name=logical) else: item[logical] = onec_data_value(value, logical_name=logical) item.update({name: {"type": "composite", "parts": parts} for name, parts in composite.items()}) decoded.append(item) return decoded DATA_VIRTUAL_ALIASES = { "slice_last": "slice_last", "slicelast": "slice_last", "срезпоследних": "slice_last", "slice_first": "slice_first", "slicefirst": "slice_first", "срезпервых": "slice_first", "balances": "balances", "balance": "balances", "остатки": "balances", "turnovers": "turnovers", "turnover": "turnovers", "обороты": "turnovers", "balances_and_turnovers": "balances_and_turnovers", "balancesandturnovers": "balances_and_turnovers", "остаткииобороты": "balances_and_turnovers", } def data_virtual_datetime(value: Any, argument: str, method: str) -> tuple[datetime | None, dict[str, Any] | None]: if value in {None, ""}: return None, None if isinstance(value, datetime): parsed = value elif isinstance(value, date): parsed = datetime.combine(value, datetime.min.time()) elif isinstance(value, str): try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None, invalid_argument(method, argument, f"{argument} must be an ISO date or datetime.") else: return None, invalid_argument(method, argument, f"{argument} must be an ISO date or datetime.") if parsed.tzinfo is not None: parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) if parsed.year < 3000: try: parsed = parsed.replace(year=parsed.year + 2000) except ValueError: return None, invalid_argument(method, argument, f"{argument} is outside the supported 1C SQL date range.") return parsed, None def data_virtual(payload: dict[str, Any]) -> dict[str, Any]: method = "data.virtual" raw_virtual = str(payload.get("virtual_table") or payload.get("view") or "").strip() virtual = DATA_VIRTUAL_ALIASES.get(normalize(raw_virtual)) or DATA_VIRTUAL_ALIASES.get(raw_virtual.casefold()) if not virtual: return invalid_argument(method, "virtual_table", "Unsupported virtual table.", allowed_values=sorted(set(DATA_VIRTUAL_ALIASES.values()))) schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": schema["method"] = method return schema base_id = str(schema.get("base_id") or "") kind = str((schema.get("object") or {}).get("kind") or "") if virtual.startswith("slice_") and kind != "InformationRegister": return invalid_argument(method, "virtual_table", "Slice views are available only for information registers.") if virtual in {"balances", "turnovers", "balances_and_turnovers"} and kind != "AccumulationRegister": return { "schema": "onec_data_virtual.v1", "status": "unsupported_register", "base_id": base_id, "object": schema.get("object"), "virtual_table": virtual, "diagnostics": {"message": "Universal SQL aggregation is enabled only for accumulation registers; accounting-register totals require register-specific account/subconto semantics."}, } table = str((schema.get("table") or {}).get("name") or "") fields = [field for field in schema.get("fields") or [] if isinstance(field, dict)] dimensions = [field for field in fields if field.get("section") in {"dimensions", "common_attributes"}] resources = [field for field in fields if field.get("section") == "resources"] by_name: dict[str, list[dict[str, Any]]] = {} for field in fields: by_name.setdefault(str(field.get("name") or ""), []).append(field) filters = payload.get("filters") or {} if not isinstance(filters, dict): return invalid_argument(method, "filters", "filters must be a JSON object with exact-match logical field values.") where: list[str] = [] params: list[Any] = [] for name, value in filters.items(): candidates = by_name.get(str(name)) or [] if len(candidates) != 1: return invalid_argument(method, "filters", f"Field `{name}` is unknown or composite; exact scalar filtering is not available.") physical = str(candidates[0].get("physical_name") or "") if not re.fullmatch(r"_[A-Za-z0-9_]+", physical): return invalid_argument(method, "filters", f"Unsafe physical route for `{name}`.") where.append(f"[{physical}]=%s") params.append(onec_data_filter_value(value, physical)) if "active" in by_name and "active" not in filters: where.append("[_Active]=0x01") start, start_error = data_virtual_datetime(payload.get("start"), "start", method) if start_error: return start_error end, end_error = data_virtual_datetime(payload.get("end") or payload.get("period"), "end", method) if end_error: return end_error limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error allow_full_scan, allow_full_scan_error = strict_bool_argument(payload, "allow_full_scan", method=method, default=False) if allow_full_scan_error: return allow_full_scan_error timeout = int(payload.get("timeout_seconds") or 60) def safe_field(field: dict[str, Any]) -> tuple[str, str] | None: physical = str(field.get("physical_name") or "") logical = str(field.get("name") or "") return (physical, logical) if re.fullmatch(r"_[A-Za-z0-9_]+", physical) and logical else None dimension_routes = [route for field in dimensions if (route := safe_field(field))] resource_routes = [route for field in resources if (route := safe_field(field))] if dimension_routes and not filters and not allow_full_scan: return invalid_argument( method, "filters", "At least one exact dimension filter is required for a virtual-table query unless allow_full_scan=true.", allowed_values=sorted({logical for _, logical in dimension_routes}), ) if virtual.startswith("slice_"): if "period" not in by_name: return {"schema": "onec_data_virtual.v1", "status": "unsupported_register", "base_id": base_id, "object": schema.get("object"), "virtual_table": virtual, "diagnostics": {"message": "This information register is not periodic and has no slice view."}} if end is not None: where.append("[_Period]<=%s") params.append(end) selected_fields = [field for field in fields if field.get("section") != "system" or field.get("name") in {"period", "active", "recorder_ref", "line_no"}] select_parts: list[str] = [] alias_map: dict[str, str] = {} logical_seen: dict[str, int] = {} for field in selected_fields: route = safe_field(field) if not route: continue physical, logical = route index = logical_seen.get(logical, 0) logical_seen[logical] = index + 1 alias = logical if index == 0 else f"{logical}__{physical.rsplit('_', 1)[-1]}" select_parts.append(f"[{physical}] AS [{alias}]") alias_map[alias] = logical partition = ", ".join(f"[{physical}]" for physical, _ in dimension_routes) order = "DESC" if virtual == "slice_last" else "ASC" where_sql = " WHERE " + " AND ".join(where) if where else "" partition_sql = f"PARTITION BY {partition} " if partition else "" sql = f"WITH ranked AS (SELECT {', '.join(select_parts)}, ROW_NUMBER() OVER ({partition_sql}ORDER BY [_Period] {order}) AS [_rn] FROM dbo.[{table}]{where_sql}) SELECT TOP {int(limit or 100)} {', '.join(f'[{alias}]' for alias in alias_map)} FROM ranked WHERE [_rn]=1" rows, error = data_sql_rows(base_id, sql, tuple(params), timeout_seconds=timeout) if error: return error decoded = decode_data_rows(rows, alias_map) else: if not resource_routes: return {"schema": "onec_data_virtual.v1", "status": "unsupported_register", "base_id": base_id, "object": schema.get("object"), "virtual_table": virtual, "diagnostics": {"message": "No numeric resources were resolved for this accumulation register."}} if "_RecordKind" not in {str(field.get("physical_name") or "") for field in fields}: return {"schema": "onec_data_virtual.v1", "status": "unsupported_register", "base_id": base_id, "object": schema.get("object"), "virtual_table": virtual, "diagnostics": {"message": "The accumulation register has no movement direction column."}} dim_select = [f"[{physical}] AS [{logical}]" for physical, logical in dimension_routes] group_sql = ", ".join(f"[{physical}]" for physical, _ in dimension_routes) alias_map = {logical: logical for _, logical in dimension_routes} aggregates: list[str] = [] select_params: list[Any] = [] if virtual == "balances": if end is None: return invalid_argument(method, "end", "end is required for balances.") where.append("[_Period]<=%s") params.append(end) for physical, logical in resource_routes: aggregates.append(f"SUM(CASE WHEN [_RecordKind]=0 THEN [{physical}] ELSE -[{physical}] END) AS [{logical}]") alias_map[logical] = logical elif virtual == "turnovers": if start is None or end is None: return invalid_argument(method, "start/end", "start and end are required for turnovers.") where.extend(["[_Period]>=%s", "[_Period]<=%s"]) params.extend([start, end]) for physical, logical in resource_routes: aggregates.append(f"SUM(CASE WHEN [_RecordKind]=0 THEN [{physical}] ELSE -[{physical}] END) AS [{logical}]") alias_map[logical] = logical else: if start is None or end is None: return invalid_argument(method, "start/end", "start and end are required for balances_and_turnovers.") where.append("[_Period]<=%s") params.append(end) for physical, logical in resource_routes: aggregates.extend( [ f"SUM(CASE WHEN [_Period]<%s THEN CASE WHEN [_RecordKind]=0 THEN [{physical}] ELSE -[{physical}] END ELSE 0 END) AS [{logical}__opening]", f"SUM(CASE WHEN [_Period]>=%s AND [_Period]<=%s THEN CASE WHEN [_RecordKind]=0 THEN [{physical}] ELSE -[{physical}] END ELSE 0 END) AS [{logical}__turnover]", f"SUM(CASE WHEN [_RecordKind]=0 THEN [{physical}] ELSE -[{physical}] END) AS [{logical}__closing]", ] ) select_params.extend([start, start, end]) alias_map.update({f"{logical}__opening": logical, f"{logical}__turnover": logical, f"{logical}__closing": logical}) where_sql = " WHERE " + " AND ".join(where) if where else "" select_sql = ", ".join([*dim_select, *aggregates]) sql = f"SELECT TOP {int(limit or 100)} {select_sql} FROM dbo.[{table}]{where_sql}{f' GROUP BY {group_sql}' if group_sql else ''}" rows, error = data_sql_rows(base_id, sql, tuple([*select_params, *params]), timeout_seconds=timeout) if error: return error decoded = decode_data_rows(rows, alias_map) return { "schema": "onec_data_virtual.v1", "status": "ok", "base_id": base_id, "object": schema.get("object"), "virtual_table": virtual, "rows": decoded, "counts": {"rows": len(decoded), "limit": int(limit or 100)}, "query": {"start": payload.get("start"), "end": payload.get("end") or payload.get("period"), "filters": filters}, } def data_present(payload: dict[str, Any]) -> dict[str, Any]: method = "data.present" record_ref = data_record_ref(payload) if not record_ref: return invalid_argument(method, "record_ref", "record_ref is required for data.present.") schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": return schema names = {str(field.get("name") or "") for field in schema.get("fields") or []} fields = [name for name in ("ref", "description", "code", "number", "date") if name in names] result = data_read({**payload, "record_ref": record_ref, "fields": fields, "limit": 1}, method=method) if result.get("status") != "ok": return result row = (result.get("rows") or [None])[0] if not isinstance(row, dict): return {"schema": "onec_data_presentation.v1", "status": "not_found", "base_id": result.get("base_id"), "object": result.get("object")} presentation = row.get("description") or row.get("number") or row.get("code") or record_ref return {"schema": "onec_data_presentation.v1", "status": "ok", "base_id": result.get("base_id"), "object": result.get("object"), "ref": row.get("ref"), "presentation": presentation, "record": row} def data_movements(payload: dict[str, Any]) -> dict[str, Any]: method = "data.movements" recorder_ref = payload.get("recorder_ref") or data_record_ref(payload) if not recorder_ref: return invalid_argument(method, "recorder_ref", "recorder_ref is required.") schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": return schema kind = str(((schema.get("object") or {}).get("kind") or "")) if kind not in {"InformationRegister", "AccumulationRegister", "AccountingRegister"}: return invalid_argument(method, "selector", "data.movements requires a register object selector.") names = {str(field.get("name") or "") for field in schema.get("fields") or []} if "recorder_ref" not in names: return {"schema": "onec_data_movements.v1", "status": "unsupported_register", "base_id": schema.get("base_id"), "object": schema.get("object"), "diagnostics": {"message": "This register has no recorder field and is not subordinate to a recorder."}} filters = dict(payload.get("filters") or {}) filters["recorder_ref"] = recorder_ref result = data_read({**payload, "record_ref": None, "filters": filters}, method=method) if result.get("status") == "ok": result["schema"] = "onec_data_movements.v1" return result def parse_module_id(module_id: str) -> tuple[str | None, str | None, int | None]: if ":" not in module_id: return None, None, None table, rest = module_id.split(":", 1) file_name, fragment = (rest.split("#", 1) + [""])[:2] if "#" in rest else (rest, "") if table not in STORAGE_TABLES or Path(file_name).name != file_name: return None, None, None stream_index = None if fragment: if fragment in {"form_module", "bsl", "bsl_container"}: return table, file_name, None match = re.fullmatch(r"stream[:=](\d+)", fragment) if not match: return None, None, None stream_index = int(match.group(1)) return table, file_name, stream_index MODULE_READ_MODES = ["text", "summary", "routines", "routines_only"] def validate_modules_read_arguments(payload: dict[str, Any]) -> dict[str, Any] | None: for name in ["include_storage", "include_text", "summary", "routines_only", "include_routines", "include_summary", "include_container_preview", "resolve_owner"]: _, bool_error = strict_bool_argument(payload, name, method="modules.read", default=False) if bool_error: return bool_error mode = payload.get("mode") if mode not in {None, ""}: if not isinstance(mode, str): return invalid_argument("modules.read", "mode", "mode must be a JSON string.", allowed_values=MODULE_READ_MODES) if mode.strip().casefold() not in MODULE_READ_MODES: return invalid_argument("modules.read", "mode", f"Unsupported mode `{mode}`.", allowed_values=MODULE_READ_MODES) _, preview_error = strict_bool_argument(payload, "preview", method="modules.read", default=False) if preview_error: return preview_error if "routine_name" in payload and payload.get("routine_name") is not None and not isinstance(payload.get("routine_name"), str): return invalid_argument("modules.read", "routine_name", "routine_name must be a JSON string.") if payload.get("routine_name") and (payload.get("line_start") is not None or payload.get("line_end") is not None): return invalid_argument("modules.read", "routine_name/line_range", "Pass routine_name or line_start/line_end, not both.") string_error = validate_optional_non_empty_string_arguments(payload, "modules.read", ["module_id", "module_ref"]) if string_error: return string_error table_error = metadata_storage_table(payload, "modules.read") if isinstance(table_error, dict): return table_error _, bsl_offset_error = parse_int_argument(payload, "bsl_offset", method="modules.read", default=0, minimum=0) if bsl_offset_error: return bsl_offset_error line_start, line_start_error = parse_int_argument(payload, "line_start", method="modules.read", default=1, minimum=1) if line_start_error: return line_start_error line_end, line_end_error = parse_int_argument(payload, "line_end", method="modules.read", default=line_start, minimum=1) if line_end_error: return line_end_error if (payload.get("line_start") is not None or payload.get("line_end") is not None) and int(line_end or 1) < int(line_start or 1): return invalid_argument("modules.read", "line_end", "line_end must be greater than or equal to line_start.") _, offset_error = parse_int_argument(payload, "offset", method="modules.read", default=0, minimum=0) if offset_error: return offset_error _, max_chars_error = parse_int_argument(payload, "max_chars", method="modules.read", default=1, minimum=1) if max_chars_error: return max_chars_error _, container_preview_chars_error = parse_int_argument(payload, "container_preview_chars", method="modules.read", default=1000, minimum=1) if container_preview_chars_error: return container_preview_chars_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="modules.read", default=30, minimum=1) if timeout_error: return timeout_error for name in ("module_ordinal", "module_index", "module_number"): if name in payload and (payload.get(name) is None or payload.get(name) == ""): return invalid_argument("modules.read", name, f"{name} must be a JSON integer when provided.") module_ordinal_value = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") if module_ordinal_value is not None: _, ordinal_error = parse_ordinal(module_ordinal_value, "modules.read", argument="module_ordinal") if ordinal_error: return ordinal_error return None def module_text_response( text: str, payload: dict[str, Any], *, include_text_default: bool = True, ) -> dict[str, Any]: normalized = text.replace("\r\n", "\n").replace("\r", "\n") lines = normalized.split("\n") if normalized else [] try: from parser.bsl_validation import routine_blocks except Exception: routine_blocks = None routines = list(routine_blocks(normalized)) if routine_blocks else [] public_routines = [ { "kind": routine.get("kind"), "name": routine.get("name"), "line_start": routine.get("line_start"), "line_end": routine.get("line_end"), } for routine in routines ] routine_name = str(payload.get("routine_name") or "").strip() has_line_range = payload.get("line_start") is not None or payload.get("line_end") is not None selected_text = normalized selected_range: dict[str, Any] | None = None if has_line_range: line_start = max(1, int(payload.get("line_start") or 1)) line_end = max(line_start, int(payload.get("line_end") or line_start)) if line_start > len(lines): selected_text = "" selected_range = {"line_start": line_start, "line_end": line_end, "status": "not_found"} else: line_end = min(line_end, len(lines)) selected_text = "\n".join(lines[line_start - 1 : line_end]) selected_range = {"line_start": line_start, "line_end": line_end, "match_by": "line_range"} elif routine_name: wanted = normalize(routine_name) routine = next((item for item in routines if normalize(str(item.get("name") or "")) == wanted), None) if routine: start = max(1, int(routine.get("line_start") or 1)) end = max(start, int(routine.get("line_end") or start)) selected_text = "\n".join(lines[start - 1 : end]) canonical_routine_name = str(routine.get("name") or "") if routine_name == canonical_routine_name: match_by = "routine_exact" elif routine_name.casefold() == canonical_routine_name.casefold(): match_by = "routine_case_insensitive" else: match_by = "routine_normalized" selected_range = {"routine_name": canonical_routine_name, "line_start": start, "line_end": end, "match_by": match_by} else: selected_text = "" selected_range = {"routine_name": routine_name, "status": "not_found"} offset, offset_error = parse_int_argument(payload, "offset", method="modules.read", default=0, minimum=0) default_max_chars = 4000 if truthy(payload.get("preview")) else len(selected_text) max_chars, max_chars_error = parse_int_argument(payload, "max_chars", method="modules.read", default=default_max_chars, minimum=1) mode = str(payload.get("mode") or "").strip().casefold() include_text = truthy(payload.get("include_text", "1" if include_text_default else "0")) summary_requested = mode in {"summary", "routines", "routines_only"} or truthy(payload.get("summary")) or truthy(payload.get("routines_only")) or not include_text if mode in {"summary", "routines", "routines_only"}: include_text = False # A routine read is normally a focused operation. Returning every method # of a large common module along with one selected procedure wastes most of # the MCP context window, so the concise view is the default only when a # routine was explicitly selected. Full inventories remain opt-in and # preserve the historic default for whole-module reads. include_routines = truthy(payload.get("include_routines", "0" if routine_name else "1")) include_summary = truthy(payload.get("include_summary", "0" if routine_name else "1")) if mode in {"routines", "routines_only"} or truthy(payload.get("routines_only")): include_routines = True if mode == "routines_only": include_summary = False result: dict[str, Any] = {} if include_summary: result["summary"] = { "chars": len(normalized), "lines": len(lines), "routines": len(public_routines), "procedures": sum(1 for item in public_routines if str(item.get("kind") or "").casefold() == "процедура"), "functions": sum(1 for item in public_routines if str(item.get("kind") or "").casefold() == "функция"), } if include_routines: result["routines"] = public_routines if selected_range: result["selection"] = selected_range if offset_error or max_chars_error: argument_error = offset_error or max_chars_error or {} result["status"] = "invalid_argument" result["error"] = argument_error.get("error", "invalid_argument") result["argument"] = argument_error.get("argument") result["diagnostics"] = argument_error.get("diagnostics") return result fragment = selected_text[int(offset or 0) : int(offset or 0) + int(max_chars or 0)] if selected_range and selected_range.get("status") == "not_found": result["status"] = "not_found" result["method"] = "modules.read" result["error"] = "routine_not_found" if routine_name else "line_range_not_found" result["diagnostics"] = {"message": f"Процедура или функция `{routine_name}` не найдена в модуле."} if routine_name else {"message": "Запрошенный диапазон строк находится за пределами модуля."} elif int(offset or 0) > len(selected_text): result["status"] = "range_not_satisfiable" result["error"] = "offset_out_of_range" result["diagnostics"] = {"message": f"offset {offset} больше размера выбранного текста {len(selected_text)}."} preview_requested = truthy(payload.get("preview")) if include_text and not summary_requested: result["text"] = fragment result["text_range"] = { "offset": offset, "chars": len(fragment), "total_chars": len(selected_text), "truncated": offset + len(fragment) < len(selected_text), "mode": "preview" if preview_requested else "text", } if preview_requested and include_text and not summary_requested: result["preview"] = fragment # Hashes are public, stable read preconditions; they do not expose physical # storage coordinates. A write must still supply an exact logical path and # origin, but an agent can now carry the text guard forward from code.read. result["content_sha1"] = hashlib.sha1(normalized.encode("utf-8")).hexdigest() result["selection_sha1"] = hashlib.sha1(selected_text.encode("utf-8")).hexdigest() return result def cached_module_owner_payload(base_id: str, module_id: str) -> dict[str, Any] | None: config, _ = sql_config_for_base(base_id) if not config: return None cached = metadata_module_owner_cache_lookup(config, module_id) if not cached: return None owner_payload = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} module_payload = cached.get("module_payload") if isinstance(cached.get("module_payload"), dict) else {} if not owner_payload: return None module_extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} result = { "owner": { "status": "resolved" if owner_payload.get("guid") else "partial", "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "synonym": owner_payload.get("synonym"), "guid": owner_payload.get("guid"), "source": "metadata.module_owner_cache", }, "module": module_payload, } if module_extension.get("guid") or module_extension.get("name"): result["origin"] = { "source": "extension", "presentation": "Расширение", "extension": { "guid": module_extension.get("guid"), "name": module_extension.get("name"), }, "status": "ok" if module_extension.get("name") else "extension_unresolved", } return result def module_ref_matches(candidate: str, wanted: str) -> bool: if candidate == wanted: return True candidate_table, candidate_file_name, candidate_stream_index = parse_module_id(candidate) wanted_table, wanted_file_name, wanted_stream_index = parse_module_id(wanted) if not candidate_table or not candidate_file_name or not wanted_table or not wanted_file_name: return False if candidate_table != wanted_table or candidate_file_name != wanted_file_name: return False if wanted_stream_index is None: return True return candidate_stream_index == wanted_stream_index def extension_module_owner_payload( base_id: str, module_id: str, *, table: str, timeout_seconds: int, ) -> dict[str, Any] | None: if table not in {"ConfigCAS", "ConfigCASSave"}: return None if table in FORM_ELEMENT_SAVED_STATE_TABLES: _, _, stream_index = parse_module_id(module_id) return { "owner": { "status": "partial", "kind": "saved_state", "name": None, "synonym": None, "guid": None, "source": "direct_saved_state_module_ref", }, "module": { "module_name": "Saved-state module", "stream_index": stream_index, }, "origin": { "source": "saved_state", "presentation": "Saved-state Configurator layer", "status": "ok", }, } guid_sources, source_error = extension_definition_guid_sources(base_id, timeout_seconds=timeout_seconds) if source_error or not guid_sources: return None owner_table = "ConfigCASSave" if table == "ConfigCASSave" else "ConfigCAS" for owner_guid, sources in sorted(guid_sources.items()): if not is_guid_text(owner_guid): continue modules_result = metadata_object_modules( { "base_id": base_id, "guid": owner_guid, "table": owner_table, "include_storage": True, "timeout_seconds": timeout_seconds, } ) if modules_result.get("status") != "ok": continue modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] for ordinal, module in enumerate(modules, start=1): candidate_module_id = str(module.get("module_id") or "").strip() if not candidate_module_id or not module_ref_matches(candidate_module_id, module_id): continue object_info = modules_result.get("object") if isinstance(modules_result.get("object"), dict) else {} source_item = next((source for source in sources or [] if isinstance(source, dict)), {}) extension = source_item.get("extension") if isinstance(source_item.get("extension"), dict) else {} _, _, candidate_stream_index = parse_module_id(candidate_module_id) public_module = public_module_row( module, include_storage=False, ordinal=ordinal, owner_kind=object_info.get("kind"), owner_name=object_info.get("name"), ) return { "owner": { "status": "resolved" if object_info.get("guid") else "partial", "kind": object_info.get("kind"), "name": object_info.get("name"), "synonym": object_info.get("synonym"), "guid": object_info.get("guid") or owner_guid, "source": "extension_definition_guid_sources", }, "module": { "module_name": public_module.get("name"), "module_ordinal": ordinal, "stream_index": candidate_stream_index, }, "origin": { "source": "extension", "presentation": "Расширение", "extension": extension or {"guid": None, "name": None, "active": None}, "status": "ok" if extension.get("name") else "extension_unresolved", }, } return None def module_origin_from_storage_table(table: str) -> dict[str, Any]: table_name = str(table or "").strip() if table_name == "Config": return { "source": "configuration", "presentation": "Конфигурация", "status": "ok", "storage_table": table_name, "write_surface": "base_saved_state", } if table_name == "ConfigSave": return { "source": "saved_state", "presentation": "Saved-state Configurator layer", "status": "ok", "storage_table": table_name, "write_surface": "base_saved_state", } if table_name == "ConfigCASSave": return { "source": "saved_state", "presentation": "Saved-state Configurator layer", "status": "ok", "storage_table": table_name, "write_surface": "saved_state", "diagnostics": {"message": "Owner extension/base layer requires resolved module owner evidence."}, } if table_name == "ConfigCAS": return { "source": "cas_reference", "presentation": "CAS module reference", "status": "owner_unresolved", "storage_table": table_name, "write_surface": "requires_owner_resolution", "diagnostics": {"message": "ConfigCAS may contain base or extension payloads; resolve owner before write planning."}, } return { "source": "unknown", "presentation": "Unknown module source", "status": "unknown", "storage_table": table_name or None, "write_surface": "requires_owner_resolution", } def merge_module_owner_context(result: dict[str, Any], owner_context: dict[str, Any] | None) -> dict[str, Any]: if not owner_context: return result owner = owner_context.get("owner") if isinstance(owner_context.get("owner"), dict) else None module_payload = owner_context.get("module") if isinstance(owner_context.get("module"), dict) else {} origin = owner_context.get("origin") if isinstance(owner_context.get("origin"), dict) else None if owner and not result.get("owner"): result["owner"] = owner if origin and (not result.get("origin") or str((result.get("origin") or {}).get("status") or "") == "owner_unresolved"): result["origin"] = origin form_from_context = owner_context.get("form") if isinstance(owner_context.get("form"), dict) else None if form_from_context and not result.get("form"): result["form"] = form_from_context module = result.get("module") if isinstance(result.get("module"), dict) else {} if module_payload: context_module_name = module_payload.get("module_name") or module_payload.get("name") if context_module_name and module.get("name") in {None, "", "BSL module", "Модуль БСЛ"}: module["name"] = context_module_name if context_module_name == "Модуль формы" and module.get("kind") in {None, "", "bsl_container_module", "container_payload"}: module["kind"] = "form_module" if module_payload.get("module_ordinal") is not None and module.get("module_ordinal") is None: module["module_ordinal"] = module_payload.get("module_ordinal") if module_payload.get("stream_index") is not None and module.get("stream_index") is None: module["stream_index"] = module_payload.get("stream_index") if module_payload.get("form") and not module.get("form"): module["form"] = module_payload.get("form") if module: result["module"] = module form_payload = result.get("form") if isinstance(result.get("form"), dict) else form_from_context qualified_name = public_code_qualified_name(owner=owner, form=form_payload, module=module) if qualified_name: result["qualified_name"] = qualified_name result["display_name"] = qualified_name return result def read_module(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "modules.read") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "modules.read") if isinstance(base_id_or_error, dict): return base_id_or_error argument_error = validate_modules_read_arguments(payload) if argument_error: return argument_error table_for_read = metadata_storage_table(payload, "modules.read") if isinstance(table_for_read, dict): return table_for_read include_storage = bool(payload.get("include_storage", False)) resolve_owner, resolve_owner_error = strict_bool_argument(payload, "resolve_owner", method="modules.read", default=True) if resolve_owner_error: return resolve_owner_error # `state=save` means an explicit saved-state read. `working` is an # effective view: resolve a public selector from active metadata first, # then let code.read choose a saved counterpart only if it exists. # Otherwise a first-write extension object is incorrectly forced to an # absent ConfigCASSave row and cannot be read or planned. requested_state = str(payload.get("state") or "").strip().casefold() if "table" not in payload and requested_state in {"save", "saved"}: table_for_read = "ConfigCASSave" if str(payload.get("extension") or "").strip() else "ConfigSave" module_id = str(payload.get("module_id") or payload.get("module_ref") or "") selected_module: dict[str, Any] | None = None owner_context: dict[str, Any] | None = None if not module_id: module_ordinal_value = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number", default=1) module_ordinal, ordinal_error = parse_ordinal(module_ordinal_value, "modules.read", argument="module_ordinal") if ordinal_error: return ordinal_error modules_payload = {key: value for key, value in payload.items() if key not in {"module", "module_name"}} modules_result = metadata_object_modules({**modules_payload, "include_storage": True, "table": table_for_read}) if modules_result.get("status") != "ok": return public_error_result(modules_result, include_storage=include_storage, method="modules.read") object_info = modules_result.get("object") if isinstance(modules_result.get("object"), dict) else {} owner_context = { "owner": { "status": "resolved" if object_info.get("guid") else "partial", "kind": object_info.get("kind") or canonical_kind(str(payload.get("kind") or "")), "name": object_info.get("name") or payload.get("name"), "synonym": object_info.get("synonym"), "guid": object_info.get("guid") or payload.get("guid"), "source": "live_metadata", }, "module": {"module_ordinal": module_ordinal}, } object_origin = object_info.get("origin") if isinstance(object_info.get("origin"), dict) else None if object_origin: owner_context["origin"] = object_origin modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] requested_module_role = str(payload.get("module") or "").strip().casefold() if requested_module_role: role_aliases = { "object_module": {"object_module", "модуль объекта"}, "command_module": {"command_module", "модуль команды"}, "form_module": {"form_module", "модуль формы"}, } wanted_roles = next((aliases for key, aliases in role_aliases.items() if requested_module_role in aliases), {requested_module_role}) role_matches = [ module for module in modules if str(module.get("kind") or "").casefold() in wanted_roles or str(module.get("name") or "").strip().casefold() in wanted_roles ] # Platform payloads can expose a short service stream beside the # actual object module. Prefer the substantial BSL stream; this is # deterministic and still never crosses an object boundary. if role_matches: selected_module = max( role_matches, key=lambda item: (int(item.get("bytes") or 0), int(item.get("stream_index") or -1)), ) module_ordinal = int(selected_module.get("module_ordinal") or module_ordinal or 1) else: selected_module = None else: index = int(module_ordinal or 1) - 1 selected_module = modules[index] if 0 <= index < len(modules) else None if selected_module is None: return { "schema": "onec_module_read.v1", "method": "modules.read", "status": "not_found", "error": "not_found", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": { "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), "ordinal": first_non_empty_arg(payload, "ordinal", "index", "object_index"), "module_ordinal": module_ordinal, }, "diagnostics": {"message": f"Module ordinal {module_ordinal} was not found for the selected object."}, } module_id = str(selected_module.get("module_id") or "") active_table, active_file_name, active_stream_index = parse_module_id(module_id) if table_for_read in FORM_ELEMENT_SAVED_STATE_TABLES and active_table in SAVED_STATE_TARGET_BY_SOURCE and active_file_name: module_id = f"{SAVED_STATE_TARGET_BY_SOURCE[active_table]}:{active_file_name}" if active_stream_index is not None: module_id += f"#stream:{active_stream_index}" table, file_name, stream_index = parse_module_id(module_id) if not table or not file_name: diagnostics: dict[str, Any] = {"message": MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE} # A bare identifier is often a common-module name accidentally passed # as an opaque module_ref. Resolve that public name only for an error # hint; never silently reinterpret module_ref and risk selecting a # different owner type. if ":" not in module_id and re.fullmatch(r"[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*", module_id): named_module = get_object( "CommonModule", module_id, base_id=base_id_or_error, include_semantic=False, timeout_seconds=min(int(payload.get("timeout_seconds") or 30), 15), ) named_object = named_module.get("object") if isinstance(named_module.get("object"), dict) else {} if named_module.get("status") == "ok" and named_object.get("name"): public_ref = named_object.get("ref") or object_selector_ref("CommonModule", named_object.get("name")) diagnostics.update( { "message": "module_ref is an opaque handle returned by modules.search/code.search, not a public module name.", "detected_public_module": {"kind": "CommonModule", "name": named_object.get("name"), "ref": public_ref}, "next_call": { "method": "modules.read", "params": { "base_id": base_id_or_error, "ref": public_ref, "module_ordinal": 1, }, }, } ) return { "schema": "onec_adapter_request_error.v1", "method": "modules.read", "status": "error", "error": "invalid_module_id", "diagnostics": diagnostics, } # An extension saved-state stream with a GUID prefix has a proven layer # identity before decoding. Reject it early only when that exact extension # is inactive; opaque non-prefixed module refs still require owner proof. if table == "ConfigCASSave" and "__" in file_name: stream_extension_guid = file_name.split("__", 1)[0].strip().lower() if is_guid_text(stream_extension_guid): inactive_stream_error = reject_inactive_extension_scope( {"base_id": base_id_or_error, "extension_guid": stream_extension_guid}, method="modules.read" ) if inactive_stream_error: return inactive_stream_error if module_id and owner_context is None: cache_config, _ = sql_config_for_base(base_id_or_error) cached_form_owner = metadata_form_owner_cache_lookup(cache_config, module_ref=module_id) if cached_form_owner: cached_owner = cached_form_owner.get("owner") if isinstance(cached_form_owner.get("owner"), dict) else {} cached_form = cached_form_owner.get("form") if isinstance(cached_form_owner.get("form"), dict) else {} owner_context = { "owner": { "status": "resolved", "kind": cached_owner.get("kind") or cached_form.get("kind"), "name": cached_owner.get("name") or cached_form.get("name"), "synonym": cached_owner.get("synonym"), "guid": cached_owner.get("guid") or cached_form.get("guid"), "source": "metadata_form_owner_cache", }, "module": { "module_name": "Модуль формы", "module_ordinal": None, "stream_index": None, "form": cached_form.get("name"), }, "origin": { "source": "extension" if cached_form_owner.get("extension") else "metadata_form_owner_cache", "presentation": "Расширение" if cached_form_owner.get("extension") else "Индекс форм", "extension": cached_form_owner.get("extension"), "status": "ok", }, } else: owner_context = cached_module_owner_payload(base_id_or_error, module_id) direct_container_module_ref = stream_index is None and ( "bsl_offset" in payload or "#form_module" in module_id or "#bsl" in module_id or "#bsl_container" in module_id ) if owner_context is None and direct_container_module_ref: owner_context = { "owner": { "status": "partial", "kind": None, "name": None, "synonym": None, "guid": None, "source": "direct_container_module_ref", "diagnostics": { "message": "Контейнерный BSL формы прочитан напрямую по SQL module_ref/bsl_offset; точный владелец формы требует отдельной индексации metadata.", }, }, "module": { "module_name": "Модуль формы", "stream_index": None, }, "origin": module_origin_from_storage_table(table), } if owner_context is None and resolve_owner: owner_context = extension_module_owner_payload( base_id_or_error, module_id, table=table, timeout_seconds=int(payload.get("timeout_seconds") or 30), ) if resolve_owner and table in FORM_ELEMENT_SAVED_STATE_TABLES and ( owner_context is None or not isinstance(owner_context.get("owner"), dict) or not str((owner_context.get("owner") or {}).get("name") or "").strip() ): saved_context = saved_state_public_module_context( base_id=base_id_or_error, table=table, file_name=file_name, object_kind=canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) or None, timeout_seconds=int(payload.get("timeout_seconds") or 30), prefer_form_module=stream_index is None, ) if saved_context: existing_context = owner_context or {} owner_context = {**existing_context, **saved_context} if isinstance(existing_context.get("module"), dict) or isinstance(saved_context.get("module"), dict): owner_context["module"] = {**(existing_context.get("module") or {}), **(saved_context.get("module") or {})} if isinstance(existing_context.get("owner"), dict) or isinstance(saved_context.get("owner"), dict): owner_context["owner"] = {**(existing_context.get("owner") or {}), **(saved_context.get("owner") or {})} if isinstance(existing_context.get("origin"), dict) or isinstance(saved_context.get("origin"), dict): owner_context["origin"] = {**(existing_context.get("origin") or {}), **(saved_context.get("origin") or {})} if owner_context is None: owner_context = { "owner": { "status": "partial", "source": "direct_module_ref", "diagnostics": { "message": "Module payload was read directly. Owner enrichment was intentionally skipped; pass resolve_owner=true only when owner names are required.", }, }, "origin": module_origin_from_storage_table(table), } if not isinstance(owner_context.get("origin"), dict): owner_context["origin"] = module_origin_from_storage_table(table) if "#form_module" in module_id: decoded_form = metadata_form_decode({ "base_id": base_id_or_error, "table": table, "form_guid": file_name, "include_module_text": True, "include_parameters": False, "timeout_seconds": int(payload.get("timeout_seconds") or 30), "_internal": True, }) profile = decoded_form.get("profile") if isinstance(decoded_form.get("profile"), dict) else {} form_module = profile.get("module") if isinstance(profile.get("module"), dict) else {} form_text = str(form_module.get("text") or "") if decoded_form.get("status") == "ok" and form_text: text_info = module_text_response(form_text, payload) result = {"schema": "onec_module_read.v1", "status": text_info.pop("status", "ok"), "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "module": {"kind": "form_module", "name": "Модуль формы"}, "extraction": {"status": "ok", "source": "metadata_form_decode"}, **text_info} # metadata_form_decode returns semantic text, not necessarily the # raw container checksum. Read the already-addressed container # once to expose the compare-and-set guard required by code.write. form_data, _form_config, form_read_error = read_storage_file_bytes( base_id_or_error, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30) ) if not form_read_error and form_data is not None: result["write_preconditions"] = { "expected_sha1": hashlib.sha1(form_data).hexdigest(), "expected_text_sha1": str(result.get("content_sha1") or ""), "status": "read_verified", } return merge_module_owner_context(result, owner_context) data, config, error = read_storage_file_bytes(base_id_or_error, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30)) if error: error["method"] = "modules.read" return error if stream_index is not None: try: from parser.cas_payload import classify_payload except Exception as exc: return { "schema": "onec_module_read.v1", "status": "error", "base_id": base_id_or_error, "module_id": module_id, "diagnostics": {"message": f"Payload classifier is unavailable: {exc}"}, } classified = classify_payload(data, include_text=True) streams = classified.get("stream_blocks") or [] if stream_index < 0 or stream_index >= len(streams): result = { "schema": "onec_module_read.v1", "status": "not_found", "base_id": base_id_or_error, "diagnostics": {"message": "Stream index was not found in the requested payload."}, } if include_storage: result["module_id"] = module_id return result stream = streams[stream_index] stream_text = repair_bsl_mojibake_text(str(stream.get("text") or "")) text_info = module_text_response(stream_text, payload) descriptor_identity = saved_state_module_owner_identity( base_id=base_id_or_error, table=table, file_name=file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30), ) if table in FORM_ELEMENT_SAVED_STATE_TABLES else None effective_owner_kind = canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) or ( descriptor_identity.get("kind") if isinstance(descriptor_identity, dict) else None ) saved_state_module_role = saved_state_bsl_module_role( file_name, owner_kind=effective_owner_kind, ) if table in FORM_ELEMENT_SAVED_STATE_TABLES else {} result = { "schema": "onec_module_read.v1", "status": text_info.pop("status", "ok"), "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, **( {"module": public_module_row( selected_module, include_storage=False, ordinal=int(payload.get("module_ordinal") or payload.get("module_index") or payload.get("module_number") or 1), owner_kind=owner_context.get("owner", {}).get("kind") if isinstance(owner_context.get("owner"), dict) else None, owner_name=owner_context.get("owner", {}).get("name") if isinstance(owner_context.get("owner"), dict) else None, )} if selected_module else ({"module": saved_state_module_role} if saved_state_module_role else {}) ), **text_info, } # `content_sha1` is the SHA of the decoded BSL text. Make that # distinction explicit for callers of code.write: `expected_sha1` # protects the binary storage payload, whereas this guard protects # the exact source text the agent reviewed. result["write_preconditions"] = { "expected_text_sha1": str(result.get("content_sha1") or ""), "status": "read_verified", } if include_storage: result["write_preconditions"]["expected_payload_sha1"] = hashlib.sha1(data).hexdigest() if descriptor_identity and descriptor_identity.get("name"): result["owner"] = { "status": "resolved", "kind": effective_owner_kind or "Catalog", "name": descriptor_identity.get("name"), "synonym": descriptor_identity.get("synonym"), "guid": descriptor_identity.get("guid"), "source": "saved_state_descriptor", } if include_storage: result["module_id"] = module_id result["source"] = {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name, "stream_index": stream_index} result["payload"] = { "role": classified.get("role"), "compression": classified.get("compression"), "raw_bytes": classified.get("raw_bytes"), "payload_bytes": classified.get("payload_bytes"), "stream": {key: value for key, value in stream.items() if key != "text"}, } return merge_module_owner_context(result, owner_context) decoded = payload_text_from_bytes(data) if decoded.get("status") != "ok": try: from parser.cas_payload import classify_payload classified = classify_payload(data, include_text=True) bsl_stream_indexes = [ index for index, stream in enumerate(classified.get("stream_blocks") or []) if stream.get("has_bsl_marker") and str(stream.get("text") or "").strip() ] except Exception: bsl_stream_indexes = [] if len(bsl_stream_indexes) == 1: fallback_payload = dict(payload) fallback_payload.pop("module_id", None) fallback_payload["module_ref"] = f"{table}:{file_name}#stream:{bsl_stream_indexes[0]}" return read_module(fallback_payload) container_text = str(decoded.pop("text", None) or "") bsl_offset = int(payload["bsl_offset"]) if "bsl_offset" in payload else None text, extraction = extract_bsl_text_from_container(container_text, bsl_offset=bsl_offset) if extraction.get("status") == "ok" and ( bsl_offset is not None or "#form_module" in module_id or "#bsl" in module_id or "#bsl_container" in module_id ): text = form_embedded_module_public_text(str(text or "")) text_info = module_text_response(str(text or ""), payload) result = { "schema": "onec_module_read.v1", "status": text_info.pop("status", decoded.get("status")), "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "module": ( public_module_row( selected_module, include_storage=False, ordinal=int(payload.get("module_ordinal") or payload.get("module_index") or payload.get("module_number") or 1), owner_kind=owner_context.get("owner", {}).get("kind") if isinstance(owner_context.get("owner"), dict) else None, owner_name=owner_context.get("owner", {}).get("name") if isinstance(owner_context.get("owner"), dict) else None, ) if selected_module else {"kind": "bsl_container_module" if extraction.get("status") == "ok" else "container_payload", "name": "BSL module"} ), "extraction": extraction, **text_info, } payload_sha1 = str(decoded.get("sha1") or hashlib.sha1(data).hexdigest()).strip().lower() if payload_sha1: result["write_preconditions"] = { "expected_sha1": payload_sha1, "expected_text_sha1": str(result.get("content_sha1") or ""), "status": "read_verified", } if bool(payload.get("include_container_preview", False)): container_preview_chars, _ = parse_int_argument(payload, "container_preview_chars", method="modules.read", default=1000, minimum=1) result["container_preview"] = container_text[: int(container_preview_chars or 1000)] if include_storage: result["module_id"] = module_id result["source"] = {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name} result["payload"] = decoded return merge_module_owner_context(result, owner_context) STANDARD_OBJECT_MEMBERS = { "Catalog": {"Наименование", "Код", "ПометкаУдаления", "Ссылка"}, "Document": {"Дата", "Номер", "ПометкаУдаления", "Ссылка", "Проведен"}, } def bsl_symbol_expression_parts(expression: str) -> list[str]: return [part.strip() for part in str(expression or "").split(".") if part.strip()] def bsl_declared_symbols(text: str) -> set[str]: symbols: set[str] = set() for match in re.finditer(r"(?im)^\s*Перем\s+([^;\n]+)", text or ""): for part in re.split(r",", match.group(1)): name = re.sub(r"\s+Экспорт\b", "", part, flags=re.IGNORECASE).strip() if name: symbols.add(name) for match in re.finditer(r"(?im)^\s*(?:Для\s+Каждого|Для каждого)\s+([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s+Из\b", text or ""): symbols.add(match.group(1)) for match in re.finditer(r"(?m)^\s*([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*=", text or ""): symbols.add(match.group(1)) return symbols def bsl_routine_params(text: str, routine_name: str | None) -> set[str]: if not routine_name: return set() wanted = normalize(routine_name) pattern = re.compile(r"(?im)^\s*(?:Асинх\s+)?(?:Процедура|Функция)\s+([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*\(([^)]*)\)") params: set[str] = set() for match in pattern.finditer(text or ""): if normalize(match.group(1)) != wanted: continue for raw in match.group(2).split(","): cleaned = re.sub(r"(?i)\b(Знач|Val)\b", "", raw).strip() cleaned = cleaned.split("=")[0].strip() if cleaned: params.add(cleaned) return params def bsl_symbol_is_full_metadata_path(parts: list[str]) -> bool: return len(parts) >= 2 and canonical_kind(parts[0]) in set(KIND_CAPABILITIES) def code_symbol_resolve(payload: dict[str, Any]) -> dict[str, Any]: method = "code.symbol.resolve" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error expression = str(first_non_empty_arg(payload, "expression", "symbol", "path", default="") or "").strip() parts = bsl_symbol_expression_parts(expression) result: dict[str, Any] = { "schema": "onec_bsl_symbol_resolution.v1", "method": method, "status": "unresolved", "base_id": base_id_or_error, "path_kind": "code_symbol", "query": { "expression": expression, "routine_name": payload.get("routine_name"), "module_ref": payload.get("module_ref"), "module_id": payload.get("module_id"), "kind": payload.get("kind"), "name": payload.get("name"), "ref": payload.get("ref"), }, "segments": parts, } if not parts: return invalid_argument(method, "expression", "expression must be a non-empty BSL expression.") if bsl_symbol_is_full_metadata_path(parts): origin = metadata_definition_find( { "base_id": base_id_or_error, "query": parts[-1], "kind": parts[0], "name": parts[1], "areas": ["metadata", "object", "extensions"], "use_cache": payload.get("use_cache", False), "timeout_seconds": payload.get("timeout_seconds", 30), } ) compact = metadata_write_plan_compact_origin_lookup(origin) if origin.get("status") == "ok" else origin canonical_path = ".".join(parts) return { **result, "status": "resolved" if origin.get("status") == "ok" and (origin.get("matches") or origin.get("object")) else "unresolved", "resolution_kind": "metadata_path", "path_kind": "metadata_path", "canonical_path": canonical_path, "safe_as_metadata_path": bool(origin.get("status") == "ok" and (origin.get("matches") or origin.get("object"))), "origin_lookup": compact, } module_result = read_module( { **payload, "base_id": base_id_or_error, "include_text": True, "max_chars": payload.get("max_chars", 200000), } ) result["module_read"] = { "status": module_result.get("status"), "schema": module_result.get("schema"), "owner": module_result.get("owner"), "module": module_result.get("module"), } text = str(module_result.get("text") or module_result.get("preview") or "") if module_result.get("status") not in {"ok", "partial"} or not text: result.update( { "reason": "module_context_not_read", "safe_as_metadata_path": False, "diagnostics": {"message": "Module text is required to distinguish local BSL symbols from metadata paths."}, } ) return result first = parts[0] params = bsl_routine_params(text, str(payload.get("routine_name") or "")) param = next((item for item in params if normalize(item) == normalize(first)), None) if param: result.update( { "status": "resolved", "resolution_kind": "parameter", "symbol": param, "context_path": ".".join([param, *parts[1:]]), "safe_as_metadata_path": False, } ) return result local = next((item for item in bsl_declared_symbols(text) if normalize(item) == normalize(first)), None) if local: result.update( { "status": "resolved", "resolution_kind": "local_variable", "symbol": local, "context_path": ".".join([local, *parts[1:]]), "safe_as_metadata_path": False, } ) return result owner = module_result.get("owner") if isinstance(module_result.get("owner"), dict) else {} owner_kind = payload.get("kind") or owner.get("kind") owner_name = payload.get("name") or owner.get("name") if owner_kind and owner_name: attrs = metadata_object_attributes( { "base_id": base_id_or_error, "kind": owner_kind, "name": owner_name, "view": payload.get("view", "effective"), "limit": 5000, } ) members: list[dict[str, Any]] = [] for area_name in ("attributes", "dimensions", "resources", "tabular_sections", "forms", "commands", "modules"): for item in attrs.get(area_name) or []: if isinstance(item, dict): members.append({**item, "area": area_name}) standard = next((item for item in STANDARD_OBJECT_MEMBERS.get(str(canonical_kind(str(owner_kind or "")) or ""), set()) if normalize(item) == normalize(first)), None) if standard: object_path = metadata_write_plan_path_parts(f"{owner_kind}.{owner_name}").get("canonical_path") or f"{owner_kind}.{owner_name}" result.update( { "status": "resolved", "resolution_kind": "context_metadata_member", "path_kind": "metadata_member", "area": "standard_attribute", "canonical_path": ".".join([object_path, standard, *parts[1:]]), "context_path": ".".join([standard, *parts[1:]]), "match": {"area": "standard_attribute", "name": standard, "standard": True}, "safe_as_metadata_path": True, } ) return result member = next((item for item in members if normalize(item.get("name")) == normalize(first) or normalize(item.get("synonym")) == normalize(first)), None) if member: object_path = metadata_write_plan_path_parts(f"{owner_kind}.{owner_name}").get("canonical_path") or f"{owner_kind}.{owner_name}" result.update( { "status": "resolved", "resolution_kind": "context_metadata_member", "path_kind": "metadata_member", "area": member.get("area"), "canonical_path": ".".join([object_path, str(member.get("name") or first), *parts[1:]]), "context_path": ".".join([str(member.get("name") or first), *parts[1:]]), "match": {key: member.get(key) for key in ("area", "name", "synonym", "type", "types") if member.get(key) not in (None, "", [])}, "safe_as_metadata_path": True, } ) return result candidates = metadata_definition_find( { "base_id": base_id_or_error, "query": first, "areas": ["metadata", "extensions"], "use_cache": payload.get("use_cache", False), "timeout_seconds": payload.get("timeout_seconds", 30), } ) result.update( { "reason": "not_a_confirmed_metadata_path_or_local_symbol", "safe_as_metadata_path": False, "candidates": [ { "canonical_path": item.get("canonical_path"), "kind": item.get("kind"), "name": item.get("name"), "reason": "short_object_name_requires_kind", } for item in (candidates.get("matches") or []) if isinstance(item, dict) ][:10], } ) return result def search_modules(payload: dict[str, Any]) -> dict[str, Any]: normalized_payload = normalize_object_selector_aliases(payload, "modules.search") if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload base_id_or_error = require_base_id(payload, "modules.search") if isinstance(base_id_or_error, dict): return base_id_or_error query_value = payload.get("query") if query_value is not None and not isinstance(query_value, str): return invalid_argument("modules.search", "query", "query must be a JSON string.") query = str(query_value or "").strip() include_storage, include_storage_error = strict_include_storage(payload, "modules.search") if include_storage_error: return include_storage_error include_storage = bool(include_storage) resolve_owners, resolve_owners_error = strict_bool_argument(payload, "resolve_owners", method="modules.search", default=False) if resolve_owners_error: return resolve_owners_error resolve_owners = bool(resolve_owners) full_scan_value, full_scan_error = strict_bool_argument(payload, "full_scan", method="modules.search", default=False) if full_scan_error: return full_scan_error full_scan = bool(full_scan_value) if not query: return invalid_argument("modules.search", "query", "Передайте непустой query.") query_cf = query.casefold() table = str(payload.get("table") or "auto") prefix = str(payload.get("prefix") or "") state = str(payload.get("state") or "working").strip().lower() if state not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument("modules.search", "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) string_error = validate_optional_string_arguments( payload, "modules.search", ["ref", "object_type", "object_name", "object_guid", "table", "prefix", "scope", "extension", "extension_guid", "routine_name", "state"], ) if string_error: return string_error saved_extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(saved_extension_guid): return invalid_argument("modules.search", "extension_guid", "extension_guid must be a GUID string.") if table != "auto" and table not in STORAGE_TABLES: return invalid_argument("modules.search", "table", "Unsupported storage table.", allowed_values=["auto", *sorted(STORAGE_TABLES)]) table_for_read = table if table in STORAGE_TABLES else "Config" scope = str(payload.get("scope") or "auto").strip().casefold() scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method="modules.search", default=300, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method="modules.search", default=20, minimum=1, maximum=100) if limit_error: return limit_error owner_scan_limit, owner_scan_limit_error = parse_int_argument(payload, "owner_scan_limit", method="modules.search", default=40, minimum=1, maximum=200) if owner_scan_limit_error: return owner_scan_limit_error extension_route_probe_limit, extension_route_probe_limit_error = parse_int_argument( payload, "extension_route_probe_limit", method="modules.search", default=8, minimum=1, maximum=100, ) if extension_route_probe_limit_error: return extension_route_probe_limit_error read_max_chars, read_max_chars_error = parse_int_argument(payload, "read_max_chars", method="modules.search", default=4000, minimum=1, maximum=100000) if read_max_chars_error: return read_max_chars_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="modules.search", default=60, minimum=1) if timeout_error: return timeout_error # `timeout_seconds` is a budget for the complete public search, not a # fresh allowance for every owner/module probe. In particular, an # extension-wide search can otherwise make several sequential 60-second # DB calls before it even starts the text scan. search_started = time.monotonic() search_deadline = search_started + int(timeout_seconds or 60) def remaining_timeout_seconds() -> int: return max(0, int(search_deadline - time.monotonic())) extension_filter = str(payload.get("extension") or "").strip() extension_guid: str | None = None extension_owner_objects: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] extension_target_module_refs: set[str] = set() extension_target_files_by_table: dict[str, set[str]] = {} extension_owner_guids: list[str] = [] extension_active_object_guids: set[str] = set() extension_route_fallback_scan = False extension_fast_route_unresolved = False extension_route_fallback_diagnostics: list[dict[str, Any]] = [] routine_name = str(payload.get("routine_name") or "").strip() routine_name_cf = normalize(routine_name) def deadline_result(stage: str) -> dict[str, Any]: return { "schema": "onec_modules_search.v1", "status": "partial", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": { "query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage, }, "matches": [], "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": True, "complete": False, "time_budget_exhausted": True}, "diagnostics": { "code": "time_budget_exhausted", "stage": stage, "elapsed_seconds": round(time.monotonic() - search_started, 3), "timeout_seconds": int(timeout_seconds or 60), "message": "Module search stopped at its total time budget; narrow it with an object ref/kind/name or run it as a background job.", }, } object_ordinal_selector = first_non_empty_arg(payload, "ordinal", "index", "object_index") has_object_selector = bool(payload.get("guid") or payload.get("name") or (object_ordinal_selector is not None)) if extension_filter and has_object_selector: extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="modules.search") if extension_error: return extension_error payload = {**payload, "extension_guid": extension_guid} if table == "auto": table_for_read = "ConfigCAS" if state == "active" else "ConfigCASSave" if extension_filter and not has_object_selector: extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="modules.search") if extension_error: return extension_error # Reuse only prior exact owner/module routes for this extension. The # BSL bytes are still read from current SQL below; the cache merely # avoids enumerating every manifest descriptor before that read. route_cache_config, _ = sql_config_for_base(base_id_or_error) if route_cache_config: try: with cache_connection() as conn: cached_routes = conn.execute( """ SELECT module_ref, module_payload_json FROM metadata_module_owner_cache WHERE server_key=? AND database_name=? AND module_table='ConfigCAS' """, (cache_server_key(route_cache_config), cache_database_name(route_cache_config)), ).fetchall() for cached_route in cached_routes: try: module_payload = json.loads(cached_route["module_payload_json"] or "{}") except Exception: module_payload = {} cached_extension = module_payload.get("extension") if isinstance(module_payload, dict) else {} if str((cached_extension or {}).get("guid") or "").strip().lower() != extension_guid: continue cached_ref = str(cached_route["module_ref"] or "").strip() table_name, file_name, _ = parse_module_id(cached_ref) if table_name == "ConfigCAS" and file_name: extension_target_module_refs.add(cached_ref) extension_target_files_by_table.setdefault(table_name, set()).add(file_name) except Exception: pass remaining_timeout = remaining_timeout_seconds() if remaining_timeout <= 0: return deadline_result("extension_definition_guid_sources") guid_sources, guid_error = extension_definition_guid_sources(base_id_or_error, timeout_seconds=remaining_timeout) if guid_error: guid_error["method"] = "modules.search" return guid_error for definition_guid, sources in guid_sources.items(): for source in sources or []: source_extension = source.get("extension") or {} source_guid = str(source_extension.get("guid") or "").strip().lower() source_role = str(source.get("storage_role") or "") if source_guid == extension_guid and source_role in DBNAMES_ROLE_KIND: extension_owner_guids.append(definition_guid) break extension_owner_guids = sorted(set(guid for guid in extension_owner_guids if is_guid_text(guid))) remaining_timeout = remaining_timeout_seconds() if remaining_timeout <= 0: return deadline_result("extension_objects_find") active_objects_result = extension_objects_find( { "base_id": base_id_or_error, "extension": extension_guid, "state": "active", "limit": 500, "include_storage": False, "use_cache": True, "timeout_seconds": remaining_timeout, } ) if active_objects_result.get("status") == "ok": extension_active_object_guids = { str(item.get("guid") or "").strip().lower() for item in active_objects_result.get("objects") or [] if isinstance(item, dict) and str(item.get("guid") or "").strip() } extension_owner_guids_to_probe = extension_owner_guids if not full_scan: extension_owner_guids_to_probe = extension_owner_guids[:int(extension_route_probe_limit)] if len(extension_owner_guids) > len(extension_owner_guids_to_probe): extension_route_fallback_diagnostics.append( { "code": "extension_owner_route_probe_limited", "probed": len(extension_owner_guids_to_probe), "available": len(extension_owner_guids), "message": "Fast extension search probes a bounded number of proven owner routes. Pass full_scan=true only for explicit deep discovery.", } ) for owner_guid in ([] if extension_target_module_refs else extension_owner_guids_to_probe): remaining_timeout = remaining_timeout_seconds() if remaining_timeout <= 0: extension_route_fallback_diagnostics.append( {"code": "time_budget_exhausted", "stage": "extension_owner_module_routes", "resolved_owners": len(extension_owner_objects)} ) return deadline_result("extension_owner_module_routes") if table_for_read in {"ConfigSave", "ConfigCASSave"}: extension_owner_table = "ConfigCASSave" elif state in {"working", "save"}: extension_owner_table = "ConfigCASSave" else: extension_owner_table = "ConfigCAS" modules_result = metadata_object_modules( { "base_id": base_id_or_error, "guid": owner_guid, "table": extension_owner_table, "include_storage": True, "timeout_seconds": min(5, remaining_timeout), } ) if modules_result.get("status") != "ok": continue modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] if not modules: continue extension_owner_objects.append((modules_result.get("object") or {}, modules)) for module in modules: module_id = str(module.get("module_id") or "").strip() if not module_id: continue extension_target_module_refs.add(module_id) table_name, file_name, _ = parse_module_id(module_id) if table_name and file_name: extension_target_files_by_table.setdefault(table_name, set()).add(file_name) if not extension_target_module_refs and full_scan: extension_route_fallback_scan = True remaining_timeout = remaining_timeout_seconds() if remaining_timeout <= 0: return deadline_result("extension_manifest_fallback") manifests, manifest_diagnostics = live_extension_manifests( base_id_or_error, extension_guid=extension_guid, timeout_seconds=remaining_timeout, ) manifest_keys = { str(entry.get("cas_key") or "").strip().lower() for manifest in manifests for entry in (manifest.get("entries") or []) if isinstance(entry, dict) and str(entry.get("cas_key") or "").strip() } if manifest_keys: extension_target_files_by_table["ConfigCAS"] = manifest_keys extension_route_fallback_diagnostics.append( { "code": "extension_module_owner_routes_not_found", "message": f"В расширении `{extension_filter}` модульные записи не найдены через DBNames owner routes; выполняется fallback scan по manifest ConfigCAS.", "manifest_entries": len(manifest_keys), "manifest_diagnostics": manifest_diagnostics, } ) elif not extension_target_module_refs: extension_fast_route_unresolved = True extension_route_fallback_diagnostics.append( { "code": "extension_deep_scan_not_requested", "message": "No exact extension module route was found in the fast bounded pass. Pass full_scan=true for a potentially slow ConfigCAS discovery scan, or warm extension/code caches first.", } ) cache_config, _ = sql_config_for_base(base_id_or_error) def extract_routine_text(text_value: str, wanted_routine_cf: str) -> tuple[str, int]: source_text = str(text_value or "") if not wanted_routine_cf: return source_text, 0 try: from parser.bsl_validation import routine_blocks routines = list(routine_blocks(source_text)) except Exception: return "", 0 lines = source_text.split("\n") for routine in routines: routine_name_from_source = str(routine.get("name") or "") if normalize(routine_name_from_source) != wanted_routine_cf: continue start_line = int(routine.get("line_start") or 0) end_line = int(routine.get("line_end") or 0) if not start_line or not end_line: continue if start_line < 1: start_line = 1 if end_line < start_line: continue if start_line > len(lines): continue end_line = min(end_line, len(lines)) prefix_len = sum(len(line) + 1 for line in lines[: start_line - 1]) return "\n".join(lines[start_line - 1 : end_line]), prefix_len return "", 0 def routine_name_for_match(text_value: str, snippet: dict[str, Any]) -> str | None: """Return a routine only when the search offset falls in its proven block.""" offset_value = snippet.get("offset") if isinstance(snippet, dict) else None if offset_value is None: return None try: from parser.bsl_validation import routine_blocks routines = list(routine_blocks(str(text_value or ""))) except Exception: return None lines = str(text_value or "").split("\n") absolute_offset = int(offset_value) for routine in routines: start_line = int(routine.get("line_start") or 0) end_line = int(routine.get("line_end") or 0) if start_line < 1 or end_line < start_line: continue start_offset = sum(len(line) + 1 for line in lines[: start_line - 1]) end_offset = sum(len(line) + 1 for line in lines[:end_line]) if start_offset <= absolute_offset < end_offset: return str(routine.get("name") or "").strip() or None return None if has_object_selector: modules_result = metadata_object_modules({**payload, "include_storage": True, "table": table_for_read}) if modules_result.get("status") != "ok": return public_error_result(modules_result, include_storage=include_storage, method="modules.search") object_info = modules_result.get("object") or {} object_origin = object_info.get("origin") if isinstance(object_info.get("origin"), dict) else {} object_extension = object_origin.get("extension") if isinstance(object_origin.get("extension"), dict) else {} if not extension_guid and is_guid_text(str(object_extension.get("guid") or "")): extension_guid = str(object_extension.get("guid") or "").lower() if not extension_filter and object_extension.get("name"): extension_filter = str(object_extension.get("name") or "") public_owner = { "status": "resolved", "kind": object_info.get("kind") or canonical_kind(str(payload.get("kind") or "")), "name": object_info.get("name") or payload.get("name"), "synonym": object_info.get("synonym"), "guid": object_info.get("guid") or payload.get("guid"), } modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] object_storage = object_info.get("storage") if isinstance(object_info.get("storage"), dict) else {} forms_result = ( {"status": "ok", "forms": []} if any(isinstance(module.get("form"), dict) for module in modules) else metadata_object_forms( { **payload, "base_id": base_id_or_error, "kind": public_owner.get("kind"), "name": public_owner.get("name"), "guid": public_owner.get("guid"), "table": table_for_read, **({"file_name": object_storage.get("file_name")} if object_storage.get("file_name") else {}), "include_storage": True, "timeout_seconds": int(timeout_seconds or 60), } ) ) known_module_refs = {str(module.get("module_id") or "") for module in modules} for form in forms_result.get("forms") or []: if not isinstance(form, dict): continue form_source = form.get("source") if isinstance(form.get("source"), dict) else {} form_table = str(form_source.get("table") or table_for_read) form_file_name = str(form_source.get("file_name") or "") if form_table not in STORAGE_TABLES or not form_file_name: continue form_module_ref = f"{form_table}:{form_file_name}#form_module" if form_module_ref in known_module_refs: continue form_payload = { "guid": form.get("guid"), "name": form.get("name"), "synonym": next(iter((form.get("synonyms") or {}).values()), None) if isinstance(form.get("synonyms"), dict) else None, } modules.append( { "module_id": form_module_ref, "table": form_table, "file_name": form_file_name, "kind": "form_module", "name": "Модуль формы", "form": form_payload, "payload_role": "form_payload", } ) known_module_refs.add(form_module_ref) metadata_form_owner_cache_upsert( cache_config, base_id=base_id_or_error, owner_kind=public_owner.get("kind"), owner_name=public_owner.get("name"), owner_guid=public_owner.get("guid"), form_name=form_payload.get("name"), form_guid=form_payload.get("guid"), table=form_table, file_name=form_file_name, extension={ "guid": extension_guid, **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), } if extension_guid else None, ) requested_module_ordinal = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") module_rows: list[tuple[int, dict[str, Any]]] = list(enumerate(modules, start=1)) command_module_rows: list[dict[str, Any]] = [] if requested_module_ordinal in {None, ""}: commands_result = metadata_object_commands( { **payload, "base_id": base_id_or_error, "guid": public_owner.get("guid"), "kind": public_owner.get("kind"), "table": table_for_read, "include_form_commands": False, "include_storage": False, "timeout_seconds": int(timeout_seconds or 60), } ) if commands_result.get("status") == "ok": command_module_rows = [ command for command in commands_result.get("object_commands") or [] if isinstance(command, dict) and isinstance(command.get("read_selector"), dict) ] if requested_module_ordinal not in {None, ""}: module_ordinal, ordinal_error = parse_ordinal(requested_module_ordinal, "modules.search", argument="module_ordinal") if ordinal_error: return ordinal_error requested_index = int(module_ordinal or 1) - 1 if not (0 <= requested_index < len(modules)): return { "schema": "onec_modules_search.v1", "status": "not_found", "error": "module_not_found", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "owner": public_owner, "query": { "query": query, "limit": limit, "max_matches": limit, "scope": "object_modules", "kind": payload.get("kind"), "name": payload.get("name"), "guid": payload.get("guid"), "module_ordinal": requested_module_ordinal, "extension": extension_filter or None, "routine_name": routine_name or None, "include_storage": include_storage, }, "matches": [], "counts": {"matches": 0, "available_modules": len(modules)}, "diagnostics": {"message": f"Module ordinal {module_ordinal} was not found for the selected object."}, } module_rows = [(int(module_ordinal or 1), modules[requested_index])] state_module_rows: list[tuple[int, dict[str, Any]]] = [] for ordinal, module in module_rows: module_id = str(module.get("module_id") or "").strip() module_table, _module_file_name, _module_stream_index = parse_module_id(module_id) candidate_refs: list[str] = [] if module_table in {"Config", "ConfigCAS"}: saved_ref = code_read_saved_counterpart_module_ref(base_id_or_error, module_id, payload) if state == "active": candidate_refs = [module_id] elif state == "save": candidate_refs = [saved_ref] if saved_ref else [] elif state == "both": candidate_refs = [ref for ref in (saved_ref, module_id) if ref] else: # The Designer working state prefers an existing saved # module, but falls back to the active module when the # saved layer is absent or does not contain the query. candidate_refs = [ref for ref in (saved_ref, module_id) if ref] elif module_table in {"ConfigSave", "ConfigCASSave"}: active_ref = code_read_active_counterpart_module_ref(module_id) if state == "active": candidate_refs = [active_ref] if active_ref else [] elif state == "save": candidate_refs = [module_id] elif state == "both": candidate_refs = [ref for ref in (module_id, active_ref) if ref] else: candidate_refs = [ref for ref in (module_id, active_ref) if ref] else: candidate_refs = [module_id] seen_candidate_refs: set[str] = set() for candidate_ref in candidate_refs: if not candidate_ref or candidate_ref in seen_candidate_refs: continue seen_candidate_refs.add(candidate_ref) candidate_table, candidate_file_name, candidate_stream_index = parse_module_id(candidate_ref) state_module_rows.append( ( ordinal, { **module, "module_id": candidate_ref, "table": candidate_table, "file_name": candidate_file_name, **({"stream_index": candidate_stream_index} if candidate_stream_index is not None else {}), }, ) ) module_rows = state_module_rows matches = [] matched_working_ordinals: set[int] = set() for ordinal, module in module_rows: if state != "both" and ordinal in matched_working_ordinals: continue module_id = str(module.get("module_id") or "") table_name, file_name, stream_index = parse_module_id(module_id) if not table_name or not file_name: continue data, config, error = read_storage_file_bytes(base_id_or_error, table_name, file_name, timeout_seconds=int(timeout_seconds or 60)) if error: continue if stream_index is not None: try: from parser.cas_payload import classify_payload classified = classify_payload(data, include_text=True) except Exception: classified = {} streams = classified.get("stream_blocks") or [] text = repair_bsl_mojibake_text(str((streams[stream_index] if 0 <= stream_index < len(streams) else {}).get("text") or "")) text = form_embedded_module_public_text(text) else: decoded = payload_text_from_bytes(data) container_text = str(decoded.get("text") or "") bsl_offset = module.get("bsl_offset") text, _ = extract_bsl_text_from_container(container_text, bsl_offset=int(bsl_offset) if bsl_offset not in {None, ""} else None) text = form_embedded_module_public_text(str(text or "")) routine_text, routine_offset = ("", 0) if routine_name_cf: routine_text, routine_offset = extract_routine_text(text, routine_name_cf) if not routine_text: routine_text = str(text or "") if routine_name_cf and not routine_text: continue if query_cf not in routine_text.casefold(): continue form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} public_module = public_module_with_qualified_name(module, owner=public_owner, include_storage=False, ordinal=ordinal, owner_kind=public_owner.get("kind")) if form_payload: form_name = str(form_payload.get("name") or "") qualified_form_module_name = ".".join( part for part in [public_owner.get("name"), "Форма", form_name, "Модуль формы"] if part ) public_module = { **public_module, "kind": "form_module", "name": "Модуль формы", "form": form_name or None, "qualified_name": qualified_form_module_name, "display_name": qualified_form_module_name, } snippet = text_snippet(routine_text, query) if routine_name_cf and routine_offset: snippet["offset"] = (snippet.get("offset") or 0) + routine_offset if snippet.get("offset") is not None else snippet["offset"] if form_payload: read_selector = enrich_selector_with_object_ref( { "base_id": base_id_or_error, "method": "modules.read", "kind": public_owner.get("kind"), "guid": public_owner.get("guid"), "name": public_owner.get("name"), "form": form_payload.get("name"), "module_ref": module_id, "preview": True, "max_chars": int(read_max_chars or 4000), }, public_owner, ) else: read_selector = enrich_selector_with_object_ref( { "base_id": base_id_or_error, "method": "modules.read", "kind": public_owner.get("kind"), "guid": public_owner.get("guid"), "module_ordinal": ordinal, "preview": True, "max_chars": int(read_max_chars or 4000), }, public_owner, ) match = { "score": 1.0, "snippet": snippet, "owner": public_owner, "module": { "kind": public_module.get("kind"), "name": public_module.get("name"), "module_ordinal": ordinal, "form": form_payload.get("name") if form_payload else None, **({"role_status": public_module.get("role_status")} if public_module.get("role_status") else {}), **({"configurator_path_status": public_module.get("configurator_path_status")} if public_module.get("configurator_path_status") else {}), }, **({"qualified_name": public_module.get("qualified_name")} if public_module.get("qualified_name") else {}), **({"display_name": public_module.get("display_name")} if public_module.get("display_name") else {}), "read_selector": read_selector, "origin": ( { "source": "extension", "presentation": "Расширение", "extension": { "guid": extension_guid, **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), }, "status": "ok", } if extension_guid else module_origin_from_storage_table(table_name) ), } if table_name in {"ConfigSave", "ConfigCASSave"}: match["activation_state"] = "saved_state" match["current_state"] = {"source": "saved_state", "activation_state": "not_activated"} else: match["activation_state"] = "active" match["current_state"] = {"source": "active", "activation_state": "active"} if include_storage: match.update({"module_id": module_id, "table": table_name, "file_name": file_name, **({"stream_index": stream_index} if stream_index is not None else {})}) if routine_name_cf: if include_storage and routine_offset: match["read_selector"]["bsl_offset"] = routine_offset match["module"]["routine_name"] = routine_name match["query"] = {"routine_name": routine_name} matches.append(match) matched_working_ordinals.add(ordinal) if len(matches) >= limit: break if len(matches) < limit: for command in command_module_rows: command_selector = dict(command.get("read_selector") or {}) command_selector.pop("method", None) module_result = read_module( { **command_selector, "include_storage": False, "max_chars": int(read_max_chars or 4000), "timeout_seconds": int(timeout_seconds or 60), } ) if module_result.get("status") != "ok": continue text = str(module_result.get("text") or "") routine_text, routine_offset = ("", 0) if routine_name_cf: routine_text, routine_offset = extract_routine_text(text, routine_name_cf) if not routine_text: routine_text = text if routine_name_cf and not routine_text: continue if query_cf not in routine_text.casefold(): continue snippet = text_snippet(routine_text, query) if routine_name_cf and routine_offset and snippet.get("offset") is not None: snippet["offset"] = int(snippet.get("offset") or 0) + routine_offset command_name = str(command.get("name") or command.get("synonym") or "") qualified_name = ".".join( part for part in [public_owner.get("name"), "Команда", command_name, "Модуль команды"] if part ) read_selector = { **(command.get("read_selector") or {}), "preview": True, "max_chars": int(read_max_chars or 4000), } module_ref = str(read_selector.get("module_ref") or "") command_table, _, _ = parse_module_id(module_ref) match = { "score": 1.0, "snippet": snippet, "owner": public_owner, "module": { "kind": "command_module", "name": "Модуль команды", "command": command_name, }, "qualified_name": qualified_name, "display_name": qualified_name, "read_selector": read_selector, "origin": module_origin_from_storage_table(command_table or table_for_read), } if command_table in {"ConfigSave", "ConfigCASSave"}: match["activation_state"] = "saved_state" if routine_name_cf: match["module"]["routine_name"] = routine_name match["query"] = {"routine_name": routine_name} matches.append(match) if len(matches) >= limit: break return { "schema": "onec_modules_search.v1", "status": "ok", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": { "query": query, "limit": limit, "max_matches": limit, "scope": "object_modules", "kind": payload.get("kind"), "name": payload.get("name"), "guid": payload.get("guid"), "module_ordinal": requested_module_ordinal, "extension": extension_filter or None, "routine_name": routine_name or None, "include_storage": include_storage, }, "matches": matches, "counts": { "matches": len(matches), "scanned_modules": len(module_rows) + len(command_module_rows), "available_modules": len(modules) + len(command_module_rows), "owner_modules": len(modules), "command_modules": len(command_module_rows), "complete": True, "scan_limit_hit": False, "owner_resolved": len(matches), "owner_unresolved": 0, "owner_scan_limit_hit": False, "owner_indexed_module_refs": len(matches), }, } if extension_fast_route_unresolved: return { "schema": "onec_modules_search.v1", "status": "partial", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": { "query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "prefix": prefix, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage, }, "matches": [], "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": True, "complete": False}, "diagnostics": {"code": "extension_deep_scan_not_requested", "details": extension_route_fallback_diagnostics}, } scan_budget = int(scan_limit or 300) if table == "auto": if scope == "all": tables_to_scan = ["ConfigCASSave", "ConfigSave", "ConfigCAS", "Config"] if state in {"working", "save", "both"} else ["ConfigCAS", "Config", "ConfigCASSave", "ConfigSave"] elif scope == "config": tables_to_scan = ["ConfigSave", "Config"] if state in {"working", "save", "both"} else ["Config", "ConfigSave"] elif scope in {"configcas", "modules"}: tables_to_scan = ["ConfigCASSave", "ConfigCAS"] if state in {"working", "save", "both"} else ["ConfigCAS", "ConfigCASSave"] else: tables_to_scan = ["ConfigCASSave", "ConfigSave", "ConfigCAS", "Config"] if state in {"working", "save", "both"} else ["ConfigCAS", "Config", "ConfigCASSave", "ConfigSave"] if state == "save": tables_to_scan = [item for item in tables_to_scan if item in {"ConfigCASSave", "ConfigSave"}] elif state == "active": tables_to_scan = [item for item in tables_to_scan if item in {"ConfigCAS", "Config"}] else: tables_to_scan = [table] if extension_route_fallback_scan: tables_to_scan = [item for item in tables_to_scan if item in {"ConfigCAS", "ConfigCASSave"}] or ["ConfigCAS", "ConfigCASSave"] if extension_target_files_by_table: tables_to_scan = [item for item in tables_to_scan if item in extension_target_files_by_table] if not tables_to_scan: return { "schema": "onec_modules_search.v1", "status": "not_found", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": { "query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "prefix": prefix, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage, }, "matches": [], "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": False}, "diagnostics": {"message": f"По расширению `{extension_filter}` не найдено целевых модульных файлов в выбранных таблицах."}, } scanned_files_total = 0 scanned_tables: list[str] = [] all_payloads: dict[str, tuple[dict[str, bytes], dict[str, Any] | None]] = {} matches = [] # The same physical file key can appear in both scanned layers in tests or # after a platform copy. Keep the first (higher-priority) layer rather # than returning duplicate logical code hits. matched_logical_streams: set[str] = set() skipped_active_extension_fallback = False for table_index, candidate_table in enumerate(tables_to_scan): remaining_budget = max(0, scan_budget - scanned_files_total) if remaining_budget <= 0: break # In automatic cross-layer search a single table must not consume the # entire scan budget before base Config or extension ConfigCAS is even # considered. Explicit scopes keep their full budget. remaining_tables = max(1, len(tables_to_scan) - table_index) table_budget = remaining_budget if scope != "auto" or table != "auto" else max(1, remaining_budget // remaining_tables) files_payload = {"base_id": base_id_or_error, "table": candidate_table, "limit": table_budget, "_internal": True} effective_prefix = prefix if not effective_prefix and extension_filter and extension_guid and candidate_table in {"ConfigCAS", "ConfigCASSave"}: effective_prefix = f"{extension_guid}__" if effective_prefix: files_payload["prefix"] = effective_prefix exact_extension_files = ( candidate_table in {"ConfigCAS", "ConfigCASSave"} and bool(extension_target_files_by_table.get(candidate_table)) ) if exact_extension_files: file_names = sorted(extension_target_files_by_table.get(candidate_table) or set())[:remaining_budget] candidate_files = {"status": "ok", "files": [{"FileName": name} for name in file_names], "truncated": False} else: candidate_files = storage_files_list(files_payload) if candidate_files.get("status") != "ok": result = dict(candidate_files) result["method"] = "modules.search" return result file_names = [str(row.get("FileName") or "") for row in candidate_files.get("files") or []] if extension_target_files_by_table: file_names = [name for name in file_names if name in extension_target_files_by_table.get(candidate_table, set())] if not file_names: continue if not file_names: if table == "auto" and scope in {"auto", "modules", "configcas"} and candidate_table in {"ConfigCAS", "ConfigCASSave"}: continue if table != "auto" or scope in {"config", "configcas"} or scope == "all": continue scanned_files_total += len(file_names) scanned_tables.append(candidate_table) payloads, config, error = read_storage_files_bytes(base_id_or_error, candidate_table, file_names, timeout_seconds=int(timeout_seconds or 60)) if error: error["method"] = "modules.search" return error all_payloads[candidate_table] = (payloads, config) if table == "auto" and scope in {"modules", "configcas"} and state != "both": break if table == "auto" and scope == "all" and candidate_files.get("truncated"): break if not scanned_tables: return { "schema": "onec_modules_search.v1", "status": "ok", "base_id": base_id_or_error, "source": {"kind": "live_sql", "database": None, "tables": scanned_tables or ([table] if table != "auto" else [])}, "query": { "query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "prefix": prefix, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage, }, "matches": [], "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": False}, "diagnostics": {"message": "Файлы для поиска не найдены в выбранных таблицах."}, } owner_index: dict[str, dict[str, Any]] = {} cache_owner_enabled = False if resolve_owners and cache_config: with cache_connection() as conn: cache_owner_enabled = bool(conn.execute("SELECT 1 FROM metadata_module_owner_cache WHERE server_key=? AND database_name=? LIMIT 1", (cache_server_key(cache_config), cache_database_name(cache_config))).fetchone()) owner_resolution = { "requested": bool(resolve_owners), "strategy": "disabled", "cache_available": bool(cache_owner_enabled), "owner_scan_limit": int(owner_scan_limit or 40), "owner_objects_scanned": 0, "owner_objects_remaining": int(owner_scan_limit or 40), "owner_scan_limit_hit": False, "indexed_module_refs": 0, "source": None, } def metadata_owner_cache_lookup(module_id: str) -> dict[str, Any] | None: # The first resolved ConfigCAS hit may be the row that warms this # cache. Do not make lookup conditional on a pre-existing row: that # turned a successful per-module resolver invocation into an # owner_unresolved response for every first extension search. cached = metadata_module_owner_cache_lookup(cache_config, module_id) if not cached: return None owner_payload = cached.get("owner") or {} module_payload = cached.get("module_payload") or {} extension_payload = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} # An extension descriptor can prove the exact owner GUID and name # before its metadata kind codec is available. Preserve that proven # ownership instead of degrading it to owner_unresolved; the missing # kind remains explicit and prevents a name-first write target. if not isinstance(owner_payload, dict) or not owner_payload.get("name") or not is_guid_text(owner_payload.get("guid") or ""): return None cached_table, _cached_file_name, _cached_stream_index = parse_module_id(module_id) if cached_table == "ConfigCAS" and not owner_payload.get("kind"): # A pre-existing partial extension cache row must be upgraded from # its current manifest descriptor before it becomes a search hit. return None result = { "owner": { "status": "resolved", "kind": owner_payload.get("kind"), "name": owner_payload.get("name"), "synonym": owner_payload.get("synonym"), "guid": owner_payload.get("guid"), }, "module": { "name": module_payload.get("module_name"), "module_ordinal": module_payload.get("module_ordinal"), "form": None, }, "module_payload": module_payload, "read_selector": { "base_id": base_id_or_error, "method": "modules.read", "kind": owner_payload.get("kind"), "guid": owner_payload.get("guid"), "preview": True, "max_chars": int(read_max_chars or 4000), }, } if extension_payload.get("guid") or extension_payload.get("name"): result["origin"] = { "source": "extension", "presentation": "Расширение", "extension": extension_payload, "status": "ok" if extension_payload.get("name") else "extension_unresolved", } return result def build_owner_index() -> None: if not resolve_owners or cache_owner_enabled: if cache_owner_enabled: owner_resolution["strategy"] = "metadata_module_owner_cache" owner_resolution["source"] = "metadata.module_owner_cache" return if extension_owner_objects: owner_resolution["strategy"] = "extension_owner_objects" owner_resolution["source"] = "extension_filter" owners = extension_owner_objects for owner_info, modules in owners: owner_resolution["owner_objects_scanned"] = int(owner_resolution["owner_objects_scanned"] or 0) + 1 owner_payload = { "kind": owner_info.get("kind"), "name": owner_info.get("name"), "synonym": owner_info.get("synonym"), "guid": owner_info.get("guid"), } for ordinal, module in enumerate(modules, start=1): module_id = str(module.get("module_id") or "") if not module_id: continue owner_resolution["indexed_module_refs"] = int(owner_resolution["indexed_module_refs"] or 0) + 1 owner_index[module_id] = { "owner": { "status": "resolved", **owner_payload, }, "module": { "name": public_module_row( module, include_storage=False, ordinal=ordinal, owner_kind=owner_payload.get("kind"), owner_name=owner_payload.get("name"), ).get("name"), "module_ordinal": ordinal, "form": None, }, "read_selector": enrich_selector_with_object_ref( { "base_id": base_id_or_error, "method": "modules.read", "kind": owner_payload.get("kind"), "guid": owner_payload.get("guid"), "module_ordinal": ordinal, "preview": True, "max_chars": int(read_max_chars or 4000), }, owner_payload, ), } return owner_resolution["strategy"] = "live_metadata_scan" owner_resolution["source"] = "metadata.objects.list+metadata.object.modules" kinds = ( [canonical_kind(str(payload.get("kind")))] if payload.get("kind") else [kind for kind in sorted(KIND_CAPABILITIES) if "modules" in KIND_CAPABILITIES.get(kind, [])] ) remaining_owner_objects = int(owner_scan_limit or 40) for owner_kind in [kind for kind in kinds if kind]: if remaining_owner_objects <= 0: break listed = list_objects( owner_kind, base_id=base_id_or_error, limit=remaining_owner_objects, offset=0, include_storage=False, table=table_for_read, ) if listed.get("status") != "ok": continue objects = listed.get("objects") or [] remaining_owner_objects -= len(objects) owner_resolution["owner_objects_scanned"] = int(owner_resolution["owner_objects_scanned"] or 0) + len(objects) owner_resolution["owner_objects_remaining"] = max(0, remaining_owner_objects) for obj in objects: selector = {"base_id": base_id_or_error, "kind": obj.get("kind"), "guid": obj.get("guid"), "timeout_seconds": int(timeout_seconds or 60)} modules_result = metadata_object_modules({**selector, "include_storage": True, "table": table_for_read}) if modules_result.get("status") != "ok": continue for ordinal, module in enumerate(modules_result.get("modules") or [], start=1): module_id = str(module.get("module_id") or "") if not module_id: continue owner_resolution["indexed_module_refs"] = int(owner_resolution["indexed_module_refs"] or 0) + 1 owner_index[module_id] = { "owner": { "status": "resolved", "kind": obj.get("kind"), "name": obj.get("name"), "synonym": obj.get("synonym"), "guid": obj.get("guid"), }, "module": { "name": public_module_row( module, include_storage=False, ordinal=ordinal, owner_kind=obj.get("kind"), owner_name=obj.get("name"), ).get("name"), "module_ordinal": ordinal, "form": None, }, "read_selector": enrich_selector_with_object_ref( { "base_id": base_id_or_error, "method": "modules.read", "kind": obj.get("kind"), "guid": obj.get("guid"), "module_ordinal": ordinal, "preview": True, "max_chars": int(read_max_chars or 4000), }, obj, ), } owner_resolution["owner_scan_limit_hit"] = remaining_owner_objects <= 0 owner_resolution["owner_objects_remaining"] = max(0, remaining_owner_objects) def enrich_match(match: dict[str, Any], module_id: str) -> dict[str, Any]: enrichment = owner_index.get(module_id) if not enrichment: return match enriched = dict(match) enriched["owner"] = enrichment["owner"] enriched["module"] = {**(match.get("module") or {}), **enrichment["module"]} enriched["read_selector"] = enrichment["read_selector"] return enriched build_owner_index() def public_module_match( module_id: str, snippet: dict[str, Any], *, stream_index: int | None = None, bsl_offset: int | None = None, routine_name_for_hit: str | None = None, ) -> dict[str, Any]: module_table, module_file_name, _ = parse_module_id(module_id) cached_owner = metadata_owner_cache_lookup(module_id) cached_origin = cached_owner.get("origin") if isinstance((cached_owner or {}).get("origin"), dict) else {} if resolve_owners and cache_config and ( not cached_owner or (module_table == "ConfigCAS" and not cached_origin.get("extension")) ): metadata_module_owner_resolve_from_ref( base_id_or_error, cache_config, module_id, timeout_seconds=int(timeout_seconds or 60), ) cached_owner = metadata_owner_cache_lookup(module_id) cached_form_owner = metadata_form_owner_cache_lookup(cache_config, module_ref=module_id) if not cached_owner else None read_selector = { "base_id": base_id_or_error, "method": "modules.read", "module_ref": module_id, "preview": True, "max_chars": int(read_max_chars or 4000), } if cached_form_owner and cached_form_owner.get("bsl_offset") is not None and stream_index is None: read_selector["bsl_offset"] = int(cached_form_owner.get("bsl_offset") or 0) if cached_owner and cached_owner.get("module", {}).get("module_ordinal") and cached_owner.get("owner", {}).get("guid"): read_selector["kind"] = cached_owner.get("owner", {}).get("kind") read_selector["guid"] = cached_owner.get("owner", {}).get("guid") read_selector["module_ordinal"] = cached_owner.get("module", {}).get("module_ordinal") read_selector = enrich_selector_with_object_ref(read_selector, cached_owner.get("owner") or {}) if stream_index is None and bsl_offset is not None: read_selector["bsl_offset"] = int(bsl_offset) match = { "score": 1.0, "snippet": snippet, "owner": cached_owner["owner"] if cached_owner else { "status": "resolved", "kind": ((cached_form_owner.get("owner") or {}).get("kind") if isinstance(cached_form_owner, dict) else None) or ((cached_form_owner.get("form") or {}).get("kind") if isinstance(cached_form_owner, dict) else None), "name": ((cached_form_owner.get("owner") or {}).get("name") if isinstance(cached_form_owner, dict) else None) or ((cached_form_owner.get("form") or {}).get("name") if isinstance(cached_form_owner, dict) else None), "synonym": None, "guid": ((cached_form_owner.get("owner") or {}).get("guid") if isinstance(cached_form_owner, dict) else None) or ((cached_form_owner.get("form") or {}).get("guid") if isinstance(cached_form_owner, dict) else None), "source": "metadata_form_owner_cache", } if cached_form_owner else { "status": "unresolved", "kind": None, "name": None, "synonym": None, "diagnostics": { "message": "Владелец модуля не восстановлен. Для чтения используйте read_selector; для точного владельца ограничьте поиск kind/name/guid, включите resolve_owners или увеличьте owner_scan_limit.", }, }, "module": { "name": "Модуль БСЛ", "module_ordinal": None, "form": None, }, "read_selector": read_selector, "origin": module_origin_from_storage_table(module_table or ""), } if cached_owner: cached_module = cached_owner.get("module") or {} if cached_module.get("name"): match["module"]["name"] = cached_module.get("name") if cached_module.get("module_type"): match["module"]["kind"] = cached_module.get("module_type") if cached_module.get("module_ordinal"): match["module"]["module_ordinal"] = cached_module.get("module_ordinal") if cached_owner.get("module_payload", {}).get("stream_index") is not None: match["module"]["stream_index"] = cached_owner.get("module_payload", {}).get("stream_index") if isinstance(cached_owner.get("origin"), dict): match["origin"] = cached_owner["origin"] if cached_form_owner: cached_form = cached_form_owner.get("form") if isinstance(cached_form_owner.get("form"), dict) else {} match["module"]["name"] = "Модуль формы" match["module"]["form"] = cached_form.get("name") match["origin"] = { "source": "extension" if cached_form_owner.get("extension") else "metadata_form_owner_cache", "presentation": "Расширение" if cached_form_owner.get("extension") else "Индекс форм", "extension": cached_form_owner.get("extension"), "status": "ok", } if stream_index is not None: match["module"]["stream_index"] = stream_index if routine_name_for_hit: match["module"]["routine_name"] = routine_name_for_hit if module_table in {"ConfigSave", "ConfigCASSave"}: saved_context = saved_state_public_module_context( base_id=base_id_or_error, table=module_table, file_name=module_file_name or "", object_kind=canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) or None, timeout_seconds=int(timeout_seconds or 60), prefer_form_module=stream_index is None and bsl_offset is not None, ) saved_owner = saved_context.get("owner") if isinstance(saved_context.get("owner"), dict) else None saved_form = saved_context.get("form") if isinstance(saved_context.get("form"), dict) else None saved_module = saved_context.get("module") if isinstance(saved_context.get("module"), dict) else None saved_qualified_name = str(saved_context.get("qualified_name") or "").strip() if saved_owner: match["owner"] = saved_owner if saved_form: match["form"] = saved_form match["module"]["form"] = saved_form.get("name") if saved_module: match["module"].update(saved_module) if saved_qualified_name: match["qualified_name"] = saved_qualified_name match["display_name"] = saved_qualified_name saved_identity = saved_state_module_file_identity(module_file_name or "") saved_owner_guid = str(saved_identity.get("module_guid") or saved_identity.get("owner_guid") or "").strip().lower() active_owner_guid_set = {str(guid or "").strip().lower() for guid in extension_owner_guids} | extension_active_object_guids if extension_filter and saved_owner_guid: match["activation_state"] = "saved_override" if saved_owner_guid in active_owner_guid_set else "saved_only" else: match["activation_state"] = "saved_state" else: match["activation_state"] = "active" return enrich_match(match, module_id) for selected_table in scanned_tables: payloads, table_config = all_payloads.get(selected_table, ({}, None)) config = table_config or config for file_name, data in (payloads or {}).items(): if extension_target_files_by_table and file_name not in extension_target_files_by_table.get(selected_table, set()): continue decoded = payload_text_from_bytes(data) container_text = str(decoded.get("text") or "") bsl_text, extraction = extract_bsl_text_from_container(container_text) bsl_search_text = str(bsl_text or "") if extraction.get("status") == "ok": bsl_search_text = form_embedded_module_public_text(bsl_search_text) bsl_search_offset = int(extraction.get("bsl_offset") or 0) if routine_name_cf: routine_text, routine_offset = extract_routine_text(bsl_search_text, routine_name_cf) if routine_text: bsl_search_text = routine_text bsl_search_offset += int(routine_offset or 0) else: bsl_search_text = "" if extraction.get("status") == "ok" and query_cf in bsl_search_text.casefold(): module_id = f"{selected_table}:{file_name}" if extension_target_module_refs and module_id not in extension_target_module_refs: continue snippet = text_snippet(bsl_search_text, query) match = public_module_match( module_id, snippet, bsl_offset=int(extraction.get("bsl_offset") or 0), routine_name_for_hit=routine_name_for_match(bsl_search_text, snippet), ) logical_stream_key = f"{file_name}#container" if logical_stream_key in matched_logical_streams: continue matched_logical_streams.add(logical_stream_key) match["extraction"] = { "status": "ok", "source": "bsl_text", "container_offset": extraction.get("bsl_offset"), **({"routine_offset": routine_offset} if routine_name_cf and routine_offset else {}), } if routine_name_cf and bsl_search_offset: snippet["offset"] = snippet["offset"] + (routine_offset or 0) if snippet.get("offset") is not None else snippet["offset"] if include_storage: match.update( { "module_id": module_id, "table": selected_table, "file_name": file_name, "payload": { "compression": decoded.get("compression"), "encoding": decoded.get("encoding"), "raw_bytes": decoded.get("raw_bytes"), "payload_bytes": decoded.get("payload_bytes"), }, } ) if routine_name_cf: match["module"]["routine_name"] = routine_name matches.append(match) if len(matches) >= limit: break continue try: from parser.cas_payload import classify_payload classified = classify_payload(data, include_text=True) except Exception: classified = {} for index, stream in enumerate(classified.get("stream_blocks") or []): stream_text = repair_bsl_mojibake_text(str(stream.get("text") or "")) stream_text = form_embedded_module_public_text(stream_text) stream_search_text = stream_text if routine_name_cf: routine_text, routine_offset = extract_routine_text(stream_text, routine_name_cf) if not routine_text: continue stream_search_text = routine_text if query_cf not in stream_search_text.casefold(): continue module_id = f"{selected_table}:{file_name}#stream:{index}" if extension_target_module_refs and module_id not in extension_target_module_refs: continue snippet = text_snippet(stream_search_text, query) if routine_name_cf and routine_offset: snippet["offset"] = (snippet.get("offset") or 0) + routine_offset if snippet.get("offset") is not None else snippet["offset"] match = public_module_match( module_id, snippet, stream_index=index, routine_name_for_hit=routine_name_for_match(stream_search_text, snippet), ) logical_stream_key = f"{file_name}#stream:{index}" if logical_stream_key in matched_logical_streams: continue matched_logical_streams.add(logical_stream_key) if include_storage: if routine_name_cf: match["module"]["routine_name"] = routine_name match.update( { "module_id": module_id, "table": selected_table, "file_name": file_name, "stream_index": index, "payload": { "role": classified.get("role"), "compression": classified.get("compression"), "raw_bytes": classified.get("raw_bytes"), "payload_bytes": classified.get("payload_bytes"), "stream": {key: value for key, value in stream.items() if key != "text"}, }, } ) matches.append(match) if len(matches) >= limit: break if len(matches) >= limit: break if len(matches) >= limit: break truncated = scanned_files_total >= scan_budget status = "partial" if truncated else "ok" resolved_owner_count = sum(1 for match in matches if (match.get("owner") or {}).get("status") == "resolved") unresolved_owner_count = sum(1 for match in matches if (match.get("owner") or {}).get("status") != "resolved") diagnostics = { "note": "Это поиск по текстам модулей. Каждый результат содержит read_selector для следующего публичного чтения модуля; owner.status показывает, удалось ли восстановить владельца.", "owner_resolution": owner_resolution, } if extension_route_fallback_scan: diagnostics["extension_route_fallback"] = extension_route_fallback_diagnostics if skipped_active_extension_fallback: diagnostics["active_fallback_scan"] = { "status": "skipped", "reason": "full_scan_disabled_for_extension", "message": "Skipped broad active ConfigCAS scan for an extension-scoped module query. Pass full_scan=true to force deep active discovery.", } if truncated: diagnostics["message"] = "Глобальный поиск ограничен scan_limit; результат может быть неполным. Увеличьте scan_limit или передайте kind/name/guid для поиска по конкретному объекту." return { "schema": "onec_modules_search.v1", "status": status, "base_id": base_id_or_error, "source": ( {"kind": "live_sql", "database": (config or {}).get("database"), "tables": scanned_tables} if include_storage else {"kind": "live_metadata"} ), "query": ( {"query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "prefix": prefix, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage} if include_storage else {"query": query, "limit": limit, "max_matches": limit, "scan_limit": scan_limit, "scope": scope, "table": table, "prefix": prefix, "extension": extension_filter or None, "routine_name": routine_name or None, "state": state, "full_scan": full_scan, "include_storage": include_storage} ), "matches": matches, "counts": { "matches": len(matches), "scanned_files": scanned_files_total, "scan_limit": scan_limit, "truncated": truncated, "complete": not truncated, "scan_limit_hit": truncated, "tables_scanned": scanned_tables, "activation_state": { activation_state: sum(1 for match in matches if str(match.get("activation_state") or "active") == activation_state) for activation_state in sorted({str(match.get("activation_state") or "active") for match in matches}) }, "owner_resolved": resolved_owner_count, "owner_unresolved": unresolved_owner_count, "owner_scan_limit_hit": bool(owner_resolution.get("owner_scan_limit_hit")), "owner_indexed_module_refs": int(owner_resolution.get("indexed_module_refs") or 0), }, "diagnostics": diagnostics, } def _code_query_object_selector(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: kind = canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) name = payload.get("object_name") or payload.get("name") guid = payload.get("object_guid") or payload.get("guid") selector = { **({"kind": kind} if kind else {}), **({"name": name} if name is not None else {}), **({"guid": guid} if guid is not None else {}), } resolved = { "kind": selector.get("kind"), "name": selector.get("name"), "guid": selector.get("guid"), } return selector, resolved def _snippet_to_line_column(text: str, offset: int | None) -> tuple[int | None, int | None]: if offset is None: return None, None safe_offset = max(0, int(offset)) normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n") if safe_offset >= len(normalized): safe_offset = max(0, len(normalized) - 1) before = normalized[:safe_offset] line = before.count("\n") + 1 if normalized else 1 last_nl = before.rfind("\n") column = len(before) - (last_nl + 1) + 1 return line, column def _extract_bsl_routine_text_for_code_read(text: str, routine_name: str) -> tuple[str, dict[str, Any] | None]: wanted = normalize(str(routine_name or "")) if not wanted: return str(text or ""), None try: from parser.bsl_validation import routine_blocks routines = list(routine_blocks(str(text or ""))) except Exception: return "", None lines = str(text or "").split("\n") for routine in routines: routine_source_name = str(routine.get("name") or "") if normalize(routine_source_name) != wanted: continue start_line = int(routine.get("line_start") or 0) end_line = int(routine.get("line_end") or 0) if not start_line or not end_line or end_line < start_line: continue selected = "\n".join(lines[start_line - 1 : min(end_line, len(lines))]) return selected, {"routine_name": routine_source_name, "line_start": start_line, "line_end": min(end_line, len(lines))} return "", None FORM_EMBEDDED_MODULE_TRAILING_MARKER_RE = re.compile(r"(?P.*?)(?P(?:\r?\n){1,2}///----.*)$", re.DOTALL) def split_form_embedded_module_public_text(text: str) -> tuple[str, str]: match = FORM_EMBEDDED_MODULE_TRAILING_MARKER_RE.match(str(text or "")) if not match: return str(text or ""), "" return match.group("body"), match.group("suffix") def form_embedded_module_public_text(text: str) -> str: public_text, _suffix = split_form_embedded_module_public_text(text) return public_text def preserve_form_embedded_module_suffix(current_text: str, new_text: str) -> str: _current_public, suffix = split_form_embedded_module_public_text(current_text) if not suffix: return new_text _new_public, new_suffix = split_form_embedded_module_public_text(new_text) if new_suffix: return new_text return f"{new_text.rstrip()}{suffix}" def code_saved_state_common_form_module_search(payload: dict[str, Any], *, query: str, limit: int, offset: int, scan_limit: int, timeout_seconds: int, include_line_numbers: bool, include_context: bool, include_storage: bool, state: str) -> dict[str, Any] | None: object_type = str(payload.get("object_type") or payload.get("kind") or "").strip() object_name = str(payload.get("object_name") or payload.get("name") or "").strip() object_guid = str(payload.get("object_guid") or payload.get("guid") or "").strip() if canonical_kind(object_type) != "CommonForm" or not (object_name or object_guid): return None if state not in {"working", "save", "both"}: return None search = metadata_saved_state_modules_search( { "base_id": payload.get("base_id"), "object_type": "CommonForm", **({"object_name": object_name} if object_name else {}), **({"object_guid": object_guid} if object_guid else {}), "query": query, "routine_name": payload.get("routine_name"), "limit": int(limit or 25) + int(offset or 0), "scan_limit": int(scan_limit or 300), "preview_chars": int(payload.get("max_chars") or 100000), "timeout_seconds": int(timeout_seconds or 60), "include_storage": True, } ) if search.get("status") != "ok" or not search.get("modules"): return None raw_items: list[dict[str, Any]] = [] for module in search.get("modules") or []: if not isinstance(module, dict): continue for stream in module.get("streams") or []: if not isinstance(stream, dict): continue text = form_embedded_module_public_text(str(stream.get("preview") or "")) search_text = text selection = None routine_name = str(payload.get("routine_name") or "") if routine_name: routine_text, routine_selection = _extract_bsl_routine_text_for_code_read(text, routine_name) if routine_text: search_text = routine_text selection = routine_selection offset_value = search_text.casefold().find(str(query or "").casefold()) if query and offset_value < 0: continue snippet = text_snippet(search_text, query) if query else {"text": search_text, "offset": None} line = column = None if include_line_numbers and offset_value >= 0: line, column = _snippet_to_line_column(search_text, offset_value) if selection and selection.get("line_start"): line = int(selection["line_start"]) + int(line or 1) - 1 read_selector = { "method": "code.read", "base_id": payload.get("base_id"), "object_type": "CommonForm", **({"object_name": object_name} if object_name else {}), **({"object_guid": object_guid} if object_guid else {}), **({"routine_name": routine_name} if routine_name else {}), "include_text": True, "max_chars": int(payload.get("max_chars") or 100000), } item = { "match": str(snippet.get("text") or ""), "line": line, "column": column, "context": str(snippet.get("text") or ""), "resolved_owner": {"status": "resolved", "kind": "CommonForm", "name": object_name or None, "guid": object_guid or None, "source": "saved_state"}, "origin": {"source": "saved_state", "presentation": "Saved state", "status": "ok"}, "module": {"name": "Модуль формы", "form": object_name or None, **({"routine_name": routine_name} if routine_name else {})}, "query": query, "read_selector": read_selector, "source": read_selector, "activation_state": "saved_state", "current_state": {"source": "saved_state", "activation_state": "not_activated"}, } if not include_context: item.pop("context", None) item["match"] = query if include_storage: item["storage"] = {"table": module.get("table"), "file_name": module.get("file_name"), "module_path": stream.get("module_path")} raw_items.append(item) sliced = raw_items[int(offset or 0) : int(offset or 0) + int(limit or 25)] return { "schema": "onec_code_search.v1", "status": "ok" if sliced else "not_found", "base_id": payload.get("base_id"), "source": ( {"kind": "saved_state", "tables": search.get("source", {}).get("tables")} if include_storage else {"kind": "saved_state"} ), "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "object": {"kind": "CommonForm", "name": object_name or None, "guid": object_guid or None}, "query": { "query": query, "kind": "CommonForm", "name": object_name or None, "guid": object_guid or None, "limit": int(limit or 25), "offset": int(offset or 0), "scan_limit": int(scan_limit or 300), "state": state, "include_storage": bool(include_storage), "include_line_numbers": bool(include_line_numbers), "include_context": bool(include_context), }, "items": sliced, "counts": {"matches": len(sliced), "returned": len(sliced), "offset": int(offset or 0), "scan_limit": int(scan_limit or 300), "truncated": False, "complete": True, "scan_limit_hit": False, "owner_resolved": len(sliced), "owner_unresolved": 0, "owner_scan_limit_hit": False, "owner_indexed_module_refs": 0}, "diagnostics": {"note": "Saved-state CommonForm module search; physical storage details are hidden unless include_storage=true."}, } def code_saved_state_common_form_read(payload: dict[str, Any], *, include_text: bool, include_line_numbers: bool, max_chars: int, timeout_seconds: int) -> dict[str, Any] | None: object_type = str(payload.get("object_type") or payload.get("kind") or "").strip() object_name = str(payload.get("object_name") or payload.get("name") or "").strip() object_guid = str(payload.get("object_guid") or payload.get("guid") or "").strip() if canonical_kind(object_type) != "CommonForm" or not (object_name or object_guid): return None routine_name = str(payload.get("routine_name") or "") search = metadata_saved_state_modules_search( { "base_id": payload.get("base_id"), "object_type": "CommonForm", **({"object_name": object_name} if object_name else {}), **({"object_guid": object_guid} if object_guid else {}), **({"query": routine_name} if routine_name else {}), "limit": 2, "scan_limit": 1000, "preview_chars": int(max_chars or 100000), "timeout_seconds": int(timeout_seconds or 60), "include_storage": True, } ) streams: list[dict[str, Any]] = [] for module in search.get("modules") or []: if not isinstance(module, dict): continue streams.extend([stream for stream in module.get("streams") or [] if isinstance(stream, dict)]) if search.get("status") != "ok" or len(streams) != 1: return None module_text = form_embedded_module_public_text(str(streams[0].get("preview") or "")) text = module_text selection = None if routine_name: text, selection = _extract_bsl_routine_text_for_code_read(module_text, routine_name) if not text: return { "schema": "onec_code_read.v1", "method": "code.read", "status": "not_found", "error": "routine_not_found", "base_id": payload.get("base_id"), "diagnostics": {"message": f"Routine `{routine_name}` was not found in the saved-state form module."}, } result = { "schema": "onec_code_read.v1", "method": "code.read", "status": "ok", "base_id": payload.get("base_id"), "source": {"kind": "saved_state", "target": "common_form_module", "include_line_numbers": bool(include_line_numbers)}, "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "resolved_owner": {"kind": "CommonForm", "name": object_name or None, "guid": object_guid or None}, "module": {"name": "Модуль формы", "form": object_name or None, **({"routine_name": routine_name} if routine_name else {})}, "selection": selection, "text": text if include_text else "", } if include_line_numbers and selection and selection.get("line_start") is not None: result["line"] = int(selection.get("line_start") or 1) result["column"] = 1 return result def code_read_indexed_fragment( payload: dict[str, Any], *, base_id: str, module_ref: str, include_text: bool, current_state: dict[str, Any], ) -> dict[str, Any] | None: """Return a focused BSL selection from the local code index. The index stores a previously decoded module text and routine boundaries. It is intentionally an opt-in fast snapshot: a caller needing a live SQL proof keeps the normal read path (``prefer_code_index=false``). This lets routine navigation avoid decompressing and decoding a large ConfigCAS container for every click without presenting cache data as live readback. """ if not truthy(payload.get("prefer_code_index", "1")): return None if not (str(payload.get("routine_name") or "").strip() or payload.get("line_start") is not None or payload.get("line_end") is not None): return None config, _config_error = sql_config_for_base(base_id) if not config: return None with cache_connection() as conn: row = conn.execute( """ SELECT * FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND module_ref=? LIMIT 1 """, (cache_server_key(config), cache_database_name(config), module_ref), ).fetchone() if not row: return None cached = code_index_row_payload(row, config) text = str(cached.get("text") or "") if not text: return None text_info = module_text_response(text, payload) owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} result: dict[str, Any] = { "schema": "onec_code_read.v1", "method": "code.read", "status": text_info.pop("status", "ok"), "base_id": base_id, "source": {"kind": "code_index_cache", "target": "module_or_routine", "live_sql_decoded": False}, "current_state": current_state, "module": {"name": "Модуль БСЛ", "module_type": "bsl_module", "form": owner.get("form")}, "resolved_owner": {"status": "resolved" if owner.get("guid") else "partial", "kind": owner.get("kind"), "name": owner.get("name"), "guid": owner.get("guid")}, "freshness": {"status": "cache_snapshot", "verified_against_sql": False, "payload_sha1": cached.get("payload_sha1"), "text_sha1": cached.get("text_sha1"), "last_verified_at": cached.get("last_verified_at")}, **text_info, } if extension.get("guid") or extension.get("name"): result["origin"] = {"source": "extension", "presentation": "Расширение", "extension": extension, "status": "ok" if extension.get("name") else "extension_unresolved"} return result def code_read_layer_item(result: dict[str, Any] | None, *, source: str, include_text: bool) -> dict[str, Any]: current_state = ( {"source": "saved_state", "activation_state": "not_activated"} if source == "saved_state" else {"source": "active", "activation_state": "active"} ) if not isinstance(result, dict): return {"source": source, "status": "not_found", "current_state": current_state} item = { "source": source, "status": result.get("status") or "unknown", "current_state": result.get("current_state") if isinstance(result.get("current_state"), dict) else current_state, } if result.get("error"): item["error"] = result.get("error") if include_text and result.get("text") is not None: item["text"] = result.get("text") if isinstance(result.get("selection"), dict): item["selection"] = result.get("selection") if isinstance(result.get("diagnostics"), dict): item["diagnostics"] = result.get("diagnostics") if isinstance(result.get("counterpart_resolution"), dict): item["counterpart_resolution"] = result.get("counterpart_resolution") return item def code_read_active_counterpart_module_ref(module_ref: str) -> str | None: table, file_name, stream_index = parse_module_id(str(module_ref or "").strip()) active_table = SAVED_STATE_SOURCE_BY_TARGET.get(str(table or "")) if not active_table or not file_name: return None return f"{active_table}:{file_name}" + (f"#stream:{stream_index}" if stream_index is not None else "") def code_read_saved_counterpart_module_ref(base_id: str, module_ref: str, payload: dict[str, Any]) -> str | None: table, _file_name, stream_index = parse_module_id(str(module_ref or "").strip()) saved_table = {"Config": "ConfigSave", "ConfigCAS": "ConfigCASSave"}.get(str(table or "")) if not saved_table: return None config, _ = sql_config_for_base(base_id) form_context = metadata_form_owner_cache_lookup(config, module_ref=module_ref) if not form_context and payload.get("form"): form_context = metadata_form_owner_cache_lookup( config, owner_kind=str(payload.get("kind") or payload.get("object_type") or ""), form_name=str(payload.get("form") or ""), extension=str(payload.get("extension") or payload.get("extension_guid") or "") or None, ) if not isinstance(form_context, dict): return None form = form_context.get("form") if isinstance(form_context.get("form"), dict) else {} extension = form_context.get("extension") if isinstance(form_context.get("extension"), dict) else {} form_guid = str(form.get("guid") or "").strip().lower() extension_guid = str(extension.get("guid") or payload.get("extension_guid") or "").strip().lower() if not is_guid_text(form_guid): return None if saved_table == "ConfigCASSave": if not is_guid_text(extension_guid): return None saved_file_name = f"{extension_guid}__{form_guid}.0" else: saved_file_name = f"{form_guid}.0" fragment = str(module_ref or "").split("#", 1)[1] if "#" in str(module_ref or "") else "" if fragment in {"form_module", "bsl", "bsl_container"}: return f"{saved_table}:{saved_file_name}#{fragment}" if stream_index is not None: return f"{saved_table}:{saved_file_name}#stream:{stream_index}" return f"{saved_table}:{saved_file_name}" def code_read_active_counterpart_selector( saved_result: dict[str, Any] | None, payload: dict[str, Any], *, module_ref: str, ) -> dict[str, Any]: if not isinstance(saved_result, dict): return {} owner = saved_result.get("owner") if isinstance(saved_result.get("owner"), dict) else {} resolved_owner = saved_result.get("resolved_owner") if isinstance(saved_result.get("resolved_owner"), dict) else {} if not owner and isinstance(resolved_owner.get("owner"), dict): owner = resolved_owner["owner"] module = saved_result.get("module") if isinstance(saved_result.get("module"), dict) else {} origin = saved_result.get("origin") if isinstance(saved_result.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} saved_table, saved_file_name, _saved_stream_index = parse_module_id(module_ref) active_table = SAVED_STATE_SOURCE_BY_TARGET.get(str(saved_table or "")) if not active_table: return {} kind = canonical_kind(str(owner.get("kind") or payload.get("object_type") or payload.get("kind") or "")) name = str(owner.get("name") or payload.get("object_name") or payload.get("name") or "").strip() guid = str(owner.get("guid") or payload.get("object_guid") or payload.get("guid") or "").strip().lower() if not (name or (guid and is_guid_text(guid))): return {} extension_guid = str(extension.get("guid") or "").strip().lower() if not extension_guid and saved_table == "ConfigCASSave" and "__" in str(saved_file_name or ""): prefix = str(saved_file_name or "").split("__", 1)[0].strip().lower() if is_guid_text(prefix): extension_guid = prefix selector: dict[str, Any] = { "table": active_table, **({"kind": kind} if kind else {}), **({"name": name} if name else {}), **({"guid": guid} if is_guid_text(guid) else {}), **({"extension_guid": extension_guid} if is_guid_text(extension_guid) else {}), } module_ordinal = module.get("module_ordinal") if module_ordinal not in {None, ""}: selector["module_ordinal"] = module_ordinal else: module_name = str(module.get("name") or module.get("module_name") or "").strip() if module_name and normalize(module_name) not in {normalize("Модуль БСЛ"), normalize("BSL module")}: selector["module"] = module_name return selector def code_read_both_response(payload: dict[str, Any], *, base_id: str, saved_result: dict[str, Any] | None, active_result: dict[str, Any] | None, include_text: bool) -> dict[str, Any]: saved_layer = code_read_layer_item(saved_result, source="saved_state", include_text=include_text) active_layer = code_read_layer_item(active_result, source="active", include_text=include_text) ok_sources = [layer["source"] for layer in (saved_layer, active_layer) if layer.get("status") in {"ok", "summary", "text"}] saved_text = saved_result.get("text") if isinstance(saved_result, dict) and isinstance(saved_result.get("text"), str) else None active_text = active_result.get("text") if isinstance(active_result, dict) and isinstance(active_result.get("text"), str) else None module_text_comparison = ( bsl_module_text_comparison( active_text, saved_text, routine_name=str(payload.get("routine_name") or ""), include_text_diff=bool(payload.get("include_text_diff", True)), max_text_diff_lines=int(payload.get("max_text_diff_lines") or 200), ) if saved_text is not None and active_text is not None else None ) comparison = { "saved_status": saved_layer.get("status"), "active_status": active_layer.get("status"), "both_present": bool(saved_text is not None and active_text is not None), "differs": module_text_comparison.get("differs") if module_text_comparison else False, "text_same": module_text_comparison.get("same") if module_text_comparison else None, "module_text_same": module_text_comparison.get("same") if module_text_comparison else None, **( { "active_text_sha1": module_text_comparison.get("active_sha1"), "saved_text_sha1": module_text_comparison.get("saved_sha1"), "scope": module_text_comparison.get("scope"), } if module_text_comparison else {} ), } result = { "schema": "onec_code_read.v1", "method": "code.read", "status": "ok" if ok_sources else "not_found", "base_id": base_id, "current_state": {"source": "both", "activation_state": "mixed"}, "query": { "kind": payload.get("object_type") or payload.get("kind"), "name": payload.get("object_name") or payload.get("name"), "guid": payload.get("object_guid") or payload.get("guid"), "routine_name": payload.get("routine_name"), "state": "both", }, "layers": [saved_layer, active_layer], "comparison": comparison, **({"module_text": module_text_comparison} if module_text_comparison else {}), } if include_text: if saved_text is not None: result["text"] = saved_text result["text_source"] = "saved_state" elif active_text is not None: result["text"] = active_text result["text_source"] = "active" return result def code_search_both_response(base_id: str, saved_result: dict[str, Any], active_result: dict[str, Any]) -> dict[str, Any]: saved_items = [dict(item) for item in saved_result.get("items") or [] if isinstance(item, dict)] active_items = [dict(item) for item in active_result.get("items") or [] if isinstance(item, dict)] for item in saved_items: item.setdefault("current_state", {"source": "saved_state", "activation_state": "not_activated"}) for item in active_items: item.setdefault("current_state", {"source": "active", "activation_state": "active"}) status = "ok" if saved_items or active_items else "not_found" saved_counts = saved_result.get("counts") if isinstance(saved_result.get("counts"), dict) else {} active_counts = active_result.get("counts") if isinstance(active_result.get("counts"), dict) else {} return { "schema": "onec_code_search.v1", "status": status, "base_id": base_id, "source": {"kind": "both", "layers": ["saved_state", "active"]}, "current_state": {"source": "both", "activation_state": "mixed"}, "query": {**(saved_result.get("query") if isinstance(saved_result.get("query"), dict) else {}), "state": "both"}, "items": saved_items + active_items, "layers": [ { "source": "saved_state", "status": saved_result.get("status") or ("ok" if saved_items else "not_found"), "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "counts": saved_counts, }, { "source": "active", "status": active_result.get("status") or ("ok" if active_items else "not_found"), "current_state": {"source": "active", "activation_state": "active"}, "counts": active_counts, }, ], "counts": { "matches": len(saved_items) + len(active_items), "returned": len(saved_items) + len(active_items), "saved_matches": len(saved_items), "active_matches": len(active_items), "complete": bool(saved_counts.get("complete", True)) and bool(active_counts.get("complete", True)), "scan_limit_hit": bool(saved_counts.get("scan_limit_hit", False)) or bool(active_counts.get("scan_limit_hit", False)), }, "comparison": { "saved_status": saved_result.get("status"), "active_status": active_result.get("status"), "saved_matches": len(saved_items), "active_matches": len(active_items), "both_present": bool(saved_items and active_items), }, } def public_code_origin(origin: Any, *, include_storage: bool) -> Any: """Expose source semantics without making SQL storage a programming selector.""" if not isinstance(origin, dict) or include_storage: return origin return { key: value for key, value in origin.items() if key not in {"storage_table", "table", "file_name", "module_id"} } def code_search(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "code.search") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload payload = normalize_configuration_view(payload, "code.search") if payload.get("status") == "invalid_argument": return payload configuration_view = str(payload.get("configuration_view") or "effective_working") base_id_or_error = require_base_id(payload, "code.search") if isinstance(base_id_or_error, dict): return base_id_or_error query_value = payload.get("query") or payload.get("pattern") if query_value is not None and not isinstance(query_value, str): return invalid_argument("code.search", "query", "query must be a JSON string.") query = str(query_value or "").strip() if not query: return invalid_argument("code.search", "query", "Передайте непустой query.") include_line_numbers, include_line_numbers_error = strict_bool_argument(payload, "include_line_numbers", method="code.search", default=False) if include_line_numbers_error: return include_line_numbers_error include_context, include_context_error = strict_bool_argument(payload, "include_context", method="code.search", default=True) if include_context_error: return include_context_error prepare_replace, prepare_replace_error = strict_bool_argument(payload, "prepare_replace", method="code.search", default=False) if prepare_replace_error: return prepare_replace_error anchor_chars, anchor_chars_error = parse_int_argument(payload, "anchor_chars", method="code.search", default=120, minimum=1, maximum=2000) if anchor_chars_error: return anchor_chars_error include_storage, include_storage_error = strict_include_storage(payload, "code.search") if include_storage_error: return include_storage_error limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method="code.search", default=25, minimum=1, maximum=500) if limit_error: return limit_error offset, offset_error = parse_int_argument(payload, "offset", method="code.search", default=0, minimum=0) if offset_error: return offset_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method="code.search", default=300, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="code.search", default=60, minimum=1) if timeout_error: return timeout_error state = str(payload.get("state") or "working").strip().lower() if state not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument("code.search", "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) saved_extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(saved_extension_guid): return invalid_argument("code.search", "extension_guid", "extension_guid must be a GUID string.") module_ordinal = payload.get("module_ordinal") if module_ordinal is not None: _, module_ordinal_error = parse_ordinal(module_ordinal, "code.search", argument="module_ordinal") if module_ordinal_error: return module_ordinal_error object_selector, resolved_object = _code_query_object_selector(payload) saved_state_result = code_saved_state_common_form_module_search( {**payload, "base_id": base_id_or_error}, query=query, limit=int(limit or 25), offset=int(offset or 0), scan_limit=int(scan_limit or 300), timeout_seconds=int(timeout_seconds or 60), include_line_numbers=bool(include_line_numbers), include_context=bool(include_context), include_storage=bool(include_storage), state=state, ) if saved_state_result is not None: if state == "both": active_result = code_search( { **payload, "base_id": base_id_or_error, "state": "active", "configuration_view": "runtime_applied", } ) return annotate_configuration_view(code_search_both_response(base_id_or_error, saved_state_result, active_result), configuration_view) return annotate_configuration_view(saved_state_result, configuration_view) search_payload = { "base_id": base_id_or_error, "query": query, # The public response may hide storage diagnostics, but the resolver # needs the exact stream from this already-scoped search hit to issue # a safe write capability. Do not let the presentation flag discard # that internal proof before write_selector is assembled below. "include_storage": True, "scan_limit": int(scan_limit or 300), "limit": int(limit or 25) + int(offset or 0), "routine_name": payload.get("routine_name"), "scope": payload.get("scope", "auto"), "table": payload.get("table", "auto"), "prefix": payload.get("prefix", ""), "state": state, "max_chars": payload.get("max_chars"), "resolve_owners": True, "full_scan": payload.get("full_scan", False), "timeout_seconds": int(timeout_seconds or 60), **({"kind": object_selector.get("kind")} if object_selector.get("kind") else {}), **({"name": object_selector.get("name")} if object_selector.get("name") is not None else {}), **({"guid": object_selector.get("guid")} if object_selector.get("guid") is not None else {}), **({"extension": payload.get("extension")} if payload.get("extension") not in {None, ""} else {}), **({"extension_guid": payload.get("extension_guid")} if payload.get("extension_guid") not in {None, ""} else {}), **( {"module_ordinal": module_ordinal} if module_ordinal is not None and payload.get("module_ordinal") is not None else {} ), } modules_result = search_modules(search_payload) if modules_result.get("status") == "error": return modules_result raw_matches = [item for item in (modules_result.get("matches") or []) if isinstance(item, dict)] # Once the caller has specified an extension, object and form, global BSL # hits without that proven owner are diagnostic noise, not code candidates. # This prevents a stale ConfigCAS payload with identical text from becoming # the apparent source for a later write. requested_form = str(payload.get("form") or payload.get("form_name") or "").strip() strict_owner_scope = bool( requested_form and payload.get("extension") and object_selector.get("kind") and object_selector.get("name") ) excluded_unbound_matches = 0 if strict_owner_scope: scoped_matches: list[dict[str, Any]] = [] for match in raw_matches: owner = match.get("owner") if isinstance(match.get("owner"), dict) else {} module = match.get("module") if isinstance(match.get("module"), dict) else {} form = module.get("form") if isinstance(module.get("form"), dict) else {} origin = match.get("origin") if isinstance(match.get("origin"), dict) else {} origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} if ( canonical_kind(str(owner.get("kind") or "")) == canonical_kind(str(object_selector.get("kind") or "")) and normalize(str(owner.get("name") or "")) == normalize(str(object_selector.get("name") or "")) and normalize(str(form.get("name") or "")) == normalize(requested_form) and normalize(str(origin_extension.get("name") or "")) == normalize(str(payload.get("extension") or "")) ): scoped_matches.append(match) else: excluded_unbound_matches += 1 raw_matches = scoped_matches sliced = raw_matches[int(offset or 0) : int(offset or 0) + int(limit or 25)] items: list[dict[str, Any]] = [] for match in sliced: snippet = match.get("snippet") or {} context_text = str(snippet.get("text") or "") offset_value = snippet.get("offset") item_read_selector = dict(match.get("read_selector") or {}) if item_read_selector: item_read_selector["method"] = "code.read" # The selector must reread the same layer that produced the hit. # Without this, an active ConfigCAS hit was silently reread from # ConfigCASSave by code.read's effective-workings default and # reported as source_missing. activation_state = str(match.get("activation_state") or "") item_read_selector.setdefault( "configuration_view", "effective_working" if activation_state.startswith("saved") else "runtime_applied", ) item_line = None item_column = None if truthy(include_line_numbers): text_for_line = "" routine_name = str(match.get("module", {}).get("routine_name") or "") if routine_name: owner_ref = match.get("read_selector") or {} read_payload = { **({"kind": owner_ref.get("kind")} if owner_ref.get("kind") else {}), **({"guid": owner_ref.get("guid")} if owner_ref.get("guid") else {}), **({"name": owner_ref.get("name")} if owner_ref.get("name") else {}), **({"module_ordinal": owner_ref.get("module_ordinal")} if owner_ref.get("module_ordinal") else {}), **({"module_id": owner_ref.get("module_id")} if owner_ref.get("module_id") else {}), **({"module_ref": owner_ref.get("module_ref")} if owner_ref.get("module_ref") else {}), "base_id": base_id_or_error, "routine_name": routine_name, "preview": True, "max_chars": int(payload.get("max_chars") or 4000), } read_payload["include_storage"] = False read_payload["max_chars"] = int(payload.get("max_chars") or 4000) read_result = read_module(read_payload) if read_result.get("status") == "ok": selection = read_result.get("selection") or {} if selection.get("line_start") is not None: item_line = int(selection.get("line_start")) item_column = 1 if offset_value is not None: item_line, item_column = _snippet_to_line_column(str(read_result.get("text") or ""), int(offset_value)) if context_text and not include_context: context_text = "" elif offset_value is not None and context_text: item_line, item_column = _snippet_to_line_column(context_text, int(offset_value)) item = { "match": str(context_text if context_text else ""), "line": item_line, "column": item_column, "context": context_text, "resolved_owner": match.get("owner"), "origin": public_code_origin(match.get("origin"), include_storage=bool(include_storage)), "module": match.get("module"), **({"qualified_name": match.get("qualified_name")} if match.get("qualified_name") else {}), **({"display_name": match.get("display_name")} if match.get("display_name") else {}), **({"activation_state": match.get("activation_state")} if match.get("activation_state") else {}), **({"current_state": {"source": "saved_state", "activation_state": "not_activated"}} if str(match.get("activation_state") or "").startswith("saved") else {}), "query": query_value, "read_selector": item_read_selector, "source": item_read_selector, } # This remains a public, name-first selector: physical ConfigCAS keys # never leak into a write request. It is emitted only after the same # search result resolved one extension owner and one module ordinal; # metadata.write still performs its normal live preflight/SHA guard. item_owner = item.get("resolved_owner") if isinstance(item.get("resolved_owner"), dict) else {} item_origin = item.get("origin") if isinstance(item.get("origin"), dict) else {} item_extension = item_origin.get("extension") if isinstance(item_origin.get("extension"), dict) else {} item_module = item.get("module") if isinstance(item.get("module"), dict) else {} if ( item_owner.get("status") == "resolved" and item_owner.get("kind") and item_owner.get("name") and item_extension.get("name") and item_module.get("module_ordinal") is not None ): routine_name = str(item_module.get("routine_name") or "").strip() canonical_path = ( ".".join([RU_KIND.get(str(item_owner.get("kind") or ""), str(item_owner.get("kind") or "")), str(item_owner.get("name")), routine_name]) if routine_name else None ) # ``search_modules`` intentionally removes physical coordinates # from its public read selector. The match itself, however, is # the already resolved active module that produced this hit. Keep # that exact handle in the adapter-issued write capability: a # later code.write must not fall back to ordinal/module-role # discovery and thereby lose an otherwise proven extension route. concrete_module_ref = str( match.get("module_id") or item_read_selector.get("module_ref") or item_read_selector.get("module_id") or "" ).strip() write_target = { "kind": "module", "ref": object_selector_ref(item_owner.get("kind"), item_owner.get("name")), "extension": item_extension.get("name"), "module_ordinal": item_module.get("module_ordinal"), **({"form": item_module.get("form")} if item_module.get("form") else {}), **({"module": item_module.get("kind") or "bsl_module"}), **({"routine_name": routine_name} if routine_name else {}), **({"canonical_path": canonical_path} if canonical_path else {}), **({"module_ref": concrete_module_ref, "concrete_reference": concrete_module_ref} if concrete_module_ref else {}), "layer_id": f"extension:{item_extension.get('guid')}" if item_extension.get("guid") else None, "preflight_required": True, } item["metadata_write_target"] = write_target item["write_selector"] = {"target": write_target} if prepare_replace and item_read_selector: candidate_read = code_read( { **item_read_selector, "include_text": True, "max_chars": 1_000_000, "include_routines": False, "include_summary": False, } ) candidate_text = candidate_read.get("text") if isinstance(candidate_read, dict) else None if isinstance(candidate_text, str): candidates = code_replacement_candidates( candidate_text, str(query_value or ""), anchor_chars=int(anchor_chars or 120), ) owner = item.get("resolved_owner") if isinstance(item.get("resolved_owner"), dict) else {} owner_kind = canonical_kind(str(owner.get("kind") or "")) owner_name = str(owner.get("name") or "").strip() origin = item.get("origin") if isinstance(item.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} write_selector = { "base_id": base_id_or_error, **({"ref": f"{owner_kind}.{owner_name}"} if owner_kind and owner_name else {}), **({"extension": extension.get("name")} if extension.get("name") else {}), **({"routine_name": item.get("module", {}).get("routine_name")} if isinstance(item.get("module"), dict) and item["module"].get("routine_name") else {}), **({"expected_text_sha1": candidate_read.get("content_sha1")} if candidate_read.get("content_sha1") else {}), } # Reuse the exact target created above, rather than creating # a reduced name-only selector for a replacement candidate. # The concrete reference remains safe only because it is # issued from this resolved search hit and code.write checks # its owner, extension and development layer before writing. item_write_selector = item.get("write_selector") if isinstance(item.get("write_selector"), dict) else {} item_write_target = item_write_selector.get("target") if isinstance(item_write_selector.get("target"), dict) else {} for selector_key in ( "ref", "extension", "routine_name", "canonical_path", "layer_id", "concrete_reference", "module_ref", ): selector_value = item_write_target.get(selector_key) if selector_value not in {None, ""}: write_selector[selector_key] = selector_value for candidate in candidates: candidate["write_selector"] = write_selector item["replacement_candidates"] = candidates item["replacement_candidate_count"] = len(candidates) else: item["replacement_candidates"] = [] item["replacement_diagnostics"] = {"code": "module_read_failed", "message": "Unable to read the complete module for contextual replacement candidates."} items.append(item) if not truthy(include_context): item = items[-1] item.pop("context", None) item["match"] = match.get("match") or query public_status = modules_result.get("status") or ("ok" if items else "not_found") if not items and public_status == "ok": public_status = "not_found" return annotate_configuration_view({ "schema": "onec_code_search.v1", "status": public_status, "base_id": base_id_or_error, "source": modules_result.get("source"), "object": resolved_object, "query": { "query": query, "kind": resolved_object.get("kind"), "name": resolved_object.get("name"), "guid": resolved_object.get("guid"), "limit": int(limit or 25), "offset": int(offset or 0), "scan_limit": int(scan_limit or 300), "state": state, "include_storage": bool(include_storage), "include_line_numbers": bool(include_line_numbers), "include_context": bool(include_context), "prepare_replace": bool(prepare_replace), **({"anchor_chars": int(anchor_chars or 120)} if prepare_replace else {}), }, "items": items, "counts": { "matches": len(items), "returned": len(items), "offset": int(offset or 0), "scan_limit": int(scan_limit or 300), "truncated": bool(modules_result.get("counts", {}).get("truncated")), "complete": bool(modules_result.get("counts", {}).get("complete", not modules_result.get("counts", {}).get("truncated"))), "scan_limit_hit": bool(modules_result.get("counts", {}).get("scan_limit_hit", modules_result.get("counts", {}).get("truncated"))), "owner_resolved": int(modules_result.get("counts", {}).get("owner_resolved") or 0), "excluded_unbound_matches": excluded_unbound_matches, "owner_unresolved": int(modules_result.get("counts", {}).get("owner_unresolved") or 0), "owner_scan_limit_hit": bool(modules_result.get("counts", {}).get("owner_scan_limit_hit", False)), "owner_indexed_module_refs": int(modules_result.get("counts", {}).get("owner_indexed_module_refs") or 0), }, "diagnostics": modules_result.get("diagnostics"), }, configuration_view) def code_read(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "code.read") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload payload = normalize_configuration_view(payload, "code.read") if payload.get("status") == "invalid_argument": return payload configuration_view = str(payload.get("configuration_view") or "effective_working") base_id_or_error = require_base_id(payload, "code.read") if isinstance(base_id_or_error, dict): return base_id_or_error include_line_numbers, include_line_numbers_error = strict_bool_argument(payload, "include_line_numbers", method="code.read", default=False) if include_line_numbers_error: return include_line_numbers_error _, include_storage_error = strict_include_storage(payload, "code.read") if include_storage_error: return include_storage_error include_text, include_text_error = strict_bool_argument(payload, "include_text", method="code.read", default=True) if include_text_error: return include_text_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="code.read", default=60, minimum=1) if timeout_error: return timeout_error max_chars = int(payload.get("max_chars") or 0) if payload.get("max_chars") not in {None, ""} else 100000 state = str(payload.get("state") or "working").strip().lower() if state not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument("code.read", "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) object_selector, _ = _code_query_object_selector(payload) module_ref_value = str(first_non_empty_arg(payload, "module_ref", "module_id") or "").strip() module_ref_table = "" if module_ref_value: parsed_table, _parsed_file_name, _parsed_stream = parse_module_id(module_ref_value) module_ref_table = parsed_table saved_state_module_ref = bool(module_ref_table in FORM_ELEMENT_SAVED_STATE_TABLES) mapped_saved_module_ref = ( code_read_saved_counterpart_module_ref(base_id_or_error, module_ref_value, payload) if module_ref_value and not saved_state_module_ref and state in {"working", "save", "both"} else None ) saved_read_module_ref = module_ref_value if saved_state_module_ref else mapped_saved_module_ref indexed_module_ref = ( saved_read_module_ref if state in {"working", "save"} and saved_read_module_ref else module_ref_value if state == "active" or configuration_view == "runtime_applied" else "" ) if indexed_module_ref and state != "both": indexed_state = ( {"source": "saved_state", "activation_state": "not_activated"} if str(indexed_module_ref).startswith(("ConfigSave:", "ConfigCASSave:")) else {"source": "active", "activation_state": "active"} ) indexed_result = code_read_indexed_fragment( payload, base_id=base_id_or_error, module_ref=indexed_module_ref, include_text=bool(include_text), current_state=indexed_state, ) if indexed_result is not None: if truthy(include_line_numbers): selection = indexed_result.get("selection") if isinstance(indexed_result.get("selection"), dict) else {} if selection.get("line_start") is not None: indexed_result["line"] = int(selection.get("line_start") or 1) indexed_result["column"] = 1 return attach_effective_routine_chain( annotate_configuration_view(indexed_result, configuration_view), payload, view=configuration_view, ) is_saved_state_common_form_request = canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) == "CommonForm" and bool( payload.get("object_name") or payload.get("name") or payload.get("object_guid") or payload.get("guid") ) saved_state_result = None if state in {"working", "save", "both"}: if saved_read_module_ref: saved_read_payload = { **payload, "base_id": base_id_or_error, "module_ref": saved_read_module_ref, "module_id": saved_read_module_ref, "include_storage": False, "include_text": bool(include_text), "max_chars": max_chars, "state": "save", } saved_read_payload.pop("include_line_numbers", None) saved_state_result = read_module(saved_read_payload) if state == "working" and isinstance(saved_state_result, dict) and saved_state_result.get("status") == "source_missing": saved_table, saved_file_name, saved_stream = parse_module_id(saved_read_module_ref) active_table = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}.get(saved_table, "") active_module_ref = module_ref_value if mapped_saved_module_ref else ( f"{active_table}:{saved_file_name}" + (f"#stream:{saved_stream}" if saved_stream is not None else "") if active_table and saved_file_name else "" ) if active_module_ref: active_fallback = read_module({ **saved_read_payload, "module_ref": active_module_ref, "module_id": active_module_ref, "state": "active", }) if isinstance(active_fallback, dict) and active_fallback.get("status") in {"ok", "summary", "text"}: saved_state_result = dict(active_fallback) saved_state_result["current_state"] = {"source": "active_fallback", "activation_state": "active"} saved_state_result["diagnostics"] = { **(active_fallback.get("diagnostics") if isinstance(active_fallback.get("diagnostics"), dict) else {}), "message": "Saved working copy was absent; the effective view fell back to the corresponding active module.", } # A public object/module selector may resolve its active stream # only after this branch. In that first-write case there is no # saved counterpart to probe; defer to the active public route # below instead of returning its expected source_missing result. if ( state == "working" and isinstance(saved_state_result, dict) and saved_state_result.get("status") == "source_missing" and not module_ref_value ): saved_state_result = None if isinstance(saved_state_result, dict): saved_state_result = dict(saved_state_result) saved_state_result["schema"] = "onec_code_read.v1" saved_state_result["method"] = "code.read" saved_state_result["source"] = { "kind": "code_read", "target": "module_or_routine", "include_line_numbers": bool(include_line_numbers), } saved_state_result["current_state"] = {"source": "saved_state", "activation_state": "not_activated"} else: saved_state_result = code_saved_state_common_form_read( {**payload, "base_id": base_id_or_error}, include_text=bool(include_text), include_line_numbers=bool(include_line_numbers), max_chars=int(max_chars or 100000), timeout_seconds=int(timeout_seconds or 60), ) if saved_state_result is not None and state != "both": saved_state_result = enrich_code_read_logical_owner(saved_state_result, payload, base_id=base_id_or_error) return attach_effective_routine_chain( annotate_configuration_view(saved_state_result, configuration_view), payload, view=configuration_view, ) if state == "save" and (is_saved_state_common_form_request or saved_read_module_ref): return annotate_configuration_view({ "schema": "onec_code_read.v1", "method": "code.read", "status": "not_found", "error": "saved_state_code_not_found", "base_id": base_id_or_error, "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "diagnostics": {"message": "Saved-state code was not found for the requested CommonForm selector."}, }, configuration_view) read_payload = { "base_id": base_id_or_error, "include_storage": False, "include_text": bool(include_text), "preview": truthy(payload.get("preview")), } read_payload.update(payload) # A module_ref obtained from code.search is already an exact SQL target. # Runtime reads must not spend the request budget rediscovering its owner # through all extension definitions. Callers that need presentation data # can opt in explicitly with resolve_owner=true. if ( module_ref_value and configuration_view == "runtime_applied" and "resolve_owner" not in payload and not (object_selector.get("kind") or object_selector.get("guid") or object_selector.get("name")) ): read_payload["resolve_owner"] = False if state == "both": read_payload["state"] = "active" if saved_state_module_ref: active_counterpart_ref = code_read_active_counterpart_module_ref(module_ref_value) if active_counterpart_ref: read_payload["module_ref"] = active_counterpart_ref read_payload["module_id"] = active_counterpart_ref else: read_payload.pop("module_ref", None) read_payload.pop("module_id", None) read_payload.pop("include_line_numbers", None) if "query" in read_payload: read_payload.pop("query") read_payload.update(object_selector) read_payload["max_chars"] = max_chars if "mode" in read_payload and read_payload["mode"] is not None and str(read_payload["mode"]).strip() == "summary": read_payload["mode"] = "summary" result = read_module(read_payload) if state == "both" and saved_state_module_ref and result.get("status") not in {"ok", "summary", "text"}: logical_selector = code_read_active_counterpart_selector( saved_state_result, payload, module_ref=module_ref_value, ) if logical_selector: logical_payload = dict(read_payload) logical_payload.pop("module_ref", None) logical_payload.pop("module_id", None) logical_payload.update(logical_selector) logical_result = read_module(logical_payload) if logical_result.get("status") in {"ok", "summary", "text"}: result = dict(logical_result) result["counterpart_resolution"] = { "status": "resolved", "strategy": "logical_owner_and_module_role", "selector": { key: logical_selector.get(key) for key in ("kind", "name", "guid", "extension_guid", "module", "module_ordinal") if logical_selector.get(key) not in {None, ""} }, } else: result = dict(result) result["counterpart_resolution"] = { "status": "not_found", "strategy": "direct_module_ref_then_logical_owner", "logical_status": logical_result.get("status"), "logical_error": logical_result.get("error"), } else: result = dict(result) result["counterpart_resolution"] = { "status": "not_resolved", "strategy": "direct_module_ref", "diagnostics": { "message": "The direct active counterpart was absent and the saved module did not expose an unambiguous logical owner selector.", }, } if result.get("status") not in {"ok", "summary", "text"}: result = dict(result) result["method"] = "code.read" result["current_state"] = {"source": "active", "activation_state": "active"} if state == "both": return annotate_configuration_view(code_read_both_response( payload, base_id=base_id_or_error, saved_result=saved_state_result, active_result=result, include_text=bool(include_text), ), configuration_view) return annotate_configuration_view(result, configuration_view) result["schema"] = "onec_code_read.v1" result["method"] = "code.read" result["source"] = { "kind": "code_read", "target": "module_or_routine", "include_line_numbers": bool(include_line_numbers), } result["current_state"] = {"source": "active", "activation_state": "active"} if state == "both": return annotate_configuration_view(code_read_both_response( payload, base_id=base_id_or_error, saved_result=saved_state_result, active_result=result, include_text=bool(include_text), ), configuration_view) if truthy(include_line_numbers): selection = result.get("selection") or {} if selection.get("line_start") is not None: result["line"] = int(selection.get("line_start")) result["column"] = 1 if result.get("text_range", {}).get("offset") is not None: line, column = _snippet_to_line_column(str(result.get("text") or ""), int(result.get("text_range", {}).get("offset"))) result["line"], result["column"] = line, column elif result.get("text_range", {}).get("offset") is not None and result.get("text"): line, column = _snippet_to_line_column(str(result.get("text") or ""), int(result.get("text_range", {}).get("offset"))) result["line"] = line result["column"] = column result_owner = result.get("owner") if isinstance(result.get("owner"), dict) else {} result_origin = result.get("origin") if isinstance(result.get("origin"), dict) else {} result_extension = result_origin.get("extension") if isinstance(result_origin.get("extension"), dict) else {} public_owner = { "kind": result_owner.get("kind") or object_selector.get("kind"), "guid": result_owner.get("guid") or object_selector.get("guid"), "name": result_owner.get("name") or object_selector.get("name"), } result["resolved_owner"] = public_owner if not (module_ref_value and configuration_view == "runtime_applied" and read_payload.get("resolve_owner") is False): result = enrich_code_read_logical_owner(result, payload, base_id=base_id_or_error) result_module = result.get("module") if isinstance(result.get("module"), dict) else {} result_routine = str((result.get("selection") or {}).get("routine_name") or payload.get("routine_name") or "").strip() if ( public_owner.get("kind") and public_owner.get("name") and result_extension.get("name") and module_ref_value ): canonical_path = ( ".".join([RU_KIND.get(str(public_owner["kind"]), str(public_owner["kind"])), str(public_owner["name"]), result_routine]) if result_routine else None ) result["write_selector"] = { "target": { "kind": "module", "extension": result_extension.get("name"), "ref": object_selector_ref(public_owner.get("kind"), public_owner.get("name")), "module": result_module.get("kind") or "bsl_module", "module_ref": module_ref_value, "concrete_reference": module_ref_value, "layer_id": f"extension:{result_extension.get('guid')}" if result_extension.get("guid") else None, **({"module_ordinal": result_module.get("module_ordinal")} if result_module.get("module_ordinal") is not None else {}), **({"routine_name": result_routine} if result_routine else {}), **({"canonical_path": canonical_path} if canonical_path else {}), } } result["resolved_selector"] = { "base_id": base_id_or_error, **({ "kind": object_selector.get("kind")} if object_selector.get("kind") else {}), **({"guid": object_selector.get("guid")} if object_selector.get("guid") is not None else {}), **({"name": object_selector.get("name")} if object_selector.get("name") is not None else {}), **({"module_id": result.get("module_id")} if result.get("module_id") else {}), **({"module_ordinal": first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number")} if first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") is not None else {}), } return attach_effective_routine_chain( annotate_configuration_view(result, configuration_view), payload, view=configuration_view, ) def _code_binding_extract_placeholders(text: str) -> list[str]: if not isinstance(text, str): return [] pattern = re.compile(r"\{([^\{\}]+)\}") matches = pattern.findall(text.replace("\r\n", "\n")) return [value.strip() for value in matches if str(value or "").strip()] def _template_bsl_usage_trace(text: str, template_name: str) -> dict[str, Any]: """Extract conservative print-template area and parameter links from one BSL module. This deliberately describes only direct, static BSL assignments. It must not pretend that a variable area name, a helper call, or ``Параметры.Заполнить`` has been resolved to individual template cells. """ if not isinstance(text, str) or not str(template_name or "").strip(): return {"status": "not_found", "template_retrievals": [], "areas": []} lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") # BSL frequently wraps a calculation over several lines. Work in complete # semicolon-terminated statements, while retaining the original first line # for a stable navigation target. statements: list[tuple[int, str]] = [] statement_start = 0 statement_parts: list[str] = [] for line_number, line in enumerate(lines, 1): stripped = line.strip() if not statement_parts and (not stripped or stripped.startswith("//")): continue if not statement_parts: statement_start = line_number statement_parts.append(stripped) if ";" in stripped: statements.append((statement_start, " ".join(part for part in statement_parts if part))) statement_parts = [] escaped_template = re.escape(str(template_name).strip()) retrieval_re = re.compile( rf'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*' rf'(?:[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*\.)*' rf'(?:МакетПечатнойФормы|ПолучитьМакет)\s*\(\s*"[^"]*{escaped_template}[^"]*"\s*\)', re.IGNORECASE, ) area_re = re.compile( r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*' r'([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.ПолучитьОбласть\s*\(\s*"([^"]+)"\s*\)', re.IGNORECASE, ) parameter_re = re.compile( r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.Параметры\.' r'([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;?\s*$', re.IGNORECASE, ) fill_re = re.compile( r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.Параметры\.Заполнить\s*\(\s*(.+?)\s*\)\s*;?\s*$', re.IGNORECASE, ) template_variables: set[str] = set() retrievals: list[dict[str, Any]] = [] for line_number, line in statements: match = retrieval_re.match(line) if match: variable = match.group(1) template_variables.add(normalize(variable)) retrievals.append({"variable": variable, "line": line_number, "expression": line.strip()}) areas_by_variable: dict[str, dict[str, Any]] = {} for line_number, line in statements: match = area_re.match(line) if not match or normalize(match.group(2)) not in template_variables: continue area = { "name": match.group(3), "variable": match.group(1), "line": line_number, "parameters": [], "bulk_fill_sources": [], } areas_by_variable[normalize(match.group(1))] = area local_assignment_re = re.compile( r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;\s*$', re.IGNORECASE, ) member_assignment_re = re.compile( r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;\s*$', re.IGNORECASE, ) local_assignments: dict[str, list[dict[str, Any]]] = {} member_assignments: dict[tuple[str, str], list[dict[str, Any]]] = {} for line_number, line in statements: local_match = local_assignment_re.match(line) if local_match: local_assignments.setdefault(normalize(local_match.group(1)), []).append({ "line": line_number, "expression": local_match.group(2).strip(), }) member_match = member_assignment_re.match(line) if member_match: member_assignments.setdefault((normalize(member_match.group(1)), normalize(member_match.group(2))), []).append({ "line": line_number, "expression": member_match.group(3).strip(), }) def resolve_local_expression(expression: str, before_line: int) -> dict[str, Any] | None: # Do not infer arbitrary code semantics. Resolve only a bare local # variable to its last preceding assignment in this static module text. if not re.fullmatch(r"[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*", expression or ""): return None candidates = [item for item in local_assignments.get(normalize(expression), []) if item["line"] < before_line] if not candidates: return None item = candidates[-1] return {"variable": expression, "expression": item["expression"], "line": item["line"], "resolution": "local_static_assignment"} def resolve_wrapped_local_expression(expression: str, before_line: int) -> dict[str, Any] | None: # Formatting/value-conversion wrappers preserve a visible first input. # For any other call the adapter leaves the expression opaque rather than # inventing an interprocedural data lineage. match = re.match( r'^\s*(Формат|Строка|Окр|ЦЕЛ)\s*\(\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)(?:\s*,|\s*\))', expression or "", re.IGNORECASE, ) if not match: return None resolved = resolve_local_expression(match.group(2), before_line) if resolved is None: return None return {"wrapper": match.group(1), "input": resolved, "resolution": "wrapped_local_static_assignment"} def resolve_member_expression(expression: str, before_line: int) -> dict[str, Any] | None: match = re.fullmatch(r"([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)", expression or "") if not match: return None variable, field = match.group(1), match.group(2) candidates = [ item for item in member_assignments.get((normalize(variable), normalize(field)), []) if item["line"] < before_line ] if not candidates: return None item = candidates[-1] source_fields = [] for source_variable, source_field in re.findall( r"\b([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)", item["expression"] ): if normalize(source_variable) == normalize(variable) and normalize(source_field) == normalize(field): continue source_fields.append({"expression": f"{source_variable}.{source_field}", "variable": source_variable, "field": source_field}) return { "member": expression, "expression": item["expression"], "line": item["line"], "resolution": "member_static_assignment", **({"source_fields": source_fields} if source_fields else {}), **({"aggregation": "accumulation"} if re.search(rf"\b{re.escape(variable)}\.{re.escape(field)}\s*\+", item["expression"], re.IGNORECASE) else {}), } for line_number, line in statements: parameter_match = parameter_re.match(line) if parameter_match: area = areas_by_variable.get(normalize(parameter_match.group(1))) if area is not None: parameter = { "name": parameter_match.group(2), "expression": parameter_match.group(3).rstrip(";").strip(), "line": line_number, "resolution": "static_assignment", } local_expression = resolve_local_expression(parameter["expression"], line_number) if local_expression: parameter["expression_lineage"] = local_expression else: wrapped_expression = resolve_wrapped_local_expression(parameter["expression"], line_number) if wrapped_expression: parameter["expression_lineage"] = wrapped_expression else: member_expression = resolve_member_expression(parameter["expression"], line_number) if member_expression: parameter["expression_lineage"] = member_expression area["parameters"].append(parameter) continue fill_match = fill_re.match(line) if fill_match: area = areas_by_variable.get(normalize(fill_match.group(1))) if area is not None: area["bulk_fill_sources"].append({ "expression": fill_match.group(2).rstrip(";").strip(), "line": line_number, "resolution": "bulk_fill_unexpanded", }) areas = list(areas_by_variable.values()) report_calls: list[dict[str, Any]] = [] seen_report_calls: set[tuple[str, str, int]] = set() report_call_re = re.compile( r"Отчеты\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(", re.IGNORECASE, ) for line_number, statement in statements: for call_match in report_call_re.finditer(statement): key = (normalize(call_match.group(1)), normalize(call_match.group(2)), line_number) if key in seen_report_calls: continue seen_report_calls.add(key) report_calls.append({ "report": f"Report.{call_match.group(1)}", "method": call_match.group(2), "line": line_number, "resolution": "static_external_report_call", }) return { "status": "confirmed" if retrievals else "not_found", "template_retrievals": retrievals, "areas": areas, "external_report_calls": report_calls, "diagnostics": {"scope": "direct_static_bsl", "unexpanded_bulk_fill": any(area["bulk_fill_sources"] for area in areas)}, } def templates_bindings(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "templates.bindings") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "templates.bindings") if isinstance(base_id_or_error, dict): return base_id_or_error object_selector, _ = _code_query_object_selector(payload) include_storage, include_storage_error = strict_include_storage(payload, "templates.bindings") if include_storage_error: return include_storage_error template_name_filter = payload.get("template") template_query = { **payload, "base_id": base_id_or_error, **({"name_filter": template_name_filter} if template_name_filter else {}), "include_text": True, "include_tree": False, "include_storage": bool(include_storage), } template_query.update(object_selector) templates_result = metadata_object_templates(template_query) if templates_result.get("status") != "ok": return { "schema": "onec_templates_bindings.v1", "status": "error", "base_id": base_id_or_error, "method": "templates.bindings", "source": templates_result.get("source"), "object": templates_result.get("object"), "diagnostics": templates_result.get("diagnostics", {"message": "Failed to read templates."}), "bindings": [], } bindings: list[dict[str, Any]] = [] index_refresh_job: dict[str, Any] | None = None for template in templates_result.get("templates") or []: if not isinstance(template, dict): continue source_text = template.get("text") or template.get("text_preview") or "" placeholders = _code_binding_extract_placeholders(str(source_text)) template_kind = str(template.get("kind") or "").strip() binding_item = { "template": { "name": template.get("name"), "presentation": template.get("presentation") or template.get("name"), "file_name": template.get("file_name"), **({"kind": template_kind} if template_kind else {}), }, "bindings": sorted(set(placeholders)), "binding_count": len(set(placeholders)), "format": template_kind if template_kind == "data_composition_schema" else (template.get("format") or template_kind), } template_name = str(template.get("name") or "").strip() if template_name: indexed_usage = metadata_code_index_search({ "base_id": base_id_or_error, **object_selector, "query": template_name, "limit": 30, "scan_limit": 200, "strict": True, "verify": True, }) indexed_matches = indexed_usage.get("matches") if indexed_usage.get("status") == "ok" and isinstance(indexed_usage.get("matches"), list) else [] if indexed_matches: usage = indexed_usage usage_matches = indexed_matches usage_source = "code_index_live_sha1_verified" else: usage = search_modules({ "base_id": base_id_or_error, **object_selector, "query": template_name, "state": "working", "limit": 30, "scan_limit": 200, "resolve_owners": True, }) usage_matches = usage.get("matches") if isinstance(usage.get("matches"), list) else [] usage_source = "live_sql_fallback" # Do not make the current answer wait for index maintenance. # It was already produced from live SQL. A bounded object-only # job makes the next identical lookup eligible for the verified # fast path without ever treating the stale index as evidence. if usage_matches and index_refresh_job is None: index_refresh_job = adapter_start_job({ "method": "metadata.code_index.build", "payload": { "base_id": base_id_or_error, **object_selector, "max_items": 200, "scan_limit": 1000, "include_vectors": False, }, "timeout_seconds": 60, }) code_usage: dict[str, Any] = { "status": "confirmed" if usage_matches else "not_found", "source": usage_source, **({"index_refresh": {key: index_refresh_job.get(key) for key in ("status", "job_id", "method") if index_refresh_job.get(key) is not None}} if usage_source == "live_sql_fallback" and index_refresh_job else {}), "matches": [ { key: match.get(key) for key in ("owner", "module", "routine", "line", "preview", "snippet", "read_selector") if match.get(key) is not None and match.get(key) != "" } for match in usage_matches[:30] if isinstance(match, dict) ], **({"diagnostics": usage.get("diagnostics")} if usage.get("diagnostics") else {}), } # A text hit by itself is not enough for a print-form lineage. Read # only the already object-scoped modules and expose direct static # Макет -> Область -> Параметры links. The bounded read count keeps # this metadata call from becoming a global code scan. trace_cache: dict[str, dict[str, Any]] = {} traces: list[dict[str, Any]] = [] trace_errors: list[dict[str, Any]] = [] for match in usage_matches[:12]: if not isinstance(match, dict): continue selector = match.get("read_selector") if not isinstance(selector, dict): continue read_payload = {key: value for key, value in selector.items() if key != "method"} read_payload.update({ "base_id": base_id_or_error, "configuration_view": "effective_working", "preview": False, "max_chars": 250000, }) cache_key = json.dumps(read_payload, ensure_ascii=False, sort_keys=True, default=str) read_result = trace_cache.get(cache_key) if read_result is None: read_result = code_read(read_payload) trace_cache[cache_key] = read_result if read_result.get("status") != "ok": trace_errors.append({ "owner": match.get("owner"), "module": match.get("module"), "error": read_result.get("error") or read_result.get("status"), }) continue trace = _template_bsl_usage_trace(str(read_result.get("text") or ""), template_name) if trace.get("status") != "confirmed": continue traces.append({ "owner": match.get("owner"), "module": match.get("module"), "routine": match.get("routine"), "read_selector": selector, **trace, }) if traces: code_usage["static_traces"] = traces if trace_errors: code_usage["trace_diagnostics"] = { "status": "partial", "unread_modules": trace_errors, "message": "Some already matched modules could not be read for static template lineage.", } binding_item["code_usage"] = code_usage if bool(include_storage): binding_item["source"] = { "kind": "live_sql", "table": template.get("table"), "part_id": template.get("part_id"), "encoding": template.get("encoding"), } bindings.append(binding_item) return { "schema": "onec_templates_bindings.v1", "status": "ok" if bindings else "not_found", **({"error": "not_found"} if not bindings else {}), "base_id": base_id_or_error, "method": "templates.bindings", "object": templates_result.get("object"), "source": templates_result.get("source"), "query": { "kind": object_selector.get("kind"), "name": object_selector.get("name"), "guid": object_selector.get("guid"), "template": template_name_filter, }, "bindings": bindings, "counts": {"templates": len(bindings), "total_bindings": sum((len(item.get("bindings") or []) for item in bindings))}, "diagnostics": templates_result.get("diagnostics", {}), } def template_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: """Return the small, evidence-backed part of a print-form parameter lineage.""" normalized = normalize_object_selector_aliases(payload, "template.field.lineage") if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": return normalized template_name = str(normalized.get("template") or "").strip() field_name = str(normalized.get("field") or "").strip() area_name = str(normalized.get("area") or "").strip() resolve_scd = truthy(normalized.get("resolve_scd")) result = templates_bindings({**normalized, "template": template_name}) base_result = { "schema": "onec_template_field_lineage.v1", "method": "template.field.lineage", "base_id": normalized.get("base_id"), "query": { "template": template_name, "field": field_name, **({"area": area_name} if area_name else {}), }, "object": result.get("object"), } if result.get("status") != "ok": return {**base_result, "status": result.get("status") or "error", "error": result.get("error"), "lineage": [], "diagnostics": result.get("diagnostics", {})} # A DataCompositionSchema is itself the field producer. Do not require a # BSL assignment such as ``Area.Parameters``: reports normally hand this # template to the platform, so the correct static route is straight into # the SQL-decoded SCD field graph. dcs_template = next( ( binding for binding in result.get("bindings") or [] if isinstance(binding, dict) and normalize(str((binding.get("template") or {}).get("name") or "")) == normalize(template_name) and str((binding.get("template") or {}).get("kind") or "") == "data_composition_schema" ), None, ) object_card = result.get("object") if isinstance(result.get("object"), dict) else {} if resolve_scd and dcs_template and canonical_kind(str(object_card.get("kind") or "")) == "Report": dcs_result = dcs_field_lineage({ "base_id": normalized.get("base_id"), "report": object_card.get("name"), "schema": template_name, "field": field_name, **({"extension": normalized.get("extension")} if normalized.get("extension") not in {None, ""} else {}), **({"extension_guid": normalized.get("extension_guid")} if normalized.get("extension_guid") not in {None, ""} else {}), }) confirmed_sources = (dcs_result.get("agent_summary") or {}).get("confirmed_sources") or [] direct_status = "confirmed" if confirmed_sources else ("partial" if dcs_result.get("status") == "ok" else dcs_result.get("status") or "not_found") direct_lineage = [{ "template": template_name, "template_kind": "data_composition_schema", "field": field_name, "resolution": "direct_scd_field_lineage", "dcs_field_lineage": dcs_result, }] return { **base_result, "status": direct_status, **({"error": "not_found"} if direct_status == "not_found" else {}), "lineage": direct_lineage, "source_context_candidates": [{ "report": object_selector_ref("Report", str(object_card.get("name") or "")), "method": "dcs.field.lineage", "status": direct_status, "resolution": "selected_data_composition_schema", "confirmed_sources": confirmed_sources, }], "counts": {"matches": len(direct_lineage), "source_context_candidates": 1}, "diagnostics": {"scope": "direct_sql_scd", "message": "Selected template is a decoded DataCompositionSchema; field lineage was resolved directly without a BSL assignment."}, } matches: list[dict[str, Any]] = [] producer_candidates: list[dict[str, Any]] = [] producer_seen: set[tuple[str, str, int]] = set() trace_seen = False for binding in result.get("bindings") or []: if not isinstance(binding, dict) or normalize(str((binding.get("template") or {}).get("name") or "")) != normalize(template_name): continue usage = binding.get("code_usage") if isinstance(binding.get("code_usage"), dict) else {} for trace in usage.get("static_traces") or []: if not isinstance(trace, dict): continue trace_seen = True for call in trace.get("external_report_calls") or []: if not isinstance(call, dict) or "таблицаданных" not in normalize(str(call.get("method") or "")): continue candidate_key = (str(call.get("report") or ""), str(call.get("method") or ""), int(call.get("line") or 0)) if candidate_key not in producer_seen: producer_seen.add(candidate_key) producer_candidates.append({**call, "status": "candidate", "reason": "Static report call in the same print module; field-to-dataset mapping still requires SCD analysis."}) for area in trace.get("areas") or []: if not isinstance(area, dict) or (area_name and normalize(str(area.get("name") or "")) != normalize(area_name)): continue for parameter in area.get("parameters") or []: if not isinstance(parameter, dict) or normalize(str(parameter.get("name") or "")) != normalize(field_name): continue matches.append({ "template": template_name, "area": {"name": area.get("name"), "variable": area.get("variable"), "line": area.get("line")}, "parameter": parameter, "code": {key: trace.get(key) for key in ("owner", "module", "routine", "read_selector") if trace.get(key) is not None}, }) status = "confirmed" if matches else ("partial" if trace_seen else "not_found") diagnostics: dict[str, Any] = {"scope": "direct_static_bsl"} if status == "partial": diagnostics["message"] = "Template code was found, but the requested parameter is populated dynamically, through a helper, or in an unresolved area." elif status == "not_found": diagnostics["message"] = "No static BSL assignment was found for the requested template parameter." if resolve_scd: for candidate in producer_candidates[:1]: report_ref = str(candidate.get("report") or "") report_name = report_ref.split(".", 1)[1] if report_ref.startswith("Report.") else report_ref dcs_result = dcs_field_lineage({"base_id": normalized.get("base_id"), "report": report_name, "field": field_name}) candidate["dcs_field_lineage"] = dcs_result candidate["status"] = "confirmed" if (dcs_result.get("agent_summary") or {}).get("confirmed_sources") else candidate["status"] if not producer_candidates: diagnostics["scd_resolution"] = "skipped_no_static_data_producer_candidate" return { **base_result, "status": status, **({"error": "not_found"} if status == "not_found" else {}), "lineage": matches, "source_context_candidates": producer_candidates, "counts": {"matches": len(matches), "source_context_candidates": len(producer_candidates)}, "diagnostics": diagnostics, } def metadata_extension_action_from_evidence( *, source: str, method_name: str, read_result: dict[str, Any] | None = None, module: dict[str, Any] | None = None, ) -> dict[str, Any]: origin = (read_result or {}).get("origin") if isinstance((read_result or {}).get("origin"), dict) else {} selection = (read_result or {}).get("selection") if isinstance((read_result or {}).get("selection"), dict) else {} evidence_sources = [origin, selection, module or {}] raw_action = "" for evidence in evidence_sources: for key in ("operation_class", "action_class", "extension_action", "operation", "action", "change_kind"): value = str(evidence.get(key) or "").strip() if value: raw_action = value break if raw_action: break if not raw_action and source == "extension": source_text = str((read_result or {}).get("text") or (read_result or {}).get("preview") or "") annotation = re.search( r"&\s*(Перед|После|Вместо|ИзменениеИКонтроль)\s*\(\s*[\"']?([^\"'\)\s]+)", source_text, re.IGNORECASE, ) if annotation and normalize(annotation.group(2)) == normalize(method_name): raw_action = { "перед": "insert_before", "после": "insert_after", "вместо": "replace", "изменениеиконтроль": "replace_with_control", }.get(normalize(annotation.group(1)), "") if raw_action: operation_class = metadata_write_plan_operation_class(raw_action) else: operation_class = "" if source != "extension": return { "status": "ok", "source": "configuration", "routine": method_name, "operation_class": operation_class or "base_definition", "requires_control_fragment": False, } known_extension_operations = {"insert_before", "insert_after", "replace", "replace_with_control"} if operation_class in known_extension_operations: return { "status": "ok", "source": "extension", "routine": method_name, "operation_class": operation_class, "raw_action": raw_action, "requires_control_fragment": operation_class == "replace_with_control", } return { "status": "unknown", "source": "extension", "routine": method_name, "operation_class": "unknown_extension_action", "requires": [ "extension routine action evidence: insert_before, insert_after, replace, or replace_with_control", "controlled base fragment when operation is replace_with_control", ], "diagnostics": { "message": "Routine text was found in an extension layer, but the adapter has not resolved the extension action type yet. Do not treat this as a plain replace without action metadata." }, } def metadata_resolve_overrides_write_plan_evidence( *, base_id: str, method_name: str, object_payload: dict[str, Any], chain: list[dict[str, Any]], ) -> dict[str, Any]: object_kind = object_payload.get("kind") or object_payload.get("type") if object_payload else None object_name = object_payload.get("name") if object_payload else None object_guid = object_payload.get("guid") if object_payload else None object_ref = object_selector_ref(str(object_kind or ""), str(object_name or "")) evidence: dict[str, Any] = { "method": METADATA_WRITE_PLAN_METHOD, "base_id": base_id, "target": { "kind": "module", "routine_name": method_name, }, "next_resolution": { "method": SAVED_STATE_MODULES_SEARCH_METHOD, "params": { "base_id": base_id, "query": method_name, "limit": 10, **({"ref": object_ref} if object_ref else {}), }, }, "diagnostics": { "message": "Pass target.extension_action into metadata.write.plan together with a concrete saved-state module route before apply." }, } if object_payload: if object_kind: evidence["target"]["object_type"] = object_kind evidence["next_resolution"]["params"]["object_type"] = object_kind if object_name: evidence["target"]["object_name"] = object_name evidence["next_resolution"]["params"]["object_name"] = object_name if object_guid: evidence["target"]["object_guid"] = object_guid if object_kind and object_name: canonical = metadata_write_plan_path_parts(f"{object_kind}.{object_name}.{method_name}") if canonical.get("is_full_path") and canonical.get("path_kind") == "module_routine": evidence["target"]["canonical_path"] = canonical.get("canonical_path") extension_actions = [ item.get("extension_action") for item in chain if item.get("source") == "extension" and isinstance(item.get("extension_action"), dict) ] if len(extension_actions) == 1: evidence["target"]["extension_action"] = extension_actions[0] evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" action_class = metadata_write_plan_operation_class(str(extension_actions[0].get("operation_class") or "")) if action_class not in {"", "unknown_extension_action", "base_definition", "extension_definition"}: evidence["intent"] = {"operation": action_class} elif extension_actions: evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" evidence["extension_actions"] = extension_actions evidence["diagnostics"]["message"] = "Multiple extension actions were found; narrow the extension/module before building metadata.write.plan." else: base_actions = [ item.get("extension_action") for item in chain if item.get("source") == "configuration" and isinstance(item.get("extension_action"), dict) and item.get("extension_action", {}).get("operation_class") == "base_definition" ] if base_actions: evidence["next_resolution"]["params"]["layer"] = "base_saved_state" return evidence def metadata_resolve_overrides(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.resolve_overrides") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.resolve_overrides") if isinstance(base_id_or_error, dict): return base_id_or_error method_name = str(payload.get("method_name") or "").strip() if not method_name: return invalid_argument("metadata.resolve_overrides", "method_name", "method_name is required.") state = str(payload.get("state") or "working").strip().lower() if state not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument("metadata.resolve_overrides", "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) object_selector, _ = _code_query_object_selector(payload) if not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument( "metadata.resolve_overrides", "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL, ) object_ref = { **({"kind": object_selector.get("kind")} if object_selector.get("kind") else {}), **({"name": object_selector.get("name")} if object_selector.get("name") is not None else {}), **({"guid": object_selector.get("guid")} if object_selector.get("guid") is not None else {}), "base_id": base_id_or_error, } extension_filter = str(payload.get("extension") or "").strip() # Internal storage handles are required to read the discovered modules. # They remain hidden from the public response below. modules_result = metadata_object_modules({**object_ref, "include_storage": True}) if modules_result.get("status") != "ok" and not (extension_filter and state in {"working", "save", "both"}): return public_error_result(modules_result, include_storage=False, method="metadata.resolve_overrides") modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] if modules_result.get("status") == "ok" else [] public_object = public_metadata_row(modules_result.get("object") or {}, include_storage=False) public_object_origin = public_object.get("origin") if isinstance(public_object.get("origin"), dict) else {} extension_owned_object = public_object_origin.get("source") == "extension" and not extension_filter chain: list[dict[str, Any]] = [] if extension_filter and state in {"working", "save", "both"}: extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="metadata.resolve_overrides") if not extension_error and extension_guid: saved_modules_result = search_modules( { "base_id": base_id_or_error, "extension": extension_filter, "query": method_name, "routine_name": method_name, "state": "save", "limit": int(payload.get("saved_state_limit") or 50), "scan_limit": int(payload.get("scan_limit") or 1000), "include_storage": False, "timeout_seconds": int(payload.get("timeout_seconds") or 60), } ) for module_match in saved_modules_result.get("matches") or []: if not isinstance(module_match, dict): continue read_selector = module_match.get("read_selector") if isinstance(module_match.get("read_selector"), dict) else {} module_ref = str(read_selector.get("module_ref") or "") if not module_ref: continue read_result = read_module( { "base_id": base_id_or_error, "module_ref": module_ref, "routine_name": method_name, "include_storage": False, "max_chars": 100000, "include_text": True, "preview": True, } ) if read_result.get("status") not in {"ok", "summary"}: continue selection = read_result.get("selection") or {} if selection.get("status") == "not_found": continue module_info = module_match.get("module") if isinstance(module_match.get("module"), dict) else {} chain.append( { "order": len(chain) + 1, "mechanism": "routine", "method": method_name, "source": "saved_state", "activation_state": module_match.get("activation_state") or "saved_state", "extension_action": metadata_extension_action_from_evidence( source="extension", method_name=method_name, read_result=read_result, module={"module_id": module_ref}, ), "line_start": selection.get("line_start"), "line_end": selection.get("line_end"), "match_by": selection.get("match_by"), "module": { "module_ref": module_ref, "name": module_info.get("name") or "Saved-state module", "form": module_info.get("form"), "stream_index": module_info.get("stream_index"), }, "read_selector": { "base_id": base_id_or_error, "module_ref": module_ref, "routine_name": method_name, "preview": True, "max_chars": 100000, }, } ) elif extension_error: return extension_error if state == "save": # Object-scoped saved counterparts are resolved below. The explicit # extension scan above remains a fallback for callers that supplied # only an extension selector. modules = modules if modules_result.get("status") == "ok" else [] resolved_module_ordinals: set[int] = set() for ordinal, module in enumerate(modules, start=1): module_id = str(module.get("module_id") or "") if not module_id: continue module_table, _module_file_name, _module_stream_index = parse_module_id(module_id) if module_table in {"Config", "ConfigCAS"}: saved_module_ref = code_read_saved_counterpart_module_ref(base_id_or_error, module_id, payload) if state == "active": candidate_module_refs = [module_id] elif state == "save": candidate_module_refs = [saved_module_ref] if saved_module_ref else [] elif state == "both": candidate_module_refs = [ref for ref in (saved_module_ref, module_id) if ref] else: candidate_module_refs = [ref for ref in (saved_module_ref, module_id) if ref] elif module_table in {"ConfigSave", "ConfigCASSave"}: active_module_ref = code_read_active_counterpart_module_ref(module_id) if state == "active": candidate_module_refs = [active_module_ref] if active_module_ref else [] elif state == "save": candidate_module_refs = [module_id] elif state == "both": candidate_module_refs = [ref for ref in (module_id, active_module_ref) if ref] else: candidate_module_refs = [ref for ref in (module_id, active_module_ref) if ref] else: candidate_module_refs = [module_id] selected_reads: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] for candidate_module_ref in dict.fromkeys(ref for ref in candidate_module_refs if ref): read_result = read_module( { "base_id": base_id_or_error, "module_ref": candidate_module_ref, "routine_name": method_name, "include_storage": False, "max_chars": 100000, "include_text": True, "preview": True, } ) if read_result.get("status") not in {"ok", "summary"}: continue selection = read_result.get("selection") or {} if selection.get("status") == "not_found": continue selected_reads.append((candidate_module_ref, read_result, selection)) if state != "both": break if not selected_reads: continue for selected_module_ref, read_result, selection in selected_reads: selected_table, _selected_file_name, _selected_stream_index = parse_module_id(selected_module_ref) source = "extension" if selected_table in {"ConfigCAS", "ConfigCASSave"} else "configuration" activation_state = "saved_state" if selected_table in {"ConfigSave", "ConfigCASSave"} else "active" extension_action = metadata_extension_action_from_evidence( source=source, method_name=method_name, read_result=read_result, module={**module, "module_ref": selected_module_ref}, ) if ( source == "extension" and extension_owned_object and extension_action.get("status") == "unknown" and extension_action.get("operation_class") == "unknown_extension_action" ): extension_action = { "status": "ok", "source": "extension", "routine": method_name, "operation_class": "extension_definition", "requires_control_fragment": False, "diagnostics": { "message": "The selected metadata object is defined by the extension and the routine has no interception annotation; it is an ordinary extension-owned routine, not an unresolved base override." }, } chain.append( { "order": len(chain) + 1, "mechanism": "routine", "method": method_name, "source": source, "activation_state": activation_state, "extension_action": extension_action, "line_start": selection.get("line_start"), "line_end": selection.get("line_end"), "match_by": selection.get("match_by"), "module": { "module_ordinal": ordinal, "name": public_module_row( module, include_storage=False, ordinal=ordinal, owner_kind=object_ref.get("kind"), owner_name=object_ref.get("name"), ).get("name"), **({"form": (module.get("form") or {}).get("name")} if isinstance(module.get("form"), dict) else {}), }, "read_selector": { "base_id": base_id_or_error, "module_ref": selected_module_ref, "routine_name": method_name, "preview": True, "max_chars": 100000, }, } ) resolved_module_ordinals.add(ordinal) return { "schema": "onec_metadata_resolve_overrides.v1", "status": "ok" if chain else "not_found", **({"error": "not_found"} if not chain else {}), "base_id": base_id_or_error, "method": "metadata.resolve_overrides", "object": public_object, "state": state, "target_method": method_name, "chain": chain, "extension_actions": [ item.get("extension_action") for item in chain if item.get("source") == "extension" and isinstance(item.get("extension_action"), dict) ], "write_plan_evidence": metadata_resolve_overrides_write_plan_evidence( base_id=base_id_or_error, method_name=method_name, object_payload=public_object, chain=chain, ), "counts": { "steps": len(chain), "resolved": len(resolved_module_ordinals), "not_found": max(0, int(len(modules) - len(resolved_module_ordinals))) if modules else 0, }, "diagnostics": { "message": "Chain is built from module text scan of discovered object modules. Extension names are inferred from module storage prefix; точное определение расширения требует metadata.module_owner_cache." } if chain else {"message": "Переопределения не найдены в доступных модулях объекта."}, } _STATIC_CALL_KEYWORDS = { "если", "иначеесли", "пока", "для", "каждого", "новый", "выполнить", "процедура", "функция", "возврат", "попытка", "исключение", "if", "elseif", "while", "for", "each", "new", "return", "try", "except", } def diagnostics_static_calls(text: str, *, line_offset: int = 0) -> list[dict[str, Any]]: """Extract conservative same-owner and CommonModule.Routine BSL calls.""" pattern = re.compile(r"(? dict[str, Any]: payload = normalize_object_selector_aliases(payload, "diagnostics.call_chain") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "diagnostics.call_chain") if isinstance(base_id_or_error, dict): return base_id_or_error method_name = str(payload.get("entry_method") or payload.get("method_name") or "").strip() if not method_name: return invalid_argument("diagnostics.call_chain", "entry_method", "entry_method is required.") module_ref = str(payload.get("module_ref") or "").strip() object_selector, _ = _code_query_object_selector(payload) if not module_ref and not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument("diagnostics.call_chain", "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) depth_value, depth_error = parse_int_argument(payload, "depth", method="diagnostics.call_chain", default=1, minimum=0, maximum=6) if depth_error: return depth_error max_nodes_value, max_nodes_error = parse_int_argument(payload, "max_nodes", method="diagnostics.call_chain", default=40, minimum=1, maximum=200) if max_nodes_error: return max_nodes_error timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="diagnostics.call_chain", default=30, minimum=1, maximum=120) if timeout_error: return timeout_error depth = int(depth_value or 1) max_nodes = int(max_nodes_value or 40) timeout_seconds = int(timeout_value or 30) entry_owner = { "kind": object_selector.get("kind"), "name": object_selector.get("name"), "guid": object_selector.get("guid"), "ref": object_selector_ref(object_selector.get("kind"), object_selector.get("name")), } def read_routine(owner: dict[str, Any], routine: str) -> dict[str, Any]: direct_module_ref = str(owner.get("_module_ref") or "").strip() if direct_module_ref: return read_module({ "base_id": base_id_or_error, "module_ref": direct_module_ref, "routine_name": routine, "include_text": True, "max_chars": 100000, "timeout_seconds": timeout_seconds, }) return code_read({ "base_id": base_id_or_error, "routine_name": routine, "include_text": True, "max_chars": 100000, "include_routines": False, "include_summary": False, "timeout_seconds": timeout_seconds, "configuration_view": "effective_working", **({"ref": owner.get("ref")} if owner.get("ref") else {}), **({"kind": owner.get("kind")} if owner.get("kind") and not owner.get("ref") else {}), **({"name": owner.get("name")} if owner.get("name") and not owner.get("ref") else {}), **({"guid": owner.get("guid")} if owner.get("guid") and not owner.get("ref") else {}), }) if module_ref: entry_read = read_module({ "base_id": base_id_or_error, "module_ref": module_ref, "routine_name": method_name, "include_text": True, "max_chars": 100000, "timeout_seconds": timeout_seconds, }) else: entry_read = read_routine(entry_owner, method_name) # A form-module reference is already an unambiguous entry point, but its # public owner is discovered only while decoding the form payload. Reuse # that owner for subsequent calls instead of searching the whole base. if module_ref: discovered_owner = entry_read.get("owner") if isinstance(entry_read.get("owner"), dict) else {} if discovered_owner: entry_owner = { "kind": discovered_owner.get("kind") or entry_owner.get("kind"), "name": discovered_owner.get("name") or entry_owner.get("name"), "guid": discovered_owner.get("guid") or entry_owner.get("guid"), "ref": discovered_owner.get("ref") or object_selector_ref( discovered_owner.get("kind") or entry_owner.get("kind"), discovered_owner.get("name") or entry_owner.get("name"), ), "_module_ref": module_ref, } entry_selection = entry_read.get("selection") if isinstance(entry_read.get("selection"), dict) else {} if entry_read.get("status") not in {"ok", "summary", "text"} or entry_selection.get("status") == "not_found": return { "schema": "onec_diagnostics_call_chain.v2", "status": "not_found", "error": "entry_routine_not_found", "base_id": base_id_or_error, "entry": {"method": method_name, "object": entry_owner}, "diagnostics": {"message": "Entry routine was not found through the supplied public selector."}, } # For a direct module entry point the owner is resolved from the decoded # module, rather than supplied by the caller. Feed that public owner into # override analysis as well. override = metadata_resolve_overrides({ "base_id": base_id_or_error, "method_name": method_name, "object_type": entry_owner.get("kind"), "object_name": entry_owner.get("name"), "object_guid": entry_owner.get("guid"), }) if entry_owner.get("kind") or entry_owner.get("name") or entry_owner.get("guid") else {"status": "not_found"} if override.get("status") not in {"ok", "not_found"}: return override nodes: list[dict[str, Any]] = [{ "id": "n1", "depth": 0, "owner": {key: value for key, value in entry_owner.items() if not key.startswith("_")}, "routine_name": entry_selection.get("routine_name") or method_name, "line_start": entry_selection.get("line_start"), "line_end": entry_selection.get("line_end"), "read_selector": {"method": "code.read", "base_id": base_id_or_error, **({"ref": entry_owner.get("ref")} if entry_owner.get("ref") else {}), "routine_name": entry_selection.get("routine_name") or method_name}, }] edges: list[dict[str, Any]] = [] unresolved_calls: list[dict[str, Any]] = [] queue: list[tuple[str, int, dict[str, Any], dict[str, Any]]] = [("n1", 0, entry_owner, entry_read)] seen = {"|".join([str(entry_owner.get("kind") or ""), str(entry_owner.get("name") or ""), str(entry_selection.get("routine_name") or method_name).casefold()])} while queue and len(nodes) < max_nodes: source_id, current_depth, owner, source_read = queue.pop(0) if current_depth >= depth: continue source_selection = source_read.get("selection") if isinstance(source_read.get("selection"), dict) else {} line_offset = max(0, int(source_selection.get("line_start") or 1) - 1) source_text = str(source_read.get("text") or "") # Managed-form code frequently calls an object method through a local # variable obtained with РеквизитФормыВЗначение(... Тип("ДокументОбъект.X")) . # This is a static, SQL-readable type declaration, so it is safe to # use it as a bounded owner transition in the graph. typed_object_variables: dict[str, dict[str, Any]] = {} for variable_match in re.finditer( r'(?im)^\s*([\wА-Яа-яЁё]+)\s*=.*?Тип\s*\(\s*"(ДокументОбъект|СправочникОбъект|ОбработкаОбъект|ОтчетОбъект)\.([^"\r\n]+)"\s*\)', source_text, ): kind_by_runtime_type = { "ДокументОбъект": "Document", "СправочникОбъект": "Catalog", "ОбработкаОбъект": "DataProcessor", "ОтчетОбъект": "Report", } typed_kind = kind_by_runtime_type.get(variable_match.group(2)) if typed_kind: typed_name = variable_match.group(3).strip() typed_object_variables[variable_match.group(1).casefold()] = { "kind": typed_kind, "name": typed_name, "ref": object_selector_ref(typed_kind, typed_name), } calls = diagnostics_static_calls(source_text, line_offset=line_offset) # Resolve statically typed object calls before generic platform calls; # it keeps an object-scoped trace bounded and avoids pointless reads of # built-in 1C functions. calls.sort(key=lambda item: 0 if str(item.get("module_name") or "").casefold() in typed_object_variables else 1) for call in calls: if call.get("resolution") == "unresolved_qualified_expression": unresolved_calls.append({"from": source_id, **call, "reason": "Only CommonModule.Routine and same-owner calls are resolved automatically."}) continue if not call.get("module_name") and str(call.get("routine_name") or "").casefold() in { "реквизитформывзначение", "значениевреквизитформы", "тип", }: unresolved_calls.append({"from": source_id, **call, "reason": "1C platform built-in; it has no configuration module source."}) continue module_name = str(call.get("module_name") or "") typed_owner = typed_object_variables.get(module_name.casefold()) if module_name else None target_owner = typed_owner or (owner if not module_name else {"kind": "CommonModule", "name": module_name, "ref": object_selector_ref("CommonModule", module_name)}) if typed_owner: call = {**call, "resolution": "typed_object_variable"} key = "|".join([str(target_owner.get("kind") or ""), str(target_owner.get("name") or ""), str(call.get("routine_name") or "").casefold()]) if key in seen: edges.append({"from": source_id, "to": None, **call, "status": "cycle_or_duplicate"}) continue target_read = read_routine(target_owner, str(call.get("routine_name") or "")) target_selection = target_read.get("selection") if isinstance(target_read.get("selection"), dict) else {} if target_read.get("status") not in {"ok", "summary", "text"} or target_selection.get("status") == "not_found": unresolved_calls.append({"from": source_id, **call, "reason": "Routine was not resolved through the bounded public selector.", "read_status": target_read.get("status")}) continue seen.add(key) target_id = f"n{len(nodes) + 1}" routine = target_selection.get("routine_name") or call.get("routine_name") nodes.append({ "id": target_id, "depth": current_depth + 1, "owner": {key: value for key, value in target_owner.items() if not key.startswith("_")}, "routine_name": routine, "line_start": target_selection.get("line_start"), "line_end": target_selection.get("line_end"), "read_selector": {"method": "code.read", "base_id": base_id_or_error, **({"ref": target_owner.get("ref")} if target_owner.get("ref") else {}), "routine_name": routine}, }) edges.append({"from": source_id, "to": target_id, **call, "status": "resolved"}) queue.append((target_id, current_depth + 1, target_owner, target_read)) if len(nodes) >= max_nodes: break return { "schema": "onec_diagnostics_call_chain.v2", "status": "ok", "base_id": base_id_or_error, "entry": { "method": method_name, "object": { "kind": entry_owner.get("kind"), "name": entry_owner.get("name"), "guid": entry_owner.get("guid"), }, }, "override_chain": override.get("chain") if isinstance(override.get("chain"), list) else [], "nodes": nodes, "edges": edges, "unresolved_calls": unresolved_calls, "risks": [ "Static graph only: dynamic dispatch, string-built calls, and runtime-created form elements are not inferred.", "Only same-owner and one-level CommonModule.Routine calls are automatically resolved.", ], "counts": {"nodes": len(nodes), "edges": len(edges), "unresolved_calls": len(unresolved_calls), "depth": depth, "max_nodes": max_nodes, "truncated": bool(queue and len(nodes) >= max_nodes)}, } def codec_decode(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "codec.decode") if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, "codec.decode") if isinstance(table_or_error, dict): return table_or_error include_text, include_text_error = strict_bool_argument(payload, "include_text", method="codec.decode", default=True) if include_text_error: return include_text_error include_tree, include_tree_error = strict_bool_argument(payload, "include_tree", method="codec.decode", default=False) if include_tree_error: return include_tree_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="codec.decode", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "codec.decode") if diagnostic_error: return diagnostic_error if "file_name" in payload and not isinstance(payload.get("file_name"), str): return invalid_argument("codec.decode", "file_name", "file_name must be a JSON string.") file_name = str(payload.get("file_name") or "") if not file_name or Path(file_name).name != file_name: return { "schema": "onec_adapter_request_error.v1", "method": "codec.decode", "status": "invalid_argument", "error": "invalid_argument", "argument": "file_name", } data, config, error = read_storage_file_bytes(base_id_or_error, table_or_error, file_name, timeout_seconds=int(timeout_seconds or 30)) if error: error["method"] = "codec.decode" return error decoded = decode_payload_full(data, include_text=bool(include_text), include_tree=bool(include_tree)) return { "schema": "onec_codec_decode.v1", "status": decoded.get("status"), "base_id": base_id_or_error, "source": {"kind": "live_sql", "database": config["database"], "table": table_or_error, "file_name": file_name}, "decoded": decoded, } def codec_encode(payload: dict[str, Any]) -> dict[str, Any]: include_payload, include_payload_error = strict_bool_argument(payload, "include_payload", method="codec.encode", default=False) if include_payload_error: return include_payload_error if "text" in payload and not isinstance(payload.get("text"), str): return invalid_argument("codec.encode", "text", "text must be a JSON string.") if "source" in payload and payload.get("source") is not None and not isinstance(payload.get("source"), dict): return invalid_argument("codec.encode", "source", "source must be a JSON object.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="codec.encode", default=30, minimum=1) if timeout_error: return timeout_error diagnostic_error = require_diagnostic_mode(payload, "codec.encode") if diagnostic_error: return diagnostic_error try: from parser.payload import decode_payload_lossless, encode_brace_tree, encode_payload_lossless except Exception as exc: return { "schema": "onec_codec_encode.v1", "status": "error", "diagnostics": {"message": f"Payload codec is unavailable: {exc}"}, } decoded_meta = payload.get("decoded") if isinstance(payload.get("decoded"), dict) else None source = payload.get("source") if isinstance(payload.get("source"), dict) else None original_bytes = None config = None if source: base_id = str(source.get("base_id") or payload.get("base_id") or "") table = str(source.get("table") or payload.get("table") or "") file_name = str(source.get("file_name") or payload.get("file_name") or "") if not base_id: return base_id_required("codec.encode") if table not in STORAGE_TABLES or not file_name: return { "schema": "onec_adapter_request_error.v1", "method": "codec.encode", "status": "error", "error": "source_required", } original_bytes, config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if error: error["method"] = "codec.encode" return error decoded_meta = decode_payload_lossless(original_bytes) if not decoded_meta: return { "schema": "onec_adapter_request_error.v1", "method": "codec.encode", "status": "error", "error": "decoded_or_source_required", } try: if "tree" in payload: encoded = encode_brace_tree(payload["tree"], decoded_meta) elif "text" in payload: encoded = encode_payload_lossless(decoded_meta, text=str(payload.get("text") or "")) elif original_bytes is not None: encoded = original_bytes else: encoded = encode_payload_lossless(decoded_meta) except Exception as exc: return { "schema": "onec_codec_encode.v1", "status": "error", "diagnostics": {"message": str(exc)}, } result = { "schema": "onec_codec_encode.v1", "status": "ok", "source": source, "encoded": { "bytes": len(encoded), "sha1": hashlib.sha1(encoded).hexdigest(), "compression": decoded_meta.get("compression"), "encoding": decoded_meta.get("encoding"), "matches_original": bool(original_bytes is not None and encoded == original_bytes), }, } if include_payload: result["encoded"]["payload_hex"] = encoded.hex() if config: result["source"]["database"] = config["database"] return result def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: string_error = validate_optional_string_arguments(payload, "changes.propose", ["summary", "description", "module_id", "table", "file_name"]) if string_error: return string_error include_text, include_text_error = strict_bool_argument(payload, "include_text", method="changes.propose", default=False) if include_text_error: return include_text_error include_payload, include_payload_error = strict_bool_argument(payload, "include_payload", method="changes.propose", default=False) if include_payload_error: return include_payload_error preserve_format, preserve_format_error = strict_bool_argument(payload, "preserve_format", method="changes.propose", default=False) if preserve_format_error: return preserve_format_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="changes.propose", default=30, minimum=1) if timeout_error: return timeout_error try: from parser.payload import ( decode_payload_lossless, encode_brace_tree, encode_payload_lossless, append_brace_text_child, get_tree_path, patch_brace_text_path, parse_brace_text, scalar, serialize_brace_tree, set_tree_path, swap_brace_text_paths, ) except Exception as exc: return { "schema": "onec_change_proposal.v1", "status": "error", "applied": False, "diagnostics": {"message": f"Payload codec is unavailable: {exc}"}, } if "source" in payload and payload.get("source") is not None and not isinstance(payload.get("source"), dict): return invalid_argument("changes.propose", "source", "source must be a JSON object.") source = payload.get("source") if isinstance(payload.get("source"), dict) else {} for argument in ("base_id", "module_id", "table", "file_name"): if argument in source: value = source.get(argument) if value is None or value == "": return invalid_argument("changes.propose", f"source.{argument}", f"source.{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("changes.propose", f"source.{argument}", f"source.{argument} must be a JSON string.") for argument in ("module_id", "table", "file_name"): if argument in payload: value = payload.get(argument) if value is None or value == "": return invalid_argument("changes.propose", argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("changes.propose", argument, f"{argument} must be a JSON string.") base_id = str(source.get("base_id") or payload.get("base_id") or "") if not base_id: return base_id_required("changes.propose") module_id = str(source.get("module_id") or payload.get("module_id") or "") module_table = module_file_name = None module_stream_index = None if module_id: module_table, module_file_name, module_stream_index = parse_module_id(module_id) if not module_table or not module_file_name: return { "schema": "onec_adapter_request_error.v1", "method": "changes.propose", "status": "error", "error": "invalid_module_id", "diagnostics": {"message": "Use module_id in the form
:#stream: where
is Config, ConfigSave, ConfigCAS, or ConfigCASSave."}, } table = str(source.get("table") or payload.get("table") or module_table or "Config") file_name = str(source.get("file_name") or payload.get("file_name") or module_file_name or "") if table not in STORAGE_TABLES or not file_name or Path(file_name).name != file_name: return { "schema": "onec_adapter_request_error.v1", "method": "changes.propose", "status": "error", "error": "source_required", "diagnostics": {"message": "Pass source/base_id with source/module_id, or source/table and safe source/file_name."}, } edits = payload.get("edits") if edits is None and payload.get("path"): edits = [{"path": payload.get("path"), "value": payload.get("value"), "node_type": payload.get("node_type", "auto")}] if edits is not None and not isinstance(edits, list): return invalid_argument("changes.propose", "edits", "edits must be a non-empty JSON array of {path, value, node_type?}.") if not edits: return invalid_argument("changes.propose", "edits", "Pass edits as a non-empty list of {path, value, node_type?}.") normalized_edits: list[Any] = [] for edit in edits: if isinstance(edit, dict) and module_stream_index is not None and "path" not in edit and "stream_index" not in edit: normalized_edits.append({**edit, "stream_index": module_stream_index}) else: normalized_edits.append(edit) edits = normalized_edits data, config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if error: error["method"] = "changes.propose" return error response_source = { "kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name, **({"module_id": module_id} if module_id else {}), **({"stream_index": module_stream_index} if module_stream_index is not None else {}), } original_sha1 = hashlib.sha1(data).hexdigest() expected_sha1 = str(source.get("expected_sha1") or payload.get("expected_sha1") or "").lower() if expected_sha1 and expected_sha1 != original_sha1: return { "schema": "onec_change_proposal.v1", "status": "precondition_failed", "applied": False, "base_id": base_id, "source": response_source, "original": {"sha1": original_sha1, "bytes": len(data)}, "diagnostics": {"message": "Source payload SHA1 differs from expected_sha1."}, } stream_mode = any(isinstance(edit, dict) and "stream_index" in edit for edit in edits) container_bsl_mode = any(isinstance(edit, dict) and edit.get("bsl_container") is True for edit in edits) path_mode = any(isinstance(edit, dict) and "path" in edit for edit in edits) structural_mode = any(isinstance(edit, dict) and ("swap_paths" in edit or "append_child" in edit or "replace_root" in edit) for edit in edits) if sum(1 for value in (stream_mode, container_bsl_mode, path_mode, structural_mode) if value) > 1: return { "schema": "onec_adapter_request_error.v1", "method": "changes.propose", "status": "error", "error": "mixed_edit_modes", "diagnostics": {"message": "Do not mix stream_index, path, and structural edits in one proposal."}, } try: decoded = decode_payload_lossless(data) applied_edits = [] proposal_text = None if container_bsl_mode: if len(edits) != 1 or not isinstance(edits[0], dict): raise ValueError("Exactly one root BSL-container edit is required") edit = edits[0] container_text = str(decoded.get("text") or "") bsl_text, extraction = extract_bsl_text_from_container(container_text) if extraction.get("status") != "ok": raise ValueError("root BSL container start was not proven") replace = edit.get("replace") if isinstance(edit.get("replace"), dict) else None if replace is None: raise ValueError("root BSL-container write currently supports exact replace only") old, new = str(replace.get("old") or ""), str(replace.get("new") or "") if not old: raise ValueError("replace.old is required") occurrences = bsl_text.count(old) if occurrences != 1: raise ValueError(f"replace.old must occur exactly once in root BSL text; found {occurrences}") expected_text_sha1 = str(edit.get("expected_text_sha1") or "") if expected_text_sha1 and expected_text_sha1.lower() != code_text_sha1(bsl_text): raise ValueError("expected_text_sha1 does not match root BSL text") patched_bsl = bsl_text.replace(old, new, 1) offset = int(extraction.get("bsl_offset") or 0) proposal_text = container_text[:offset] + patched_bsl encoded = encode_payload_lossless(decoded, text=proposal_text) applied_edits.append({"index": 0, "mode": "root_bsl_container", "bsl_offset": offset, "old_text_sha1": code_text_sha1(bsl_text), "new_text_sha1": code_text_sha1(patched_bsl), "occurrences": 1}) elif stream_mode: from parser.cas_payload import ( classify_payload, replace_declared_utf8_bsl_prefix_same_width, replace_stream_block, stream_blocks_with_data, ) current_payload = decoded.get("payload") if not isinstance(current_payload, (bytes, bytearray)): raise ValueError("source payload bytes are unavailable") modified_payload = bytes(current_payload) for index, edit in enumerate(edits): if not isinstance(edit, dict): raise ValueError(f"edit {index} is not an object") replace = edit.get("replace") if isinstance(edit.get("replace"), dict) else None routine = edit.get("routine") if isinstance(edit.get("routine"), dict) else None stream_index = int(edit.get("stream_index")) # Some report-object carriers store BSL in the declared prefix # of a fixed-size member followed by opaque platform state. # Only that evidenced *tail-bearing* layout requires the # fixed-width codec. A declared prefix whose length equals # its member length is an ordinary complete stream: the # generic writer is proven to rebuild its header and may # safely change source length. Treating both layouts as # fixed-width blocked normal BSL edits unnecessarily. classified_streams = classify_payload( encode_payload_lossless(decoded, payload=modified_payload), include_text=True ).get("stream_blocks") or [] candidate_stream = classified_streams[stream_index] if 0 <= stream_index < len(classified_streams) else {} declared_prefix = candidate_stream.get("declared_utf8_bsl_prefix") if isinstance(candidate_stream, dict) else None if isinstance(declared_prefix, dict) and int(declared_prefix.get("opaque_tail_bytes") or 0) > 0: if routine is not None or not isinstance(replace, dict): return { "schema": "onec_change_proposal.v1", "status": "unsupported", "error": "report_object_module_fixed_width_replace_only", "applied": False, "base_id": base_id, "source": response_source, "diagnostics": {"message": "This carrier currently permits only an exact old/new replacement that fits its fixed declared BSL prefix."}, } declared_text = str(candidate_stream.get("text") or "") old = str(replace.get("old") or "") new = str(replace.get("new") or "") if not old: raise ValueError("replace.old is required") line_ending = "\r\n" if "\r\n" in declared_text else "\r" if "\r" in declared_text else "\n" source_old = old if old in declared_text else old.replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending) source_new = new if source_old == old and "\n" not in new and "\r" not in new else new.replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending) occurrences = declared_text.count(source_old) if occurrences != 1: raise ValueError(f"replace.old must occur exactly once in declared BSL prefix; found {occurrences}") patched_text = declared_text.replace(source_old, source_new, 1) expected_text_sha1 = str(edit.get("expected_text_sha1") or "") if "expected_text_sha1" in edit else None modified_payload, stream_edit = replace_declared_utf8_bsl_prefix_same_width( modified_payload, stream_index, text=patched_text, expected_text_sha1=expected_text_sha1, ) stream_edit["index"] = index stream_edit["mode"] = "declared_utf8_bsl_prefix_same_width" stream_edit["occurrences"] = occurrences applied_edits.append(stream_edit) continue blocks = stream_blocks_with_data(modified_payload) block = blocks[stream_index] if 0 <= stream_index < len(blocks) else None raw_stream_text = str((block or {}).get("text") or "") repaired_stream_text = repair_bsl_mojibake_text(raw_stream_text) if raw_stream_text and repaired_stream_text != raw_stream_text and (replace or routine or "expected_contains" in edit or "expected_text_sha1" in edit): expected_contains = str(edit.get("expected_contains") or "") if "expected_contains" in edit else "" if expected_contains and expected_contains not in repaired_stream_text: raise ValueError("expected_contains was not found in repaired stream text") expected_text_sha1 = str(edit.get("expected_text_sha1") or "") if "expected_text_sha1" in edit else "" if expected_text_sha1 and expected_text_sha1.lower() != code_text_sha1(repaired_stream_text): raise ValueError("expected_text_sha1 does not match repaired stream text") repaired_new_text: str | None = None routine_edit = None if replace is not None: old = str(replace.get("old") or "") new = str(replace.get("new") or "") if not old: raise ValueError("replace.old is required") if old not in repaired_stream_text: raise ValueError("replace.old was not found in repaired stream text") count = int(replace.get("count") or 1) repaired_new_text = repaired_stream_text.replace(old, new, count) if routine is not None: from parser.bsl_validation import replace_routine_text repaired_new_text, routine_edit = replace_routine_text( repaired_stream_text, str(routine.get("text") or ""), operation=str(routine.get("operation") or "replace"), name=str(routine.get("name")) if routine.get("name") else None, expected_old_sha1=str(routine.get("expected_old_sha1")) if routine.get("expected_old_sha1") else None, expected_old_contains=str(routine.get("expected_old_contains")) if routine.get("expected_old_contains") else None, ) if repaired_new_text is not None: repaired_new_text = repaired_new_text.lstrip("\ufeff") modified_payload, stream_edit = replace_stream_block(modified_payload, stream_index, text=repaired_new_text) stream_edit["index"] = index stream_edit["mode"] = "stream" stream_edit["encoding_repaired"] = True stream_edit["old_text_sha1"] = code_text_sha1(repaired_stream_text) stream_edit["new_text_sha1"] = code_text_sha1(repaired_new_text) stream_edit["old_text_preview"] = repaired_stream_text[:500] stream_edit["new_text_preview"] = repaired_new_text[:500] if routine_edit: stream_edit["routine"] = routine_edit applied_edits.append(stream_edit) continue modified_payload, stream_edit = replace_stream_block( modified_payload, stream_index, text=str(edit["text"]) if "text" in edit else None, replace=replace, routine=routine, expected_contains=str(edit.get("expected_contains")) if "expected_contains" in edit else None, expected_text_sha1=str(edit.get("expected_text_sha1")) if "expected_text_sha1" in edit else None, ) stream_edit["index"] = index stream_edit["mode"] = "stream" applied_edits.append(stream_edit) encoded = encode_payload_lossless(decoded, payload=modified_payload) else: text = decoded.get("text") if not text or "{" not in text: raise ValueError("source payload is not a brace-tree text payload") if preserve_format: proposal_text = str(text) for index, edit in enumerate(edits): if not isinstance(edit, dict): raise ValueError(f"edit {index} is not an object") if "replace_root" in edit: replacement_root = edit.get("replace_root") if not isinstance(replacement_root, dict) or replacement_root.get("type") not in {"list", "sequence"}: raise ValueError(f"edit {index} replace_root must be a parsed list/sequence node") brace_offset = proposal_text.find("{") if brace_offset < 0: raise ValueError("replace_root requires a brace-tree text payload") old_root_text = proposal_text[brace_offset:] new_root_text = serialize_brace_tree(replacement_root) proposal_text = proposal_text[:brace_offset] + new_root_text applied_edits.append( { "index": index, "mode": "structural_replace_root_preserve_prefix", "old_tree_sha1": hashlib.sha1(old_root_text.encode("utf-8")).hexdigest(), "new_tree_sha1": hashlib.sha1(new_root_text.encode("utf-8")).hexdigest(), "old_tree_chars": len(old_root_text), "new_tree_chars": len(new_root_text), "fields": list(edit.get("fields") or []), "resized_collections": list(edit.get("resized_collections") or []), "old_counts": dict(edit.get("old_counts") or {}), "new_counts": dict(edit.get("new_counts") or {}), } ) continue if "swap_paths" in edit: swap_paths = edit.get("swap_paths") if not isinstance(swap_paths, list) or len(swap_paths) != 2: raise ValueError(f"edit {index} swap_paths must contain exactly two paths") path_a = str(swap_paths[0] or "") path_b = str(swap_paths[1] or "") proposal_text, patch_info = swap_brace_text_paths(proposal_text, path_a, path_b) applied_edits.append({"index": index, "mode": "structural_swap_preserve_format", **patch_info}) continue if "append_child" in edit: append_child = edit.get("append_child") if not isinstance(append_child, dict): raise ValueError(f"edit {index} append_child must be an object") parent_path = str(append_child.get("parent_path") or append_child.get("path") or "") node_text = str(append_child.get("node_text") or "") child_node = append_child.get("node") if node_text: child_node = parse_brace_text(node_text) if not parent_path or child_node is None: raise ValueError(f"edit {index} append_child requires parent_path and node/node_text") proposal_text, patch_info = append_brace_text_child(proposal_text, parent_path, child_node) applied_edits.append({"index": index, "mode": "structural_append_child_preserve_format", **patch_info}) continue path = str(edit.get("path") or "") tree = parse_brace_text(proposal_text) old_node = get_tree_path(tree, path) old_value = scalar(old_node) if "expected_old" in edit and str(edit.get("expected_old")) != old_value: return { "schema": "onec_change_proposal.v1", "status": "precondition_failed", "applied": False, "base_id": base_id, "source": response_source, "original": {"sha1": original_sha1, "bytes": len(data)}, "edit": {"index": index, "path": path, "expected_old": edit.get("expected_old"), "actual_old": old_value}, "diagnostics": {"message": "Edit expected_old does not match current value."}, } proposal_text, patch_info = patch_brace_text_path(proposal_text, path, edit.get("value"), node_type=str(edit.get("node_type") or "auto")) applied_edits.append({"index": index, "mode": "path_preserve_format", **patch_info}) encoded = encode_payload_lossless(decoded, text=proposal_text) else: tree = parse_brace_text(text) for index, edit in enumerate(edits): if not isinstance(edit, dict): raise ValueError(f"edit {index} is not an object") path = str(edit.get("path") or "") old_node = get_tree_path(tree, path) old_value = scalar(old_node) if "expected_old" in edit and str(edit.get("expected_old")) != old_value: return { "schema": "onec_change_proposal.v1", "status": "precondition_failed", "applied": False, "base_id": base_id, "source": response_source, "original": {"sha1": original_sha1, "bytes": len(data)}, "edit": {"index": index, "path": path, "expected_old": edit.get("expected_old"), "actual_old": old_value}, "diagnostics": {"message": "Edit expected_old does not match current value."}, } tree = set_tree_path(tree, path, edit.get("value"), node_type=str(edit.get("node_type") or "auto")) new_node = get_tree_path(tree, path) applied_edits.append( { "index": index, "mode": "path", "path": path, "old": old_value, "new": scalar(new_node), "old_node_type": old_node.get("type") if isinstance(old_node, dict) else None, "new_node_type": new_node.get("type") if isinstance(new_node, dict) else None, } ) proposal_text = serialize_brace_tree(tree) encoded = encode_brace_tree(tree, decoded) except Exception as exc: return { "schema": "onec_change_proposal.v1", "status": "error", "applied": False, "base_id": base_id, "source": response_source, "diagnostics": {"message": str(exc)}, } encoded_sha1 = hashlib.sha1(encoded).hexdigest() validation: dict[str, Any] try: if stream_mode: from parser.cas_payload import classify_payload classified = classify_payload(encoded, include_text=True) stream_validations = [] try: from parser.bsl_validation import validate_bsl_text except Exception: validate_bsl_text = None streams = classified.get("stream_blocks") or [] for edit in applied_edits: stream_index = edit.get("stream_index") stream = streams[stream_index] if isinstance(stream_index, int) and 0 <= stream_index < len(streams) else None text = stream.get("text") if isinstance(stream, dict) else None item = { "stream_index": stream_index, "has_text": bool(text), "has_bsl_marker": bool(stream and stream.get("has_bsl_marker")), } if text and validate_bsl_text: item["bsl"] = validate_bsl_text(text) stream_validations.append(item) validation = { "status": "ok" if classified.get("status") == "ok" else "error", "mode": "stream", "role": classified.get("role"), "compression": classified.get("compression"), "payload_bytes": classified.get("payload_bytes"), "counts": classified.get("counts"), "stream_indexes": [edit.get("stream_index") for edit in applied_edits], "streams": stream_validations, } if any(((item.get("bsl") or {}).get("status") == "error") for item in stream_validations): validation["status"] = "error" else: roundtrip_decoded = decode_payload_lossless(encoded) roundtrip_text = roundtrip_decoded.get("text") if not roundtrip_text: raise ValueError("encoded payload text is not decodable") roundtrip_tree = parse_brace_text(roundtrip_text) checks = [] for edit in applied_edits: if edit.get("mode") in {"path", "path_preserve_format"}: checks.append( { "path": edit.get("path"), "value": scalar(get_tree_path(roundtrip_tree, str(edit.get("path") or ""))), "expected": edit.get("new"), "ok": scalar(get_tree_path(roundtrip_tree, str(edit.get("path") or ""))) == edit.get("new"), } ) elif edit.get("mode") == "structural_swap_preserve_format": path_a = str(edit.get("path_a") or "") path_b = str(edit.get("path_b") or "") checks.append( { "path_a": path_a, "path_b": path_b, "ok": bool(get_tree_path(roundtrip_tree, path_a) and get_tree_path(roundtrip_tree, path_b)), } ) elif edit.get("mode") == "structural_append_child_preserve_format": parent_path = str(edit.get("parent_path") or "") inserted_index = edit.get("inserted_index") appended_path = f"{parent_path}.{inserted_index}" if parent_path and isinstance(inserted_index, int) else "" appended_node = get_tree_path(roundtrip_tree, appended_path) if appended_path else None parent_node = get_tree_path(roundtrip_tree, parent_path) if parent_path else None parent_items = config_tree_list_items(parent_node) declared_count = config_tree_scalar(parent_items[1]) if len(parent_items) >= 2 else "" count_ok = ( not edit.get("count_updated") or (declared_count.isdigit() and int(declared_count) == len(parent_items) - 2) ) checks.append( { "operation": "append_child", "parent_path": parent_path, "inserted_index": inserted_index, "appended_path": appended_path, "node_present": appended_node is not None, "count_ok": count_ok, "ok": appended_node is not None and count_ok, } ) elif edit.get("mode") == "structural_replace_root_preserve_prefix": roundtrip_serialized = serialize_brace_tree(roundtrip_tree) checks.append( { "operation": "replace_root", "expected_tree_sha1": edit.get("new_tree_sha1"), "actual_tree_sha1": hashlib.sha1(roundtrip_serialized.encode("utf-8")).hexdigest(), "ok": hashlib.sha1(roundtrip_serialized.encode("utf-8")).hexdigest() == edit.get("new_tree_sha1"), } ) validation = { "status": "ok", "mode": "path_preserve_format" if preserve_format else "path", "compression": roundtrip_decoded.get("compression"), "encoding": roundtrip_decoded.get("encoding"), "checks": checks, } validation["status"] = "ok" if all(check.get("ok") for check in validation["checks"]) else "error" except Exception as exc: validation = {"status": "error", "diagnostics": {"message": str(exc)}} result: dict[str, Any] = { "schema": "onec_change_proposal.v1", "status": "accepted_for_review", "applied": False, "base_id": base_id, "source": response_source, "original": {"sha1": original_sha1, "bytes": len(data)}, "encoded": { "sha1": encoded_sha1, "bytes": len(encoded), "compression": decoded.get("compression"), "encoding": decoded.get("encoding"), "matches_original": encoded == data, }, "edits": applied_edits, "validation": validation, "counts": {"edits": len(applied_edits)}, "diagnostics": {"note": "Proposal only. The adapter did not write to SQL."}, } if include_text: result["text"] = proposal_text if include_payload: result["encoded"]["payload_hex"] = encoded.hex() return result def validate_changes_propose_payload(payload: dict[str, Any]) -> dict[str, Any] | None: string_error = validate_optional_string_arguments(payload, "changes.propose", ["summary", "description", "module_id", "table", "file_name"]) if string_error: return string_error _, include_text_error = strict_bool_argument(payload, "include_text", method="changes.propose", default=False) if include_text_error: return include_text_error _, include_payload_error = strict_bool_argument(payload, "include_payload", method="changes.propose", default=False) if include_payload_error: return include_payload_error _, preserve_format_error = strict_bool_argument(payload, "preserve_format", method="changes.propose", default=False) if preserve_format_error: return preserve_format_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="changes.propose", default=30, minimum=1) if timeout_error: return timeout_error if "source" in payload and payload.get("source") is not None and not isinstance(payload.get("source"), dict): return invalid_argument("changes.propose", "source", "source must be a JSON object.") source = payload.get("source") if isinstance(payload.get("source"), dict) else {} for argument in ("base_id", "module_id", "table", "file_name"): if argument in source: value = source.get(argument) if value is None or value == "": return invalid_argument("changes.propose", f"source.{argument}", f"source.{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("changes.propose", f"source.{argument}", f"source.{argument} must be a JSON string.") for argument in ("module_id", "table", "file_name"): if argument in payload: value = payload.get(argument) if value is None or value == "": return invalid_argument("changes.propose", argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("changes.propose", argument, f"{argument} must be a JSON string.") if not str(source.get("base_id") or payload.get("base_id") or ""): return base_id_required("changes.propose") edits = payload.get("edits") if edits is None and payload.get("path"): edits = [{"path": payload.get("path"), "value": payload.get("value"), "node_type": payload.get("node_type", "auto")}] if edits is not None and not isinstance(edits, list): return invalid_argument("changes.propose", "edits", "edits must be a non-empty JSON array of {path, value, node_type?}.") if not edits: return invalid_argument("changes.propose", "edits", "Pass edits as a non-empty list of {path, value, node_type?}.") return None def validate_metadata_object_decode_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.decode") if isinstance(base_id_or_error, dict): return base_id_or_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument("metadata.object.decode", "extension_guid", "extension_guid must be a GUID string.") selector_error = validate_object_selector_arguments(payload, "metadata.object.decode") if selector_error: return selector_error guid_error = validate_explicit_guid_argument(payload, "metadata.object.decode") if guid_error: return guid_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.decode") if ordinal_error: return ordinal_error _, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.decode") if lookup_limit_error: return lookup_limit_error _, view_error = parse_view_argument(payload, "metadata.object.decode") if view_error: return view_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.decode", default=60, minimum=1) if timeout_error: return timeout_error _, include_storage_error = strict_include_storage(payload, "metadata.object.decode") if include_storage_error: return include_storage_error _, include_text_error = strict_bool_argument(payload, "include_text", method="metadata.object.decode", default=False) if include_text_error: return include_text_error _, include_tree_error = strict_bool_argument(payload, "include_tree", method="metadata.object.decode", default=False) if include_tree_error: return include_tree_error _, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.decode") if evidence_mode_error: return evidence_mode_error table_or_error = metadata_storage_table(payload, "metadata.object.decode") if isinstance(table_or_error, dict): return table_or_error _, max_depth_error = parse_int_argument(payload, "max_depth", method="metadata.object.decode", default=3, minimum=1, maximum=8) if max_depth_error: return max_depth_error return None def decode_metadata_object(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.decode") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.object.decode") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument("metadata.object.decode", "extension_guid", "extension_guid must be a GUID string.") guid_error = validate_explicit_guid_argument(payload, "metadata.object.decode") if guid_error: return guid_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.decode") if ordinal_error: return ordinal_error lookup_limit, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.decode") if lookup_limit_error: return lookup_limit_error view, view_error = parse_view_argument(payload, "metadata.object.decode") if view_error: return view_error parsed_timeout, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.decode", default=60, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(parsed_timeout or 60) table_or_error = metadata_storage_table(payload, "metadata.object.decode") if isinstance(table_or_error, dict): return table_or_error table = table_or_error extension_route_file_name = "" include_storage, include_storage_error = strict_include_storage(payload, "metadata.object.decode") if include_storage_error: return include_storage_error include_storage = bool(include_storage) include_text, include_text_error = strict_bool_argument(payload, "include_text", method="metadata.object.decode", default=False) if include_text_error: return include_text_error include_tree, include_tree_error = strict_bool_argument(payload, "include_tree", method="metadata.object.decode", default=False) if include_tree_error: return include_tree_error evidence_mode, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.decode") if evidence_mode_error: return evidence_mode_error max_depth, max_depth_error = parse_int_argument(payload, "max_depth", method="metadata.object.decode", default=3, minimum=1, maximum=8) if max_depth_error: return max_depth_error records, error = live_dbnames_records(base_id, timeout_seconds=timeout_seconds) if error: error["method"] = "metadata.object.decode" return error object_card: dict[str, Any] | None = None guid = str(payload.get("guid") or "").strip().lower() kind = canonical_kind(str(payload.get("kind") or "")) if payload.get("kind") else None extension_filter = str(payload.get("extension") or "").strip() if extension_filter and not guid and str(payload.get("name") or "").strip(): resolved_extension_guid, extension_error = extension_filter_to_guid( base_id, extension_filter, method="metadata.object.decode" ) if extension_error: return extension_error extension_match = fast_extension_object_exact_match( base_id, kind=kind, name=str(payload.get("name") or ""), extension_guid=resolved_extension_guid, table="ConfigCAS", timeout_seconds=timeout_seconds, ) if extension_match: route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} extension_route_file_name = str(route.get("file_name") or "") table = str(route.get("table") or "ConfigCAS") guid = str(extension_match.get("guid") or "").strip().lower() kind = canonical_kind(str(extension_match.get("kind") or kind or "")) object_card = { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind or "", kind), "name": extension_match.get("name"), "synonym": extension_match.get("synonym"), "origin": extension_match.get("origin"), "route": route, } if not guid: ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if ordinal_value is not None and ordinal_value != "": ordinal, ordinal_error = parse_ordinal(ordinal_value, "metadata.object.decode") if ordinal_error: return ordinal_error if not kind: return { "schema": "onec_adapter_request_error.v1", "method": "metadata.object.decode", "status": "error", "error": "kind_required", "diagnostics": {"message": "kind is required when selecting an object by ordinal."}, } page = list_objects(kind=kind, base_id=base_id, limit=1, offset=int(ordinal or 1) - 1, include_storage=False, table=table) if page.get("status") != "ok" or not page.get("objects"): result = dict(page) result["method"] = "metadata.object.decode" result["status"] = "not_found" result["error"] = "not_found" result["diagnostics"] = {"message": f"Object ordinal {ordinal} was not found for kind {kind}."} return result object_card = (page.get("objects") or [])[0] guid = str((object_card or {}).get("guid") or "").lower() kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None if not guid: object_result = get_object( kind, str(payload.get("name") or ""), base_id=base_id, view=str(view or "effective"), limit=int(lookup_limit or 20), table=table, extension_guid=extension_guid or None, timeout_seconds=timeout_seconds, ) if object_result.get("status") != "ok": result = dict(object_result) result["method"] = "metadata.object.decode" return result object_card = object_result.get("object") guid = str((object_card or {}).get("guid") or "").lower() kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None if not guid: return { "schema": "onec_metadata_object_decode.v1", "status": "not_found", "error": "not_found", "base_id": base_id, "query": {"guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name")}, "diagnostics": {"message": "Object was not found. Pass guid or kind/name."}, } if table == "ConfigCASSave" and extension_guid and (not object_card or not object_card.get("name")): saved_object_result = get_object( kind, guid, base_id=base_id, view=str(view or "effective"), limit=int(lookup_limit or 20), table=table, extension_guid=extension_guid, include_storage=include_storage, include_semantic=False, timeout_seconds=timeout_seconds, ) if saved_object_result.get("status") == "ok": object_card = saved_object_result.get("object") or object_card kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None storage_file_name = extension_route_file_name or (f"{extension_guid}__{guid}" if table == "ConfigCASSave" and extension_guid else guid) data, config, error = read_storage_file_bytes(base_id, table, storage_file_name, timeout_seconds=timeout_seconds) if error: error["method"] = "metadata.object.decode" return error try: from parser.cas_payload import classify_payload except Exception: classify_payload = None decoded = decode_config_object_full( data, kind=kind, dbnames_records=records, include_text=bool(include_text), include_tree=bool(include_tree), max_depth=int(max_depth or 3), ) classified_payload = ( classify_payload( data, include_text=bool(include_text), include_tree=bool(include_tree), ) if classify_payload else {} ) semantic_raw = decoded.get("semantic") if decoded.get("status") == "ok" else None resolved_types = resolve_type_guids( base_id, collect_reference_type_guids_from_sections((semantic_raw or {}).get("sections") or []), timeout_seconds=timeout_seconds, table=table, ) public_decoded = dict(decoded) semantic_public = public_semantic_profile(semantic_raw, include_storage=False, resolved_types=resolved_types) if not include_storage: public_decoded = { "status": decoded.get("status"), "root": decoded.get("root"), "semantic": semantic_public, "undecoded_evidence": payload_public_undecoded_evidence( classified_payload, include_text_preview=bool(include_text), mode=str(evidence_mode or "summary"), allow_storage_details=bool(include_storage), ), } if "diagnostics" in decoded: public_decoded["diagnostics"] = decoded.get("diagnostics") if include_text and "text" in decoded: public_decoded["text"] = decoded.get("text") if include_tree and "tree" in decoded: public_decoded["tree"] = decoded.get("tree") else: public_decoded["undecoded_evidence"] = payload_public_undecoded_evidence( classified_payload, include_text_preview=bool(include_text), mode=str(evidence_mode or "summary"), allow_storage_details=bool(include_storage), ) semantic_sections = (semantic_public or {}).get("sections") or [] semantic_counts = { "semantic_sections": len(semantic_sections), "attributes": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "Attribute"), "tabular_sections": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "TabularSection"), "dimensions": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "Dimension"), "resources": sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "Resource"), } return { "schema": "onec_metadata_object_decode.v1", "status": decoded.get("status"), "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": storage_file_name} if include_storage else {"kind": "live_metadata"}, "query": { "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), "table": table, "include_storage": include_storage, }, "object": object_card or {"guid": guid, "kind": kind}, "decoded": public_decoded, "counts": semantic_counts, } def resolve_object_guid( payload: dict[str, Any], base_id: str, *, timeout_seconds: int = 60, method: str = "metadata.object.selector", table: str = "Config", ) -> tuple[str | None, str | None, dict[str, Any] | None, dict[str, Any] | None]: guid_error = validate_explicit_guid_argument(payload, method) if guid_error: return None, None, None, guid_error ordinal_argument_error = validate_explicit_ordinal_arguments(payload, method) if ordinal_argument_error: return None, None, None, ordinal_argument_error lookup_limit, lookup_limit_error = parse_object_lookup_limit(payload, method) if lookup_limit_error: return None, None, None, lookup_limit_error view, view_error = parse_view_argument(payload, method) if view_error: return None, None, None, view_error guid = str(payload.get("guid") or "").strip().lower() kind = canonical_kind(str(payload.get("kind") or "")) if payload.get("kind") else None object_card = None if guid: return guid, kind, {"guid": guid, "kind": kind}, None ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if ordinal_value not in {None, ""}: ordinal, ordinal_error = parse_ordinal(ordinal_value, method) if ordinal_error: return None, None, None, ordinal_error if not kind: return None, None, None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "error", "error": "kind_required", "diagnostics": {"message": "kind is required when selecting an object by ordinal."}, } page = list_objects( kind=kind, base_id=base_id, limit=1, offset=int(ordinal or 1) - 1, include_storage=False, table=table, ) if page.get("status") != "ok": result = dict(page) result["method"] = method return None, None, None, result objects = page.get("objects") or [] if objects: object_card = objects[0] guid = str((object_card or {}).get("guid") or "").lower() kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None return guid, kind, object_card, None return None, None, None, { "schema": "onec_adapter_request_error.v1", "method": method, "status": "not_found", "error": "not_found", "base_id": base_id, "diagnostics": {"message": f"Object ordinal {ordinal} was not found for kind {kind}."}, } object_result = get_object( kind, str(payload.get("name") or ""), base_id=base_id, view=str(view or "effective"), limit=int(lookup_limit or 20), timeout_seconds=timeout_seconds, table=table, include_semantic=False, ) if object_result.get("status") != "ok": result = dict(object_result) result["method"] = method return None, None, None, result object_card = object_result.get("object") guid = str((object_card or {}).get("guid") or "").lower() kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None if not guid: return None, kind, object_card, { "schema": "onec_metadata_object_parts.v1", "status": "not_found", "error": "not_found", "base_id": base_id, "query": {"guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name")}, "diagnostics": {"message": "Object was not found. Pass guid or kind/name."}, } return guid, kind, object_card, None def metadata_object_parts(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.parts") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.object.parts") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument("metadata.object.parts", "extension_guid", "extension_guid must be a GUID string.") timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.parts", default=60, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(timeout_value or 60) refresh_cache = truthy(payload.get("refresh_cache")) include_storage, include_storage_error = strict_include_storage(payload, "metadata.object.parts") if include_storage_error: return include_storage_error include_storage = bool(include_storage) include_text, include_text_error = strict_bool_argument(payload, "include_text", method="metadata.object.parts", default=False) if include_text_error: return include_text_error include_tree, include_tree_error = strict_bool_argument(payload, "include_tree", method="metadata.object.parts", default=False) if include_tree_error: return include_tree_error evidence_mode, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.parts") if evidence_mode_error: return evidence_mode_error table_or_error = metadata_storage_table(payload, "metadata.object.parts") if isinstance(table_or_error, dict): return table_or_error table = table_or_error part_limit, part_limit_error = parse_int_argument(payload, "part_limit", method="metadata.object.parts", default=200, minimum=1, maximum=5000) if part_limit_error: return part_limit_error extension_filter = str(payload.get("extension") or "").strip() extension_match: dict[str, Any] | None = None manifest_entries: list[dict[str, Any]] = [] if extension_filter and str(payload.get("name") or payload.get("object_name") or "").strip(): resolved_extension_guid, extension_error = extension_filter_to_guid( base_id, extension_filter, method="metadata.object.parts" ) if extension_error: return extension_error extension_match = fast_extension_object_exact_match( base_id, kind=payload.get("kind") or payload.get("object_type"), name=str(payload.get("name") or payload.get("object_name") or ""), extension_guid=resolved_extension_guid, table="ConfigCAS", timeout_seconds=timeout_seconds, ) if extension_match: route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} manifest_entries = [ entry for entry in (extension_match.get("manifest_entries") or []) if isinstance(entry, dict) and str(entry.get("cas_key") or "").strip() ] table = str(route.get("table") or "ConfigCAS") guid = str(extension_match.get("guid") or "").strip().lower() kind = canonical_kind(str(extension_match.get("kind") or payload.get("kind") or "")) object_card = { "guid": guid, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "name": extension_match.get("name"), "synonym": extension_match.get("synonym"), "ref": object_selector_ref(kind, str(extension_match.get("name") or "")), "origin": extension_match.get("origin"), "route": route, } if not manifest_entries: extension_match = None if extension_match is None: guid, kind, object_card, error = resolve_object_guid( payload, base_id, timeout_seconds=timeout_seconds, method="metadata.object.parts", table=table, ) if error: return error if table == "ConfigCASSave" and extension_guid and (not object_card or not object_card.get("name")): saved_object_result = get_object( kind, str(guid or ""), base_id=base_id, table=table, extension_guid=extension_guid, include_storage=include_storage, include_semantic=False, timeout_seconds=timeout_seconds, ) if saved_object_result.get("status") == "ok": object_card = saved_object_result.get("object") or object_card storage_prefix_guid = str(guid or "").lower() if table == "ConfigCASSave" and extension_guid and storage_prefix_guid: storage_prefix_guid = f"{extension_guid}__{storage_prefix_guid}" if kind == "Configuration" and isinstance(object_card, dict): identity = object_card.get("identity") if isinstance(object_card.get("identity"), dict) else {} # The cached public Configuration identity may contain the root # descriptor GUID. Re-read the current SQL descriptor because its # embedded identity GUID is the prefix of configuration-level parts. if guid: descriptor_data, _, descriptor_error = read_storage_file_bytes( base_id, table, str(guid), timeout_seconds=timeout_seconds, ) if not descriptor_error: descriptor_identity = config_identity_from_bytes(descriptor_data or b"") or {} descriptor_identity_guid = str(descriptor_identity.get("guid") or "").strip().lower() if is_guid_text(descriptor_identity_guid): identity = descriptor_identity object_card = {**object_card, "identity": descriptor_identity} identity_guid = str(identity.get("guid") or "").strip().lower() if is_guid_text(identity_guid): storage_prefix_guid = identity_guid try: from parser.cas_payload import classify_payload except Exception as exc: return { "schema": "onec_metadata_object_parts.v1", "status": "error", "base_id": base_id, "diagnostics": {"message": f"Payload classifier is unavailable: {exc}"}, } if extension_match is not None: file_names = [str(entry.get("cas_key") or "") for entry in manifest_entries][: int(part_limit or 200)] else: files = storage_files_list({"base_id": base_id, "table": table, "prefix": storage_prefix_guid, "limit": part_limit, "timeout_seconds": timeout_seconds, "_internal": True}) if files.get("status") != "ok": return public_error_result(files, include_storage=include_storage, method="metadata.object.parts") file_names = [ str(row.get("FileName") or "") for row in files.get("files") or [] if str(row.get("FileName") or "") == storage_prefix_guid or str(row.get("FileName") or "").startswith(f"{storage_prefix_guid}.") ] if kind == "Configuration" and guid and guid != storage_prefix_guid and guid not in file_names: file_names.insert(0, str(guid)) payloads, config, read_error = read_storage_files_bytes(base_id, table, file_names, timeout_seconds=timeout_seconds) if read_error: return public_error_result(read_error, include_storage=include_storage, method="metadata.object.parts") parts = [] manifest_suffix_by_key = {str(entry.get("cas_key") or ""): str(entry.get("suffix") or "") for entry in manifest_entries} for file_name in sorted(payloads or {}, key=lambda value: (value != guid, value != storage_prefix_guid, value)): data = (payloads or {})[file_name] classified = classify_payload( data, include_text=bool(include_text), include_tree=bool(include_tree), ) suffix = manifest_suffix_by_key.get(file_name, "" if file_name == guid else file_name[len(storage_prefix_guid) :]) part = { "part_id": file_name, "suffix": suffix, "table": table, "classification": classified, } parts.append(part) role_counts: dict[str, int] = {} for part in parts: role = str(((part.get("classification") or {}).get("role")) or "unknown") public_role = public_payload_role(role) if not include_storage else role role_counts[public_role] = role_counts.get(public_role, 0) + 1 public_parts = [] for part in parts: classification = part.get("classification") or {} if include_storage: public_part = dict(part) public_part["undecoded_evidence"] = payload_public_undecoded_evidence( classification, include_text_preview=bool(include_text), mode=str(evidence_mode or "summary"), allow_storage_details=True, ) public_parts.append(public_part) else: public_parts.append( { **payload_public_properties(classification), "preview": payload_public_preview(classification, include_text_preview=bool(include_text)), "undecoded_evidence": payload_public_undecoded_evidence( classification, include_text_preview=bool(include_text), mode=str(evidence_mode or "summary"), allow_storage_details=False, ), } ) return { "schema": "onec_metadata_object_parts.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table} if include_storage else {"kind": "live_metadata"}, "query": {"guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), "include_storage": include_storage}, "object": object_card or {"guid": guid, "kind": kind}, "parts": public_parts, "counts": {"parts": len(parts), "roles": dict(sorted(role_counts.items()))}, "diagnostics": { "note": "Диагностические координаты частей скрыты. Для служебной диагностики используйте include_storage=true.", **({"extension_manifest": {"status": "resolved", "entries": len(manifest_entries)}} if extension_match is not None else {}), }, } def fast_extension_object_exact_match( base_id: str, *, kind: str | None, name: str, extension_guid: str | None, table: str, timeout_seconds: int, ) -> dict[str, Any] | None: wanted_kind, wanted_name = parse_object_query(kind, name) guid_filter = wanted_name.lower() if is_guid_text(wanted_name) else "" cache_config, _ = sql_config_for_base(base_id) cache_rows = extension_route_cache_lookup( cache_config, query="" if guid_filter else wanted_name, kind_filter=wanted_kind, guid_filter=guid_filter, extension_guid=extension_guid, limit=20, ) exact_cached: list[dict[str, Any]] = [] for row in cache_rows: if guid_filter: is_exact = guid_filter in { str(row.get("guid") or "").lower(), str(row.get("descriptor_cas_key") or "").lower(), str(row.get("object_base_id") or "").lower(), } else: is_exact = normalize_exact(row.get("name") or "") == normalize_exact(wanted_name) if not is_exact: continue recent_freshness = extension_route_cache_recent_freshness(row, ttl_seconds=300) if recent_freshness: exact_cached.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness=recent_freshness)) continue validated_row, freshness = validate_extension_route_cache_row( base_id, cache_config, row, timeout_seconds=min(timeout_seconds, 30), ) if validated_row: exact_cached.append(extension_route_cache_row_to_match(base_id, validated_row, include_storage=True, freshness=freshness)) if len(exact_cached) == 1: return exact_cached[0] preferred_extension_guid = extension_guid if not preferred_extension_guid and wanted_name and not guid_filter: matching_extensions = [ item for item in extension_map_by_guid(base_id).values() if normalize_exact(item.get("name") or "") == normalize_exact(wanted_name) ] if len(matching_extensions) == 1: preferred_extension_guid = str(matching_extensions[0].get("guid") or "").lower() if not preferred_extension_guid: return None result = extension_objects_find( { "base_id": base_id, "extension": preferred_extension_guid, "kind": wanted_kind, **({"guid": guid_filter} if guid_filter else {"query": wanted_name}), "state": "save" if table == "ConfigCASSave" else "active", "limit": 20, "include_storage": True, "timeout_seconds": timeout_seconds, } ) exact_live = [ item for item in result.get("objects") or [] if isinstance(item, dict) and (not wanted_kind or canonical_kind(str(item.get("kind") or "")) == wanted_kind) and ( (guid_filter and str(item.get("guid") or "").lower() == guid_filter) or (not guid_filter and normalize_exact(item.get("name") or "") == normalize_exact(wanted_name)) ) ] return exact_live[0] if len(exact_live) == 1 else None def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.modules") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.object.modules") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument("metadata.object.modules", "extension_guid", "extension_guid must be a GUID string.") extension_filter = str(payload.get("extension") or "").strip() if extension_filter and not extension_guid: resolved_extension_guid, extension_error = extension_filter_to_guid(base_id, extension_filter, method="metadata.object.modules") if extension_error: return extension_error extension_guid = str(resolved_extension_guid or "") timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.modules", default=60, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(timeout_value or 60) ordinal_argument_error = validate_explicit_ordinal_arguments(payload, "metadata.object.modules") if ordinal_argument_error: return ordinal_argument_error lookup_limit, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.modules") if lookup_limit_error: return lookup_limit_error view, view_error = parse_view_argument(payload, "metadata.object.modules") if view_error: return view_error requested_module, requested_module_error = optional_string_filter(payload, ["module", "name_filter"], method="metadata.object.modules") if requested_module_error: return requested_module_error wanted_module = normalize(requested_module or "") include_storage, include_storage_error = strict_include_storage(payload, "metadata.object.modules") if include_storage_error: return include_storage_error include_storage = bool(include_storage) table_or_error = metadata_storage_table(payload, "metadata.object.modules") if isinstance(table_or_error, dict): return table_or_error table = table_or_error if extension_guid and "table" not in payload: table = "ConfigCAS" if "table" not in payload and not extension_guid and not truthy(payload.get("_extension_auto_resolved")): extension_match = fast_extension_object_exact_match( base_id, kind=payload.get("kind"), name=str(payload.get("name") or payload.get("guid") or ""), extension_guid=None, table=table, timeout_seconds=timeout_seconds, ) if extension_match: origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} return metadata_object_modules( { **payload, "kind": extension_match.get("kind") or payload.get("kind"), "name": extension_match.get("name") or payload.get("name"), "guid": extension_match.get("guid") or payload.get("guid"), "extension": extension.get("name"), "extension_guid": extension.get("guid"), "table": route.get("table") or "ConfigCAS", **({"file_name": route.get("file_name")} if route.get("file_name") else {}), "_extension_auto_resolved": True, } ) object_probe = get_object( payload.get("kind"), str(payload.get("name") or payload.get("guid") or ""), base_id=base_id, view=str(view or "effective"), limit=int(lookup_limit or 20), table=table, include_storage=True, ordinal=first_non_empty_arg(payload, "ordinal", "index", "object_index"), include_semantic=False, timeout_seconds=timeout_seconds, extension_guid=extension_guid or None, file_name=str(payload.get("file_name") or "").strip() or None, ) if object_probe.get("status") != "ok": if not truthy(payload.get("_extension_auto_resolved")): extension_match = fast_extension_object_exact_match( base_id, kind=payload.get("kind"), name=str(payload.get("name") or payload.get("guid") or ""), extension_guid=extension_guid or None, table=table, timeout_seconds=timeout_seconds, ) if extension_match: origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} return metadata_object_modules( { **payload, "kind": extension_match.get("kind") or payload.get("kind"), "name": extension_match.get("name") or payload.get("name"), "guid": extension_match.get("guid") or payload.get("guid"), "extension": extension.get("name") or extension_filter or None, "extension_guid": extension.get("guid") or extension_guid or None, "table": route.get("table") or ("ConfigCASSave" if table == "ConfigCASSave" else "ConfigCAS"), **({"file_name": route.get("file_name")} if route.get("file_name") else {}), "_extension_auto_resolved": True, } ) result = dict(object_probe) result["method"] = "metadata.object.modules" return result object_card = object_probe.get("object") or {} object_guid = str(object_card.get("guid") or payload.get("guid") or "").lower() config, _ = sql_config_for_base(base_id) cache_role = metadata_modules_cache_role() if config and object_guid and not truthy(payload.get("refresh_cache")): cached_result = metadata_guid_index_lookup_payload(config, object_guid, cache_role) if cached_result and extension_guid and not any( isinstance(module, dict) and ( str(module.get("kind") or "") == "form_module" or isinstance(module.get("form"), dict) ) for module in cached_result.get("modules") or [] ): cached_result = None if cached_result: cached_object = cached_result.get("object") if isinstance(cached_result.get("object"), dict) else {} merged_object = { **cached_object, **{key: value for key, value in object_card.items() if value is not None and value != ""}, } if extension_guid: merged_object["origin"] = { "source": "extension", "presentation": "Расширение", "extension": { "guid": extension_guid, **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), }, "status": "ok", } modules = [module for module in cached_result.get("modules") or [] if isinstance(module, dict)] owner_public = { "kind": merged_object.get("kind"), "kind_ru": merged_object.get("kind_ru"), "public_kind": merged_object.get("public_kind"), "guid": object_guid, "name": merged_object.get("name"), "synonym": merged_object.get("synonym"), } public_modules = [ public_module_with_qualified_name( module, owner=owner_public, include_storage=include_storage, ordinal=index + 1, owner_kind=merged_object.get("kind") or object_card.get("kind"), ) for index, module in enumerate(modules) ] cached_module_refs = [str(module.get("module_id") or "") for module in modules if str(module.get("module_id") or "").strip()] if config and object_guid: metadata_module_owner_cache_prune_for_owner(config, object_guid, cached_module_refs) for module in modules: module_id = str(module.get("module_id") or "") if not module_id: continue metadata_module_owner_cache_upsert(config, module_id, owner_public, module=module) form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} if form_payload: module_table, module_file_name, _module_stream_index = parse_module_id(module_id) merged_origin = merged_object.get("origin") if isinstance(merged_object.get("origin"), dict) else {} merged_extension = merged_origin.get("extension") if isinstance(merged_origin.get("extension"), dict) else {} if module_table in STORAGE_TABLES and module_file_name: metadata_form_owner_cache_upsert( config, base_id=base_id, owner_kind=owner_public.get("kind"), owner_name=owner_public.get("name"), owner_guid=owner_public.get("guid"), form_name=form_payload.get("name"), form_guid=form_payload.get("guid"), table=module_table, file_name=module_file_name, extension=merged_extension or None, ) matched_modules = [] for module, match_by in filter_public_rows_by_name(public_modules, requested_module): public_module = dict(module) if wanted_module: public_module["match_by"] = match_by matched_modules.append(public_module) if wanted_module and not matched_modules: result = child_not_found("metadata.object.modules", "Модуль", requested_module, merged_object or object_card, base_id=base_id) result.update( { "schema": "onec_object_modules.v1", "source": {"kind": "live_metadata"} if not include_storage else cached_result.get("source", {"kind": "live_metadata"}), "query": {"module": requested_module, "include_storage": include_storage}, "modules": [], "counts": {"modules": 0, "available_modules": len(public_modules)}, "cache": {"status": "hit", "role": cache_role}, } ) return result return { "schema": "onec_object_modules.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"} if not include_storage else cached_result.get("source", {"kind": "live_metadata"}), "object": merged_object or object_card, "query": {"module": requested_module, "include_storage": include_storage}, "modules": matched_modules, "counts": {**(cached_result.get("counts") or {}), "modules": len(matched_modules), "available_modules": len(public_modules)}, "cache": {"status": "hit", "role": cache_role}, "diagnostics": { "note": "Storage coordinates are hidden unless include_storage=true.", }, } parts_payload = { **payload, "guid": object_guid, "kind": object_card.get("kind") or payload.get("kind"), "table": table, # Module ownership may be evidenced by a declared UTF-8 BSL prefix # even when the module has no procedure/function (for example, an # intentionally comment-only object module). This read is internal; # public text remains controlled by the caller's code.read request. "include_text": True, "include_tree": False, "include_storage": True, "timeout_seconds": timeout_seconds, } if canonical_kind(str(object_card.get("kind") or payload.get("kind") or "")) == "Configuration" and object_card.get("name"): # Resolve by public name so metadata.object.parts can retain the # configuration identity GUID used as the SQL file prefix. parts_payload.pop("guid", None) parts_payload["name"] = object_card.get("name") parts_result = metadata_object_parts(parts_payload) if parts_result.get("status") != "ok": return public_error_result(parts_result, include_storage=include_storage, method="metadata.object.modules") modules = [] for part in parts_result.get("parts") or []: classification = part.get("classification") or {} owner_kind = canonical_kind(str(object_card.get("kind") or payload.get("kind") or "")) configuration_suffix = str(part.get("suffix") or "").lstrip(".") if owner_kind == "Configuration" else "" has_declared_bsl_prefix = any( isinstance(stream, dict) and stream.get("declared_utf8_bsl_prefix") for stream in (classification.get("stream_blocks") or []) ) if ( classification.get("role") != "bsl_module_payload" and not has_declared_bsl_prefix and configuration_suffix not in {"0", "5", "6", "7"} ): continue candidate_streams = [ (index, stream) for index, stream in enumerate(classification.get("stream_blocks") or []) if stream.get("has_bsl_marker") or stream.get("declared_utf8_bsl_prefix") ] # A Config service part contains one logical BSL module. Some platform # versions also repeat a short tail/head fragment as another marked # stream. Keep the full stream so callers do not see phantom modules. if owner_kind == "Configuration": if configuration_suffix in {"0", "5", "6", "7"}: streams = list(classification.get("stream_blocks") or []) # Configuration modules use the fourth zero-based stream in # the SQL container. The external-connection module may be # intentionally empty, so it has no BSL marker but must still # remain visible as a real configuration module. if len(streams) > 4: candidate_streams = [(4, streams[4])] if owner_kind in {"CommonModule", "WebService", "HTTPService", "IntegrationService"} and len(candidate_streams) > 1: candidate_streams = [ max( candidate_streams, key=lambda pair: ( int((pair[1] or {}).get("bytes") or 0), len(str((pair[1] or {}).get("text_preview") or "")), ), ) ] for index, stream in candidate_streams: modules.append( { "module_id": f"{part.get('table')}:{part.get('part_id')}#stream:{index}", "table": part.get("table"), "file_name": part.get("part_id"), "suffix": part.get("suffix"), "stream_index": index, "kind": "bsl_stream_module", "name": f"{part.get('part_id')}#stream:{index}", "bytes": stream.get("bytes"), "sha1": stream.get("sha1"), "encoding": stream.get("encoding"), "text_preview": stream.get("text_preview"), "payload_role": classification.get("role"), **( {"code_evidence": "declared_utf8_bsl_prefix"} if stream.get("declared_utf8_bsl_prefix") else {} ), } ) parts_object = parts_result.get("object") if isinstance(parts_result.get("object"), dict) else {} resolved_object_card = { **object_card, **{key: value for key, value in parts_object.items() if value is not None and value != ""}, } if extension_guid: resolved_object_card["origin"] = { "source": "extension", "presentation": "Расширение", "extension": { "guid": extension_guid, **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), }, "status": "ok", } owner_public = { "kind": resolved_object_card.get("kind"), "kind_ru": resolved_object_card.get("kind_ru"), "public_kind": resolved_object_card.get("public_kind"), "guid": object_guid, "name": resolved_object_card.get("name"), "synonym": resolved_object_card.get("synonym"), } object_storage = resolved_object_card.get("storage") if isinstance(resolved_object_card.get("storage"), dict) else {} forms_result = metadata_object_forms( { **payload, "base_id": base_id, "kind": owner_public.get("kind"), "name": owner_public.get("name"), "guid": owner_public.get("guid"), "table": table, **({"file_name": object_storage.get("file_name")} if object_storage.get("file_name") else {}), "include_storage": True, "timeout_seconds": timeout_seconds, "_extension_auto_resolved": True, } ) known_module_refs = {str(module.get("module_id") or "") for module in modules} for form in forms_result.get("forms") or []: if not isinstance(form, dict): continue form_source = form.get("source") if isinstance(form.get("source"), dict) else {} form_table = str(form_source.get("table") or table) form_file_name = str(form_source.get("file_name") or "") if form_table not in STORAGE_TABLES or not form_file_name: continue form_module_ref = f"{form_table}:{form_file_name}#form_module" if form_module_ref in known_module_refs: continue decoded_form = metadata_form_decode( { "base_id": base_id, "table": form_table, "file_name": form_file_name, "include_storage": True, "include_parameters": False, "include_module": True, "include_module_text": True, "timeout_seconds": timeout_seconds, } ) profile = decoded_form.get("profile") if isinstance(decoded_form.get("profile"), dict) else {} decoded_module = profile.get("module") if isinstance(profile.get("module"), dict) else {} decoded_text = str(decoded_module.get("text") or "") # A form payload is not automatically a writable module. Register it # as such only when its code was actually decoded from this payload. # Otherwise a caller could overwrite a form container while the real # BSL module lives in a separate, still-unresolved CAS record. if not decoded_text.strip() or not is_bsl_like_text(decoded_text): continue form_payload = { "guid": form.get("guid"), "name": form.get("name"), "synonym": next(iter((form.get("synonyms") or {}).values()), None) if isinstance(form.get("synonyms"), dict) else None, } modules.append( { "module_id": form_module_ref, "table": form_table, "file_name": form_file_name, "kind": "form_module", "name": "Модуль формы", "form": form_payload, "payload_role": "form_payload", "code_evidence": "decoded_form_module", } ) known_module_refs.add(form_module_ref) metadata_form_owner_cache_upsert( config, base_id=base_id, owner_kind=owner_public.get("kind"), owner_name=owner_public.get("name"), owner_guid=owner_public.get("guid"), form_name=form_payload.get("name"), form_guid=form_payload.get("guid"), table=form_table, file_name=form_file_name, extension={ "guid": extension_guid, **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), } if extension_guid else None, ) public_modules = [ public_module_with_qualified_name( module, owner=owner_public, include_storage=include_storage, ordinal=index + 1, owner_kind=owner_public.get("kind"), ) for index, module in enumerate(modules) ] module_refs = [str(module.get("module_id") or "") for module in modules if str(module.get("module_id") or "").strip()] if config and object_guid: metadata_module_owner_cache_prune_for_owner(config, object_guid, module_refs) for module in modules: module_id = str(module.get("module_id") or "") if not module_id: continue metadata_module_owner_cache_upsert(config, module_id, owner_public, module=module) matched_modules = [] for module, match_by in filter_public_rows_by_name(public_modules, requested_module): public_module = dict(module) if wanted_module: public_module["match_by"] = match_by matched_modules.append(public_module) if wanted_module and not matched_modules: result = child_not_found("metadata.object.modules", "Модуль", requested_module, resolved_object_card, base_id=base_id) result.update( { "schema": "onec_object_modules.v1", "source": parts_result.get("source") if include_storage else {"kind": "live_metadata"}, "query": {"module": requested_module, "include_storage": include_storage}, "modules": [], "counts": {"modules": 0, "available_modules": len(public_modules), "parts": (parts_result.get("counts") or {}).get("parts")}, } ) return result result = { "schema": "onec_object_modules.v1", "status": "ok", "base_id": base_id, "source": parts_result.get("source") if include_storage else {"kind": "live_metadata"}, "object": resolved_object_card, "query": {"module": requested_module, "include_storage": include_storage}, "modules": matched_modules, "counts": {"modules": len(matched_modules), "available_modules": len(public_modules), "parts": (parts_result.get("counts") or {}).get("parts")}, "diagnostics": { "note": "Storage coordinates are hidden unless include_storage=true.", }, } if config and object_guid: cache_payload = { "object": resolved_object_card, "modules": modules, "counts": {"modules": len(modules), "parts": (parts_result.get("counts") or {}).get("parts")}, "source": parts_result.get("source"), } metadata_guid_index_upsert( config, { "guid": object_guid, "guid_role": cache_role, "kind": object_card.get("kind"), "kind_ru": object_card.get("kind_ru"), "public_kind": object_card.get("public_kind"), "name": object_card.get("name"), "synonym": object_card.get("synonym"), "presentation": ".".join(part for part in [object_card.get("kind_ru"), object_card.get("name")] if part), "payload": cache_payload, "source_file": object_guid, }, ) result["cache"] = {"status": "stored", "role": cache_role} return result def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.related") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.object.related") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error guid_error = validate_explicit_guid_argument(payload, "metadata.object.related") if guid_error: return guid_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument( "metadata.object.related", "extension_guid", "extension_guid must be a GUID string.", ) extension_filter = str(payload.get("extension") or "").strip() if extension_filter and not extension_guid: resolved_extension_guid, extension_error = extension_filter_to_guid( base_id, extension_filter, method="metadata.object.related" ) if extension_error: return extension_error extension_guid = str(resolved_extension_guid or "").strip().lower() timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.related", default=60, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(timeout_value or 60) ordinal_argument_error = validate_explicit_ordinal_arguments(payload, "metadata.object.related") if ordinal_argument_error: return ordinal_argument_error lookup_limit, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.related") if lookup_limit_error: return lookup_limit_error view, view_error = parse_view_argument(payload, "metadata.object.related") if view_error: return view_error include_storage, include_storage_error = strict_include_storage(payload, "metadata.object.related") if include_storage_error: return include_storage_error include_storage = bool(include_storage) include_text, include_text_error = strict_bool_argument(payload, "include_text", method="metadata.object.related", default=False) if include_text_error: return include_text_error guids_per_record, guids_per_record_error = parse_int_argument(payload, "guids_per_record", method="metadata.object.related", default=5, minimum=1, maximum=50) if guids_per_record_error: return guids_per_record_error table_or_error = metadata_storage_table(payload, "metadata.object.related") if isinstance(table_or_error, dict): return table_or_error table = table_or_error object_probe = get_object( payload.get("kind"), str(payload.get("name") or payload.get("guid") or ""), base_id=base_id, view=str(view or "effective"), limit=int(lookup_limit or 20), table=table, extension_guid=extension_guid or None, include_storage=True, ordinal=first_non_empty_arg(payload, "ordinal", "index", "object_index"), include_semantic=False, timeout_seconds=timeout_seconds, ) if object_probe.get("status") != "ok": result = dict(object_probe) result["method"] = "metadata.object.related" return result object_card = object_probe.get("object") or {} guid = str(object_card.get("guid") or payload.get("guid") or "").lower() kind = str(object_card.get("kind") or payload.get("kind") or "") object_table = preferred_object_storage_table(object_card, table) object_file_name = str(((object_card.get("storage") or {}).get("file_name") if isinstance(object_card.get("storage"), dict) else "") or guid) rules = RELATED_SECTION_RULES.get(str(kind or ""), []) if not rules: return { "schema": "onec_metadata_object_related.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_metadata"}, "object": public_metadata_row(object_card or {"guid": guid, "kind": kind}, include_storage=include_storage), "rules": [], "related": [], "counts": {"related": 0, "by_category": {}, "by_status": {}}, "capabilities": { "related": False, "reason": "У этого вида объекта адаптер не знает связанных разделов.", }, } data, config, read_error = read_storage_file_bytes(base_id, object_table, object_file_name, timeout_seconds=timeout_seconds) if read_error: return public_error_result(read_error, include_storage=include_storage, method="metadata.object.related") tree = parse_config_tree_from_bytes(data) if tree is None: return { "schema": "onec_metadata_object_related.v1", "status": "undecodable", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": object_table, "file_name": object_file_name} if include_storage else {"kind": "live_metadata"}, "object": public_metadata_row(object_card or {"guid": guid, "kind": kind}, include_storage=include_storage), "related": [], "counts": {"related": 0}, } try: from parser.cas_payload import classify_payload from parser.child_records import declared_child_records from parser.config_object import find_identity from parser.structured_metadata import get_by_path except Exception as exc: return { "schema": "onec_metadata_object_related.v1", "status": "error", "base_id": base_id, "diagnostics": {"message": f"Related object decoder is unavailable: {exc}"}, } related = [] for rule in rules: section = get_by_path(tree, str(rule["path"])) if section is None: continue for record in declared_child_records(section, str(rule["path"])): record_identity = find_identity(record.node) candidate_guids = [] if record_identity: candidate_guids.append(record_identity.guid) else: for candidate in sorted(record.evidence.get("guids") or []): if candidate == "00000000-0000-0000-0000-000000000000": continue if candidate not in candidate_guids: candidate_guids.append(candidate) if not candidate_guids: item = {"category": rule["category"], "status": "no_guid_evidence"} if include_storage: item.update({"section_path": rule["path"], "record_path": record.path, "record_index": record.index}) related.append(item) continue for related_guid in candidate_guids[:guids_per_record]: item: dict[str, Any] = { "category": rule["category"], "guid": related_guid, "status": "source_missing", } if record_identity: item["record_identity"] = record_identity.to_dict() if include_storage: item.update( { "section_path": rule["path"], "record_path": record.path, "record_index": record.index, "source": {"kind": "live_sql", "table": object_table, "file_name": related_guid}, } ) related_file_name = ( f"{extension_guid}__{related_guid}" if object_table == "ConfigCASSave" and extension_guid else related_guid ) manifest_match: dict[str, Any] | None = None if extension_guid and object_table in {"ConfigCAS", "ConfigCASSave"}: manifest_matches, _manifest_diagnostics, _manifest_stats = extension_manifest_object_matches( base_id=base_id, query="", kind_filter=None, guid_filter=str(related_guid).lower(), extension_guid=extension_guid, limit=1, timeout_seconds=timeout_seconds, include_storage=True, ) if manifest_matches: manifest_match = manifest_matches[0] manifest_route = manifest_match.get("route") if isinstance(manifest_match.get("route"), dict) else {} manifest_file_name = str(manifest_route.get("file_name") or "").strip() if manifest_file_name: related_file_name = manifest_file_name manifest_name = str(manifest_match.get("name") or "").strip() if normalize("СхемаКомпоновкиДанных") in normalize(manifest_name): item["category"] = "DataCompositionSchema" item["classification_evidence"] = "extension_manifest_name" if manifest_name: item["name"] = manifest_name if include_storage: item["source"]["file_name"] = related_file_name related_data, _, related_error = read_storage_file_bytes(base_id, object_table, related_file_name, timeout_seconds=timeout_seconds) if related_error and object_table == "ConfigCASSave" and rule["category"] == "Command": command_module_file_name = f"{related_file_name}.2" related_data, _, related_error = read_storage_file_bytes( base_id, object_table, command_module_file_name, timeout_seconds=timeout_seconds, ) if not related_error: related_file_name = command_module_file_name if include_storage: item["source"]["file_name"] = related_file_name if related_error: if include_storage: item["diagnostics"] = related_error.get("diagnostics") else: item["diagnostics"] = {"message": "Описание связанного объекта метаданных не найдено или недоступно."} related.append(item) continue classified = classify_payload( related_data, include_text=bool(include_text), include_tree=False, ) item["status"] = "ok" item["identity"] = config_identity_from_bytes(related_data) or (record_identity.to_dict() if record_identity else None) # СКД в базовой конфигурации, как и в расширениях, хранится # обычным Template. У расширений имя уточняется манифестом, # у базовых объектов единственный надёжный публичный признак — # подтверждённое имя из самого шаблона. identity_name = str((item.get("identity") or {}).get("name") or "") if ( item.get("category") == "Template" and normalize("СхемаКомпоновкиДанных") in normalize(identity_name) ): item["category"] = "DataCompositionSchema" item["classification_evidence"] = "template_identity_name" # The decoded Template identity is the public 1C name of a # base SCD. Promote it after classification so callers can # select the exact schema by name without using a GUID. if item.get("category") == "DataCompositionSchema" and identity_name: item["name"] = identity_name if manifest_match: item["route_resolution"] = "extension_manifest_guid" if include_storage: item["classification"] = {key: value for key, value in classified.items() if key not in {"text", "tree"}} related.append(item) counts_by_category: dict[str, int] = {} counts_by_status: dict[str, int] = {} for item in related: category = str(item.get("category") or "") status = str(item.get("status") or "") counts_by_category[category] = counts_by_category.get(category, 0) + 1 counts_by_status[status] = counts_by_status.get(status, 0) + 1 return { "schema": "onec_metadata_object_related.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": object_table, "file_name": object_file_name} if include_storage else {"kind": "live_metadata"}, "object": public_metadata_row(object_card or {"guid": guid, "kind": kind}, include_storage=include_storage), "rules": rules if include_storage else [{"category": rule.get("category")} for rule in rules], "related": related, "counts": { "related": len(related), "by_category": dict(sorted(counts_by_category.items())), "by_status": dict(sorted(counts_by_status.items())), }, "diagnostics": { "note": "Storage coordinates are hidden unless include_storage=true.", }, } def appearance_inspect(payload: dict[str, Any]) -> dict[str, Any]: """Read a proven base CommonTemplate appearance XML carrier by public name.""" method = "appearance.inspect" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error if payload.get("extension") not in {None, ""} or payload.get("extension_guid") not in {None, ""}: return { "schema": "onec_appearance_inspect.v1", "method": method, "status": "unsupported", "base_id": base_id, "diagnostics": {"code": "APPEARANCE_EXTENSION_ROUTE_UNPROVEN", "message": "Only a base CommonTemplate carrier is proven for this reader."}, } template = str(payload.get("template") or payload.get("name") or payload.get("guid") or "").strip() if not template: return invalid_argument(method, "template", "template is required as a public CommonTemplate name or GUID.") timeout_seconds = int(payload.get("timeout_seconds") or 60) resolved = get_object("CommonTemplate", template, base_id=base_id, view="effective", limit=2, include_storage=True, include_semantic=False, timeout_seconds=timeout_seconds, table="Config") if resolved.get("status") != "ok" or not isinstance(resolved.get("object"), dict): return {"schema": "onec_appearance_inspect.v1", "method": method, "status": resolved.get("status") or "not_found", "base_id": base_id, "diagnostics": resolved.get("diagnostics") or {}} object_card = resolved["object"] guid = str(object_card.get("guid") or "").strip() if not guid: return {"schema": "onec_appearance_inspect.v1", "method": method, "status": "unresolved", "base_id": base_id, "diagnostics": {"code": "APPEARANCE_GUID_NOT_PROVEN"}} data, _, read_error = read_storage_file_bytes(base_id, "Config", f"{guid}.0", timeout_seconds=min(timeout_seconds, 60)) if read_error or data is None: return {"schema": "onec_appearance_inspect.v1", "method": method, "status": "not_found", "base_id": base_id, "diagnostics": (read_error or {}).get("diagnostics") or {"code": "APPEARANCE_PAYLOAD_NOT_FOUND"}} from parser.appearance_payload import inspect_appearance_payload decoded = inspect_appearance_payload(data) return { "schema": "onec_appearance_inspect.v1", "method": method, "status": decoded.get("status"), "base_id": base_id, "template": public_metadata_row(object_card), "appearance": decoded, "template_type": { "status": "unresolved", "reason": "AppearanceTemplate XML root selects this read-only decoder but does not prove the 1C TemplateType; a decoded metadata descriptor is required.", }, "activation_state": "working_not_runtime_applied", } def report_inspect(payload: dict[str, Any]) -> dict[str, Any]: """Return the confirmed component graph of a report without filling gaps.""" method = "report.inspect" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error report = str(payload.get("report") or payload.get("name") or "").strip() if not report: return invalid_argument(method, "report", "report is required as a public 1C report name.") component_payload = {"base_id": base_id_or_error, "kind": "Report", "name": report, "include_storage": False, "timeout_seconds": int(payload.get("timeout_seconds") or 60)} for key in ("extension", "extension_guid"): if payload.get(key) not in {None, ""}: component_payload[key] = payload[key] components_result = metadata_object_components(component_payload) if not isinstance(components_result.get("object"), dict): return {"schema": "onec_report_inspect.v1", "method": method, "status": components_result.get("status") or "not_found", "base_id": base_id_or_error, "unresolved": components_result.get("unresolved") or [], "diagnostics": components_result.get("diagnostics") or {}} components = [item for item in components_result.get("components") or [] if isinstance(item, dict)] return { "schema": "onec_report_inspect.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "report": public_metadata_row(components_result["object"]), "components": components, "scd": [item for item in components if item.get("component_kind") == "scd"], "templates": [item for item in components if item.get("component_kind") in {"scd", "template"}], "forms": [item for item in components if item.get("component_kind") == "form"], "modules": [item for item in components if item.get("component_kind") == "module"], "unresolved": components_result.get("unresolved") or [], "diagnostics": {"rule": "Only components proven by the individual live-SQL readers are included; a form is never promoted to an SCD by name."}, } def scd_inspect(payload: dict[str, Any]) -> dict[str, Any]: """Resolve and decode one report SCD from its SQL ConfigCAS payload.""" method = "scd.inspect" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error report = str(payload.get("report") or payload.get("name") or payload.get("object_name") or "").strip() if not report: return invalid_argument(method, "report", "report is required as a public 1C report name.") schema_name = str(payload.get("schema") or "").strip() requested_sections = payload.get("sections") or ["data_sources", "parameters", "datasets", "fields", "calculated_fields", "resources", "groupings", "settings", "variants", "total_fields"] if not isinstance(requested_sections, list) or not all(isinstance(item, str) for item in requested_sections): return invalid_argument(method, "sections", "sections must be an array of strings.") resolve_metadata, resolve_metadata_error = strict_bool_argument(payload, "resolve_metadata", method=method, default=True) if resolve_metadata_error: return resolve_metadata_error related_payload = { "base_id": base_id, "kind": "Report", "name": report, "include_storage": True, "timeout_seconds": int(payload.get("timeout_seconds") or 60), } if payload.get("extension") not in {None, ""}: related_payload["extension"] = payload.get("extension") if payload.get("extension_guid") not in {None, ""}: related_payload["extension_guid"] = payload.get("extension_guid") related = metadata_object_related(related_payload) if related.get("status") != "ok": related["method"] = method return related schemas = [item for item in related.get("related") or [] if isinstance(item, dict) and item.get("category") == "DataCompositionSchema" and item.get("status") == "ok"] discovered_schemas = list(schemas) if schema_name: schemas = [item for item in schemas if normalize(str(item.get("name") or "")) == normalize(schema_name)] if not schemas: unnamed_schemas = [item for item in discovered_schemas if not str(item.get("name") or "").strip()] if schema_name and unnamed_schemas: return { "schema": "onec_scd_inspect.v1", "method": method, "status": "partial", "error": "scd_schema_name_not_proven", "base_id": base_id, "extension": payload.get("extension"), "report": report, "requested_schema": schema_name, "schemas": [{"name": item.get("name"), "guid": item.get("guid")} for item in discovered_schemas], "diagnostics": {"code": "SCD_SCHEMA_NAME_NOT_PROVEN", "message": "An SCD payload is proven, but its name is absent from decoded SQL metadata; the requested template name cannot be matched safely.", "next_call": {"method": method, "params": {"base_id": base_id, "report": report}}}, } return { "schema": "onec_scd_inspect.v1", "method": method, "status": "not_found", "error": "scd_schema_not_found", "base_id": base_id, "extension": payload.get("extension"), "report": report, **({"requested_schema": schema_name} if schema_name else {}), "diagnostics": {"code": "SCD_SCHEMA_NOT_FOUND", "message": "No DataCompositionSchema was proven by the report related-object graph."}, } if len(schemas) > 1: return { "schema": "onec_scd_inspect.v1", "method": method, "status": "ambiguous", "base_id": base_id, "extension": payload.get("extension"), "report": report, "schemas": [{"name": item.get("name"), "guid": item.get("guid")} for item in schemas], "diagnostics": {"code": "SCD_SCHEMA_AMBIGUOUS", "message": "Several schemas were found; pass schema exactly."}, } schema = schemas[0] timeout_seconds = int(payload.get("timeout_seconds") or 60) extension_guid = str(payload.get("extension_guid") or "").strip().lower() if not extension_guid and payload.get("extension") not in {None, ""}: extension_guid, extension_error = extension_filter_to_guid(base_id, str(payload.get("extension")), method=method) if extension_error: extension_guid = "" schema_manifest: dict[str, Any] = {} if extension_guid: matches, _, _ = extension_manifest_object_matches( base_id=base_id, query="", kind_filter=None, guid_filter=str(schema.get("guid") or "").lower(), extension_guid=extension_guid, limit=1, timeout_seconds=timeout_seconds, include_storage=True, ) match = matches[0] if matches else None entries = match.get("manifest_entries") if isinstance(match, dict) and isinstance(match.get("manifest_entries"), list) else [] payload_entry = next((entry for entry in entries if isinstance(entry, dict) and str(entry.get("suffix") or "") == ".0" and str(entry.get("cas_key") or "").strip()), None) if payload_entry: schema_manifest = {"table": "ConfigCAS", "file_name": str(payload_entry.get("cas_key")), "suffix": ".0"} decoded: dict[str, Any] | None = None artifact_cache: dict[str, Any] | None = None if not extension_guid: # В базовой конфигурации XML СКД не имеет манифеста расширения: он # лежит в одном из потоков Template с тем же GUID. Проверяем только # доказанную группу файлов этого шаблона, а не сканируем Config целиком. base_route = schema.get("source") if isinstance(schema.get("source"), dict) else {} template_file_name = str(base_route.get("file_name") or schema.get("guid") or "").strip() if template_file_name: cache_config, _ = sql_config_for_base(base_id) cached_route = decoded_artifact_cache_lookup_source_prefix( cache_config, artifact_kind=SCD_TEMPLATE_ARTIFACT_KIND, source_table="Config", source_file_prefix=template_file_name, ) if cached_route: cached_file = str(cached_route.get("source_file") or "") cached_bytes, _, cached_error = read_storage_file_bytes(base_id, "Config", cached_file, timeout_seconds=min(timeout_seconds, 60)) cached_artifact = cached_route.get("artifact") if isinstance(cached_route.get("artifact"), dict) else {} cached_sections = cached_artifact.get("sections_requested") if isinstance(cached_artifact.get("sections_requested"), list) else [] cached_decoded = cached_artifact.get("decoded") if isinstance(cached_artifact.get("decoded"), dict) else None if cached_bytes and not cached_error and hashlib.sha1(cached_bytes).hexdigest() == str(cached_route.get("content_sha1") or "") and cached_decoded and set(requested_sections) <= set(cached_sections): schema_manifest = {"table": "Config", "file_name": cached_file} decoded = cached_decoded artifact_cache = {"status": "live_sha1_verified_hit", "content_sha1": cached_route.get("content_sha1")} files = storage_files_list({ "base_id": base_id, "table": "Config", "prefix": template_file_name, "limit": 100, "timeout_seconds": min(timeout_seconds, 60), "_internal": True, }) if decoded is None and files.get("status") == "ok": try: from parser.scd_payload import inspect_scd_payload for row in files.get("files") or []: candidate_name = str(row.get("FileName") or "").strip() if not candidate_name: continue candidate_bytes, _, candidate_error = read_storage_file_bytes( base_id, "Config", candidate_name, timeout_seconds=min(timeout_seconds, 60), ) if candidate_error or not candidate_bytes: continue candidate = inspect_scd_payload(candidate_bytes, sections=requested_sections) if candidate.get("status") == "ok": schema_manifest = {"table": "Config", "file_name": candidate_name} decoded = candidate candidate_sha1 = hashlib.sha1(candidate_bytes).hexdigest() decoded_artifact_cache_upsert( cache_config, artifact_kind=SCD_TEMPLATE_ARTIFACT_KIND, content_sha1=candidate_sha1, source_table="Config", source_file=candidate_name, payload_bytes=len(candidate_bytes), artifact={"sections_requested": requested_sections, "decoded": candidate}, ) artifact_cache = {"status": "live_sha1_verified_miss_stored", "content_sha1": candidate_sha1} break except Exception as exc: decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": {"code": "SCD_DECODER_ERROR", "message": str(exc)}} if schema_manifest: if decoded is None: schema_bytes, _, read_error = read_storage_file_bytes( base_id, str(schema_manifest.get("table") or "ConfigCAS"), str(schema_manifest["file_name"]), timeout_seconds=min(timeout_seconds, 60), ) if read_error: decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": read_error} elif schema_bytes: try: from parser.scd_payload import inspect_scd_payload decoded = inspect_scd_payload(schema_bytes, sections=requested_sections) except Exception as exc: decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": {"code": "SCD_DECODER_ERROR", "message": str(exc)}} if decoded and decoded.get("status") == "ok": analysis = dict(decoded.get("analysis") or {}) source_scan = analysis.get("data_source_references") if isinstance(analysis.get("data_source_references"), dict) else {} source_kind_map = { "регистрнакопления": "AccumulationRegister", "регистрсведений": "InformationRegister", "регистрбухгалтерии": "AccountingRegister", "регистррасчета": "CalculationRegister", "справочник": "Catalog", "документ": "Document", "планвидовхарактеристик": "ChartOfCharacteristicTypes", } resolved_sources: list[dict[str, Any]] = [] for dataset in (source_scan.get("datasets") or []) if resolve_metadata else []: if not isinstance(dataset, dict): continue for source_name in dataset.get("sources") or []: parts = str(source_name or "").split(".", 1) kind = source_kind_map.get(normalize(parts[0])) if len(parts) == 2 else None if not kind: resolved_sources.append({"dataset": dataset.get("dataset"), "source": source_name, "status": "unsupported_source_kind"}) continue resolved = get_object(kind, parts[1], base_id=base_id, view="effective", limit=2, include_semantic=False, timeout_seconds=min(timeout_seconds, 30), table="Config") card = resolved.get("object") if isinstance(resolved, dict) and isinstance(resolved.get("object"), dict) else {} resolved_sources.append({ "dataset": dataset.get("dataset"), "source": source_name, "kind": kind, "name": parts[1], "status": "resolved" if resolved.get("status") == "ok" and card else "not_found", **({"object": public_metadata_row(card)} if card else {}), }) if resolved_sources: analysis["data_source_resolution"] = {"kind": "live_sql_metadata_resolution", "sources": resolved_sources} direct_references = analysis.get("query_direct_field_references") if isinstance(analysis.get("query_direct_field_references"), dict) else {} if direct_references and resolved_sources: source_by_alias: dict[tuple[str, str], dict[str, Any]] = {} for dataset in source_scan.get("datasets") or []: if not isinstance(dataset, dict): continue for binding in dataset.get("bindings") or []: if isinstance(binding, dict) and binding.get("alias"): source_by_alias[(str(dataset.get("dataset") or ""), str(binding.get("alias")))] = binding source_objects = {str(item.get("source") or ""): item for item in resolved_sources if item.get("status") == "resolved"} source_fields: dict[str, set[str]] = {} field_resolution: list[dict[str, Any]] = [] for dataset in direct_references.get("datasets") or []: if not isinstance(dataset, dict): continue dataset_name = str(dataset.get("dataset") or "") for reference in dataset.get("references") or []: alias, field = str(reference.get("alias") or ""), str(reference.get("field") or "") binding = source_by_alias.get((dataset_name, alias)) or {} source_name = str(binding.get("source") or "") source_object = source_objects.get(source_name) or {} card = source_object.get("object") if isinstance(source_object.get("object"), dict) else {} if not card: field_resolution.append({"dataset": dataset_name, "alias": alias, "field": field, "status": "source_not_resolved"}) continue object_ref = str(card.get("ref") or source_name) if object_ref not in source_fields: fields_result = metadata_object_attributes({"base_id": base_id, "kind": card.get("kind"), "guid": card.get("guid"), "only": "all", "timeout_seconds": min(timeout_seconds, 30)}) names: set[str] = set() if fields_result.get("status") == "ok": for section_name in ("attributes", "dimensions", "resources"): names.update(normalize(str(item.get("name") or "")) for item in fields_result.get(section_name) or [] if item.get("name")) source_fields[object_ref] = names field_resolution.append({ "dataset": dataset_name, "alias": alias, "field": field, "source": source_name, "status": "resolved" if normalize(field) in source_fields[object_ref] else "not_declared_in_decoded_fields", }) if field_resolution: analysis["direct_field_resolution"] = {"kind": "live_sql_field_metadata_resolution", "references": field_resolution} return { "schema": "onec_scd_inspect.v1", "method": method, "status": "ok", "base_id": base_id, "extension": payload.get("extension"), "report": report, "configuration_view": configuration_view_details(str(payload.get("configuration_view") or "effective_working")), "activation_state": "working_not_runtime_applied", "scd": {"name": schema.get("name"), "guid": schema.get("guid"), "source": "extension_manifest_guid" if extension_guid else "template_payload_group", "payload_route": schema_manifest, "container": decoded.get("container")}, "sections": decoded.get("sections") or {}, "analysis": analysis, "metadata_resolution": "live_sql" if resolve_metadata else "skipped_for_field_scoped_static_analysis", **({"artifact_cache": artifact_cache} if artifact_cache else {}), "diagnostics": decoded.get("diagnostics") or {}, "unresolved": [], } return { "schema": "onec_scd_inspect.v1", "method": method, "status": "partial", "base_id": base_id, "extension": payload.get("extension"), "report": report, "configuration_view": configuration_view_details(str(payload.get("configuration_view") or "effective_working")), "activation_state": "working_not_runtime_applied", "scd": {"name": schema.get("name"), "guid": schema.get("guid"), "source": "extension_manifest_guid" if extension_guid else "template_payload_group", "read_selector": schema.get("source"), **({"payload_route": schema_manifest} if schema_manifest else {})}, "sections": {section: [] for section in requested_sections}, "unresolved": [{"code": "SCD_PAYLOAD_DECODE_UNAVAILABLE", "source": "schema_payload" if schema_manifest else "schema", "message": "Schema route is proven, but the SQL payload could not be decoded.", **({"details": decoded.get("error") or decoded.get("container")} if decoded else {})}], } def scd_prepare(payload: dict[str, Any]) -> dict[str, Any]: """Prepare every active SQL file needed by one report SCD without exposing file keys.""" method = "scd.prepare" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error report = str(payload.get("report") or payload.get("name") or "").strip() extension = str(payload.get("extension") or "").strip() if not report: return invalid_argument(method, "report", "report is required as a public 1C report name.") timeout_seconds = int(payload.get("timeout_seconds") or 60) inspect = scd_inspect({ "base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "timeout_seconds": timeout_seconds, }) if inspect.get("status") != "ok": return {"schema": "onec_scd_prepare.v1", "method": method, "status": inspect.get("status"), "base_id": base_id, "inspect": inspect} layer = "extension_saved_state" if extension else "base_saved_state" if extension: prepare_payload = { "base_id": base_id, "extension": extension, "ref": f"Report.{report}", "layer": layer, "mode": str(payload.get("mode") or "plan"), "merge_missing": True, "timeout_seconds": timeout_seconds, } else: report_files, _object_card, files_error = saved_state_prepare_file_names( {"ref": f"Report.{report}", "kind": "Report", "name": report}, base_id, "Config", timeout_seconds, ) if files_error: return {"schema": "onec_scd_prepare.v1", "method": method, "status": files_error.get("status") or "error", "base_id": base_id, "error": "scd_report_files_not_resolved", "diagnostics": files_error.get("diagnostics")} route = (inspect.get("scd") or {}).get("payload_route") if isinstance((inspect.get("scd") or {}).get("payload_route"), dict) else {} payload_file_name = str(route.get("file_name") or "").strip() template_prefix = payload_file_name.split(".", 1)[0] template_files: list[str] = [] if template_prefix: listed = storage_files_list({ "base_id": base_id, "table": "Config", "prefix": template_prefix, "limit": 100, "timeout_seconds": min(timeout_seconds, 60), "_internal": True, }) if listed.get("status") == "ok": template_files = [ str(row.get("FileName") or "") for row in listed.get("files") or [] if str(row.get("FileName") or "") == template_prefix or str(row.get("FileName") or "").startswith(f"{template_prefix}.") ] all_files = sorted(set(report_files + template_files)) if payload_file_name and payload_file_name not in all_files: return {"schema": "onec_scd_prepare.v1", "method": method, "status": "partial", "base_id": base_id, "report": report, "diagnostics": {"code": "SCD_TEMPLATE_GROUP_NOT_RESOLVED", "message": "The active SCD template payload group could not be resolved for saved-state preparation."}} prepare_payload = { "base_id": base_id, "ref": f"Report.{report}", "kind": "Report", "name": report, "file_names": all_files, "layer": layer, "mode": str(payload.get("mode") or "plan"), "timeout_seconds": timeout_seconds, } for flag in ("allow_sql_saved_state_prepare", "include_storage", "merge_missing"): if flag in payload: prepare_payload[flag] = payload[flag] # Keep the preparation receipt internally even for the public response: # it is an opaque rollback capability, not a storage coordinate. public_include_storage = bool(payload.get("include_storage") is True) prepare_payload["include_storage"] = True prepared = metadata_saved_state_prepare(prepare_payload) receipt = ((prepared.get("apply_result") or {}).get("prepare_receipt") or {}) if isinstance(prepared.get("apply_result"), dict) else {} return { "schema": "onec_scd_prepare.v1", "method": method, "status": prepared.get("status"), "base_id": base_id, "extension": extension or None, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), "prepared": public_saved_state_prepare_embedded_result(prepared, {**prepare_payload, "include_storage": public_include_storage}), **({"prepare_receipt_id": receipt.get("receipt_id")} if receipt.get("receipt_id") else {}), "write_mode": {"target": "saved_state", "sql_write_performed": bool(prepared.get("applied")), "requires_allow_flag": True}, } def scd_prepare_rollback(payload: dict[str, Any]) -> dict[str, Any]: """Rollback only rows inserted by a previous scd.prepare apply receipt.""" method = "scd.prepare.rollback" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error receipt_id = str(payload.get("prepare_receipt_id") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{32}", receipt_id): return invalid_argument(method, "prepare_receipt_id", "prepare_receipt_id must be the 32-character receipt returned by scd.prepare.") allowed, allowed_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allowed_error: return allowed_error if not allowed: return invalid_argument(method, "allow_sql_saved_state_rollback", "Rollback is opt-in; pass allow_sql_saved_state_rollback=true after verifying the test result.") result = rollback_saved_state_prepare_receipt(base_id_or_error, receipt_id, timeout_seconds=int(payload.get("timeout_seconds") or 60)) return {"schema": "onec_scd_prepare_rollback.v1", "method": method, "base_id": base_id_or_error, "prepare_receipt_id": receipt_id, **result} def scd_patch_prepare_and_retry( payload: dict[str, Any], *, base_id: str, extension: str, report: str, timeout_seconds: int, inspect: dict[str, Any], ) -> dict[str, Any] | None: """Prepare a missing saved SCD only inside an explicitly gated apply route.""" execution_mode = str(payload.get("execution_mode") or "plan").strip().casefold() if execution_mode not in {"apply", "apply_and_verify", "apply_and_rollback"}: return None if payload.get("allow_saved_state_write") is not True: return None if payload.get("_scd_auto_prepare_attempted") is True: return None allow_prepare, prepare_gate_error = strict_bool_argument( payload, "allow_sql_saved_state_prepare", method="scd.patch", default=False ) if prepare_gate_error: return prepare_gate_error if not allow_prepare: return None prepared = scd_prepare( { "base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "apply", "allow_sql_saved_state_prepare": True, "timeout_seconds": timeout_seconds, } ) if prepared.get("status") not in {"applied", "ok", "unchanged"}: return { "schema": "onec_scd_patch.v1", "method": "scd.patch", "status": prepared.get("status") or "error", "base_id": base_id, "extension": extension or None, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), "error": "scd_saved_state_prepare_failed", "prepare_result": prepared, } receipt_id = str(prepared.get("prepare_receipt_id") or "").strip().lower() retry_payload = dict(payload) retry_payload["_scd_auto_prepare_attempted"] = True if re.fullmatch(r"[0-9a-f]{32}", receipt_id): retry_payload["_scd_prepare_receipt_id"] = receipt_id retried = scd_patch(retry_payload) # A failed retry must not leave a first-write overlay behind. This cleanup # deletes only rows named in this call's receipt and still checks their # hashes, so it cannot remove a concurrent or subsequently changed save. if re.fullmatch(r"[0-9a-f]{32}", receipt_id) and retried.get("applied") is not True: retried["prepare_cleanup"] = rollback_saved_state_prepare_receipt( base_id, receipt_id, timeout_seconds=timeout_seconds ) retried["prepare_result"] = prepared if receipt_id: retried["prepare_receipt_id"] = receipt_id return retried def scd_patch(payload: dict[str, Any]) -> dict[str, Any]: """Create a byte-preserving SCD patch proposal in the appropriate SQL saved state.""" method = "scd.patch" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension = str(payload.get("extension") or "").strip() report = str(payload.get("report") or payload.get("name") or "").strip() section = str(payload.get("section") or "").strip() item_name = str(payload.get("item_name") or payload.get("name_in_schema") or "").strip() property_name = str(payload.get("property") or "").strip() value = payload.get("value") if not report or not section or not item_name or not property_name or not isinstance(value, str): return invalid_argument(method, "payload", "report, section, item_name, property, and string value are required; extension is optional for a base report.") timeout_seconds = int(payload.get("timeout_seconds") or 60) execution_mode = str(payload.get("execution_mode") or "plan").strip().casefold() if execution_mode not in {"plan", "apply", "apply_and_verify", "apply_and_rollback"}: return invalid_argument(method, "execution_mode", "execution_mode must be plan, apply, apply_and_verify, or apply_and_rollback.") allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) if allow_write_error: return allow_write_error inspect = scd_inspect({ "base_id": base_id, "extension": extension, "report": report, "schema": payload.get("schema"), "timeout_seconds": timeout_seconds, }) if inspect.get("status") != "ok": return {"schema": "onec_scd_patch.v1", "method": method, "status": inspect.get("status"), "base_id": base_id, "inspect": inspect} schema_guid = str((inspect.get("scd") or {}).get("guid") or "") saved_table = "ConfigCASSave" if extension else "ConfigSave" file_name = "" data: bytes | None = None read_error: dict[str, Any] | None = None if extension: saved = extension_objects_find({ "base_id": base_id, "extension": extension, "guid": schema_guid, "state": "save", "limit": 2, "include_storage": True, "timeout_seconds": timeout_seconds, }) saved_objects = saved.get("objects") if isinstance(saved, dict) and isinstance(saved.get("objects"), list) else [] if len(saved_objects) == 1: entries = saved_objects[0].get("manifest_entries") if isinstance(saved_objects[0], dict) and isinstance(saved_objects[0].get("manifest_entries"), list) else [] entry = next((item for item in entries if isinstance(item, dict) and str(item.get("suffix") or "") == ".0" and str(item.get("cas_key") or "").strip()), None) if entry: file_name = str(entry.get("cas_key")) else: file_name = str(((inspect.get("scd") or {}).get("payload_route") or {}).get("file_name") or "").strip() if not file_name: retried = scd_patch_prepare_and_retry( payload, base_id=base_id, extension=extension, report=report, timeout_seconds=timeout_seconds, inspect=inspect, ) if retried is not None: return retried prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} prepare_plan = scd_prepare(prepare_payload) return { "schema": "onec_scd_patch.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), "diagnostics": {"code": "SCD_SAVED_STATE_SCHEMA_NOT_FOUND", "message": "No unique saved SCD payload exists. The prepare plan distinguishes an empty target from a partial saved-state copy."}, "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}, } data, _, read_error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) if read_error or data is None: retried = scd_patch_prepare_and_retry( payload, base_id=base_id, extension=extension, report=report, timeout_seconds=timeout_seconds, inspect=inspect, ) if retried is not None: return retried prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} prepare_plan = scd_prepare(prepare_payload) return {"schema": "onec_scd_patch.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension or None, "report": report, "error": "scd_saved_payload_not_found", "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} from parser.scd_payload import plan_scd_scalar_patch plan = plan_scd_scalar_patch(data, section=section, name=item_name, property_name=property_name, value=value) post_patch_analysis: dict[str, Any] = {} if plan.get("status") == "planned" and section == "datasets" and property_name == "query": from parser.scd_payload import inspect_scd_payload post_patch = inspect_scd_payload(plan["payload"]) post_patch_analysis = dict(post_patch.get("analysis") or {}) missing_parameters = post_patch_analysis.get("referenced_not_declared_in_schema") or [] if missing_parameters: return { "schema": "onec_scd_patch.v1", "method": method, "status": "blocked", "base_id": base_id, "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), "edit": {"section": section, "item_name": item_name, "property": property_name, "old": plan.get("old"), "new": value}, "diagnostics": {"code": "SCD_QUERY_PARAMETER_NOT_DECLARED", "message": "The proposed query references parameters not declared by this SCD.", "parameters": missing_parameters}, } result: dict[str, Any] = { "schema": "onec_scd_patch.v1", "method": method, "status": "blocked" if not allow_write else plan.get("status"), "base_id": base_id, "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), "write_mode": {"requested": "saved_state", "target_table": saved_table, "sql_write_performed": False, "requires_apply_gate": True}, "edit": {"section": section, "item_name": item_name, "property": property_name, "old": plan.get("old"), "new": value}, **({"post_patch_static_analysis": {"query_parameters": post_patch_analysis.get("query_parameter_references") or []}} if post_patch_analysis else {}), } if not allow_write: result["diagnostics"] = {"code": "SCD_WRITE_PLAN_OPT_IN", "message": "No proposal was created. Pass allow_saved_state_write=true to build a reviewed proposal; this still does not write SQL."} return result if plan.get("status") != "planned": result["diagnostics"] = {key: plan.get(key) for key in ("code", "message", "container") if plan.get(key) is not None} return result encoded: dict[str, Any] = {"sha1": plan["result_sha1"], "bytes": len(plan["payload"])} if payload.get("include_payload") is True or execution_mode in {"apply", "apply_and_verify", "apply_and_rollback"}: encoded["payload_hex"] = plan["payload"].hex() result["proposal"] = { "schema": "onec_change_proposal.v1", "method": method, "status": "accepted_for_review", "source": {"base_id": base_id, "table": saved_table, "file_name": file_name}, "original": {"sha1": plan["expected_sha1"], "bytes": len(data)}, "encoded": encoded, "edits": [{"kind": "scd_scalar", "section": section, "item_name": item_name, "property": property_name, "old": plan["old"], "new": value, "mode": "xml_scalar_byte_preserving"}], } if execution_mode == "plan": return result allow_apply, apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if apply_error: return apply_error if not allow_apply: return invalid_argument(method, "allow_sql_saved_state_apply", "Apply is opt-in; pass allow_sql_saved_state_apply=true after reviewing the proposal.") if execution_mode == "apply_and_rollback": allow_rollback, rollback_gate_error = strict_bool_argument( payload, "allow_sql_saved_state_rollback", method=method, default=False ) if rollback_gate_error: return rollback_gate_error if not allow_rollback: return invalid_argument( method, "allow_sql_saved_state_rollback", "apply_and_rollback is opt-in; pass allow_sql_saved_state_rollback=true.", ) apply_result = storage_saved_state_apply_proposal({ **repository_write_context(payload), "base_id": base_id, "ref": f"Report.{report}", "kind": "Report", "name": report, "allow_sql_saved_state_apply": True, "proposal": result["proposal"], "timeout_seconds": timeout_seconds, }) result["apply_result"] = apply_result result["applied"] = bool(apply_result.get("applied")) result["write_mode"]["sql_write_performed"] = result["applied"] result["status"] = apply_result.get("status") or "error" semantic_ok: bool | None = None if result["applied"]: readback, _, readback_error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) if not readback_error and readback: from parser.scd_payload import inspect_scd_payload decoded_readback = inspect_scd_payload(readback) found = next((item for item in (decoded_readback.get("sections") or {}).get(section, []) if item.get("name") == item_name), {}) actual = found.get(property_name) result["semantic_verification"] = {"status": "ok" if actual == value else "mismatch", "section": section, "item_name": item_name, "property": property_name, "expected": value, "actual": actual} semantic_ok = actual == value if execution_mode == "apply_and_verify" and semantic_ok: result["status"] = "verified" if result["applied"] and execution_mode == "apply_and_rollback": if apply_result.get("schema") == "onec_extension_saved_state_pair_apply.v1": rollback_result = rollback_extension_saved_state_pair( base_id, apply_result, timeout_seconds=timeout_seconds ) else: backup = apply_result.get("backup") if isinstance(apply_result.get("backup"), dict) else {} backup_id = str(backup.get("backup_id") or "").strip() rollback_result = ( storage_saved_state_rollback( { **repository_write_context(payload), "base_id": base_id, "allow_sql_saved_state_rollback": True, "backup_id": backup_id, "timeout_seconds": timeout_seconds, } ) if backup_id else {"status": "rollback_unavailable", "applied": False} ) result["rollback_result"] = rollback_result result["rolled_back"] = bool(rollback_result.get("applied")) prepare_receipt_id = str(payload.get("_scd_prepare_receipt_id") or "").strip().lower() if result["rolled_back"] and re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): prepare_cleanup = rollback_saved_state_prepare_receipt( base_id, prepare_receipt_id, timeout_seconds=timeout_seconds ) result["prepare_cleanup"] = prepare_cleanup result["rolled_back"] = bool(prepare_cleanup.get("applied")) result["status"] = ( "verified_and_rolled_back" if semantic_ok is True and result["rolled_back"] else "applied_rollback_failed" ) return result def scd_compare(payload: dict[str, Any]) -> dict[str, Any]: """Compare active and saved SCD payload semantics, read-only.""" method = "scd.compare" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extension, report = str(payload.get("extension") or "").strip(), str(payload.get("report") or payload.get("name") or "").strip() if not report: return invalid_argument(method, "payload", "report is required; extension is optional for a base report.") timeout_seconds = int(payload.get("timeout_seconds") or 60) active = scd_inspect({"base_id": base_id, "extension": extension, "report": report, "schema": payload.get("schema"), "timeout_seconds": timeout_seconds}) if active.get("status") != "ok": return {"schema": "onec_scd_compare.v1", "method": method, "status": active.get("status"), "base_id": base_id, "active": active} schema_guid = str((active.get("scd") or {}).get("guid") or "") saved_table = "ConfigCASSave" if extension else "ConfigSave" file_name = "" if extension: saved = extension_objects_find({"base_id": base_id, "extension": extension, "guid": schema_guid, "state": "save", "limit": 2, "include_storage": True, "timeout_seconds": timeout_seconds}) objects = saved.get("objects") if isinstance(saved, dict) and isinstance(saved.get("objects"), list) else [] if len(objects) == 1: entries = objects[0].get("manifest_entries") if isinstance(objects[0], dict) and isinstance(objects[0].get("manifest_entries"), list) else [] entry = next((item for item in entries if isinstance(item, dict) and str(item.get("suffix") or "") == ".0" and item.get("cas_key")), None) if entry: file_name = str(entry["cas_key"]) else: file_name = str(((active.get("scd") or {}).get("payload_route") or {}).get("file_name") or "").strip() if not file_name: prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} prepare_plan = scd_prepare(prepare_payload) return {"schema": "onec_scd_compare.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension, "report": report, "active": {"schema": active.get("scd"), "container": (active.get("scd") or {}).get("container")}, "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} data, _, error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) if error or data is None: prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} prepare_plan = scd_prepare(prepare_payload) return {"schema": "onec_scd_compare.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension or None, "report": report, "active": {"schema": active.get("scd"), "container": (active.get("scd") or {}).get("container")}, "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} from parser.scd_payload import compare_scd_semantics, inspect_scd_payload saved_decoded = inspect_scd_payload(data) if saved_decoded.get("status") != "ok": return {"schema": "onec_scd_compare.v1", "method": method, "status": "partial", "base_id": base_id, "error": "scd_saved_payload_undecodable"} diff = compare_scd_semantics({"sections": active.get("sections") or {}}, saved_decoded) return {"schema": "onec_scd_compare.v1", "method": method, "status": diff.get("status"), "base_id": base_id, "extension": extension, "report": report, "schema_name": (active.get("scd") or {}).get("name"), "comparison": diff, "activation_state": "working_not_runtime_applied"} def form_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: """Find one visual/form field without widening into a global code search.""" method = "form.field.lineage" execution_mode = str(payload.get("execution_mode") or "").strip().casefold() if execution_mode: if execution_mode != "job": return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) job_payload = dict(payload) job_payload.pop("execution_mode", None) return adapter_start_job({ "method": method, "payload": job_payload, **({"timeout_seconds": payload.get("timeout_seconds")} if payload.get("timeout_seconds") not in {None, ""} else {}), }) field = str(payload.get("field") or "").strip() if not field: return invalid_argument(method, "field", "field is required.") match_mode = str(payload.get("match_mode") or "contains").strip().lower() if match_mode not in {"contains", "exact", "semantic"}: return invalid_argument(method, "match_mode", "match_mode must be contains, exact, or semantic.", allowed_values=["contains", "exact", "semantic"]) analysis_profile = str(payload.get("analysis_profile") or "full").strip().casefold() if analysis_profile not in {"full", "fast"}: return invalid_argument(method, "analysis_profile", "analysis_profile must be full or fast.", allowed_values=["full", "fast"]) include_calculation, include_calculation_error = strict_bool_argument( payload, "include_calculation", method=method, default=analysis_profile != "fast", ) if include_calculation_error: return include_calculation_error include_binding_inference, include_binding_inference_error = strict_bool_argument( payload, "include_binding_inference", method=method, default=True, ) if include_binding_inference_error: return include_binding_inference_error include_related, include_related_error = strict_bool_argument(payload, "include_related", method=method, default=False) if include_related_error: return include_related_error include_call_chain, include_call_chain_error = strict_bool_argument(payload, "include_call_chain", method=method, default=analysis_profile != "fast") if include_call_chain_error: return include_call_chain_error call_chain_depth, call_chain_depth_error = parse_int_argument( payload, "call_chain_depth", method=method, default=2, minimum=0, maximum=3, ) if call_chain_depth_error: return call_chain_depth_error call_timeout, call_timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=30, ) if call_timeout_error: return call_timeout_error form = str(payload.get("form") or "").strip() form_title = str(payload.get("form_title") or "").strip() details = metadata_object_form_details({ # Field lineage uses decoded semantic fields, not raw per-element # parameter arrays, which can be very large on managed forms. **payload, **({"form": form} if form else {}), "include_parameters": False, "enrich_references": False, "include_module_text": False, "max_forms": 20, "max_items": 2000, }) if details.get("status") != "ok": return {"schema": "onec_form_field_lineage.v1", "method": method, "status": details.get("status"), "base_id": payload.get("base_id"), "form_details": details} needle = normalize(field) matches: list[dict[str, Any]] = [] matched_forms: set[str] = set() handler_candidates: dict[str, list[str]] = {} title_confirmed_forms: set[str] = set() title_unavailable_forms: set[str] = set() def compact_candidate(candidate: dict[str, Any]) -> dict[str, Any]: semantic = candidate.get("semantic") if isinstance(candidate.get("semantic"), dict) else {} result = { key: candidate.get(key) for key in ( "name", "id", "title", "synonym", "type_name", "data_name", "dynamic_list", "expression", "query_text", "query_sources", "query_context", "source_references", "query_lineage", "origin_graph", "dynamic_list_binding", "handler", "action", "binding", ) if candidate.get(key) not in (None, "") } | ({"coverage": semantic.get("coverage")} if semantic.get("coverage") else {}) # Form payload decoder exposes the public logical binding as # `path_to_data`; normalize it for the lineage API. It is a 1C path, # not an internal Config/SQL route. data_path = str(candidate.get("data_path") or candidate.get("path_to_data") or "").strip() if data_path: result["data_path"] = data_path return result for item in details.get("forms") or []: if not isinstance(item, dict): continue form_name = item.get("name") # Some managed forms do not expose a decoded title. Keep the search # object-scoped in that case instead of incorrectly reporting absence. decoded_title_identity = normalize(" ".join(str(item.get(key) or "") for key in ("title", "synonym"))) title_match_status = "not_requested" if form_title and decoded_title_identity and normalize(form_title) not in decoded_title_identity: continue if form_title: if decoded_title_identity: title_match_status = "confirmed" else: title_match_status = "unavailable" candidate_sections: list[tuple[str, list[dict[str, Any]]]] = [] for section in ("elements", "attributes", "commands", "dynamic_lists"): candidate_sections.append((section, [candidate for candidate in (item.get(section) or []) if isinstance(candidate, dict)])) dynamic_list_fields: list[dict[str, Any]] = [] for attribute in item.get("attributes") or []: if not isinstance(attribute, dict): continue list_name = str(attribute.get("name") or "").strip() settings = attribute.get("dynamic_list_settings") if isinstance(attribute.get("dynamic_list_settings"), dict) else {} query_text = str(settings.get("query_text") or "").strip() for dynamic_field in attribute.get("dynamic_list_fields") or []: if not isinstance(dynamic_field, dict): continue candidate = dict(dynamic_field) candidate.setdefault("name", candidate.get("data_name")) candidate["dynamic_list"] = list_name if query_text: candidate["query_text"] = query_text try: from parser.scd_payload import QUERY_SOURCE_BINDING_RE source_bindings: dict[str, str] = {} source_aliases: dict[str, str] = {} for found in QUERY_SOURCE_BINDING_RE.finditer(query_text): source = str(found.group(1)).strip() alias = str(found.group(2) or source).strip() source_bindings[normalize(alias)] = source source_aliases[normalize(alias)] = alias # QUERY_SOURCE_BINDING_RE intentionally stays shared # and conservative. Dynamic-list lineage additionally # accepts the parameter list of a virtual table, e.g. # ``InformationRegister.Prices.SliceLast(&Date) AS P``. virtual_source_arguments: dict[str, str] = {} source_with_arguments = re.compile( r"(?is)\b(?:ИЗ|СОЕДИНЕНИЕ)\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"\s*\((?P[^()]*)\)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" ) for source_match in source_with_arguments.finditer(query_text): source = str(source_match.group("source") or "").strip() alias = str(source_match.group("alias") or "").strip() if not source or not alias: continue source_bindings[normalize(alias)] = source source_aliases[normalize(alias)] = alias virtual_source_arguments[normalize(alias)] = str(source_match.group("arguments") or "").strip() temporary_field_bindings: dict[tuple[str, str], tuple[str, str]] = {} temporary_tables: dict[str, list[dict[str, str]]] = {} temporary_rule = re.compile( r"(?is)\bВЫБРАТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\." r"[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПОМЕСТИТЬ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ИЗ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" ) for temporary_match in temporary_rule.finditer(query_text): expression_alias, expression_field = str(temporary_match.group("expression")).split(".", 1) source_alias = str(temporary_match.group("source_alias") or "") # Do not infer a source if projection points to a # different alias than the declared source. if normalize(expression_alias) != normalize(source_alias): continue temporary_name = str(temporary_match.group("temporary") or "") output_field = str(temporary_match.group("output") or "") source = str(temporary_match.group("source") or "") temporary_field_bindings[(normalize(temporary_name), normalize(output_field))] = (source, expression_field) temporary_tables.setdefault(normalize(temporary_name), []).append({ "field": output_field, "source": source, "source_field": expression_field, }) temporary_source_aliases: dict[str, str] = {} for temporary_source in re.finditer( r"(?is)\bИЗ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", query_text, ): temporary_name = str(temporary_source.group("temporary") or "") if normalize(temporary_name) in temporary_tables: temporary_source_aliases[normalize(str(temporary_source.group("alias") or ""))] = temporary_name # Resolve the intentionally narrow, common case of a # single derived table: ``FROM (SELECT A.X AS X FROM # Catalog.A AS A) AS T``. It is not a general 1C # query parser: nested derived tables, joins and # expressions remain explicitly partial below. derived_field_bindings: dict[tuple[str, str], tuple[str, str]] = {} derived_rule = re.compile( r"(?is)\bИЗ\s*\(\s*\bВЫБРАТЬ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\." r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+" r"\bИЗ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?\s*\)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" ) for derived in derived_rule.finditer(query_text): projection_alias = str(derived.group("projection") or "").strip() source_alias = str(derived.group("source_alias") or "").strip() # A mismatch cannot be proven from this compact # grammar, therefore do not infer it. if source_alias and normalize(projection_alias) != normalize(source_alias): continue derived_field_bindings[( normalize(str(derived.group("derived_alias") or "")), normalize(str(derived.group("output") or "")), )] = (str(derived.group("source") or ""), str(derived.group("field") or "")) candidate["query_sources"] = [ {"object_ref": source, "alias": source_aliases[alias]} for alias, source in source_bindings.items() ] # Keep query constraints next to field provenance: a # price/source can be correct only under the dynamic # list's parameter, filter and ordering context. We # intentionally return text clauses rather than # executing or normalising 1C query semantics. query_context: dict[str, Any] = {} parameters = sorted(set(re.findall(r"&([A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", query_text)), key=normalize) if parameters: query_context["parameters"] = parameters parameter_bindings: list[dict[str, Any]] = [] for parameter in parameters: candidates: list[dict[str, Any]] = [] for attribute in item.get("attributes") or []: if not isinstance(attribute, dict): continue attribute_name = str(attribute.get("name") or "").strip() attribute_path = str(attribute.get("data_path") or attribute.get("path_to_data") or "").strip() if normalize(attribute_name) == normalize(parameter) or normalize(attribute_path.rsplit(".", 1)[-1]) == normalize(parameter): candidates.append({ "kind": "form_attribute", "name": attribute_name, **({"data_path": attribute_path} if attribute_path else {}), }) for element in item.get("elements") or []: if not isinstance(element, dict): continue element_name = str(element.get("name") or "").strip() element_path = str(element.get("data_path") or element.get("path_to_data") or "").strip() if normalize(element_name) == normalize(parameter) or normalize(element_path.rsplit(".", 1)[-1]) == normalize(parameter): candidates.append({ "kind": "form_element", "name": element_name, **({"data_path": element_path} if element_path else {}), }) if candidates: parameter_bindings.append({ "parameter": parameter, "status": "inferred", "source": "decoded_name_or_data_path", "candidates": candidates, }) if parameter_bindings: query_context["parameter_bindings"] = parameter_bindings for clause_name, start_pattern, end_pattern in ( ("filter", r"\bГДЕ\b", r"\b(?:СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), ("group_by", r"\bСГРУППИРОВАТЬ\s+ПО\b", r"\b(?:УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), ("order_by", r"\bУПОРЯДОЧИТЬ\s+ПО\b", r"\b(?:ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), ): clause_match = re.search( rf"(?is){start_pattern}\s*(.*?)(?={end_pattern}|\Z)", query_text, ) if clause_match and clause_match.group(1).strip(): query_context[clause_name] = clause_match.group(1).strip() joins: list[dict[str, Any]] = [] join_pattern = re.compile( r"(?is)\b(?P(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)\s+)?" r"СОЕДИНЕНИЕ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"(?:\s*\([^()]*\))?(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?\s+ПО\s+" r"(?P.*?)(?=\b(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)?\s*СОЕДИНЕНИЕ\b|\b(?:ГДЕ|СГРУППИРОВАТЬ|УПОРЯДОЧИТЬ|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)" ) for join in join_pattern.finditer(query_text): joins.append({ "kind": " ".join(str(join.group("join_kind") or "inner").split()).casefold(), "source": str(join.group("source") or "").strip(), **({"alias": str(join.group("alias")).strip()} if join.group("alias") else {}), "condition": str(join.group("condition") or "").strip(), }) if joins: query_context["joins"] = joins virtual_sources_by_source: dict[str, dict[str, Any]] = {} virtual_names = {"срезпоследних", "остатки", "обороты", "остаткииобороты"} for alias, source in source_bindings.items(): parts = source.split(".") if len(parts) < 3 or normalize(parts[-1]) not in virtual_names: continue if normalize(parts[0]) not in { "регистрсведений", "регистрнакопления", "informationregister", "accumulationregister", }: continue virtual_sources_by_source[normalize(source)] = { "alias": source_aliases.get(alias, alias), "source": source, "register": ".".join(parts[:-1]), "virtual_table": parts[-1], **({"arguments": virtual_source_arguments[alias]} if virtual_source_arguments.get(alias) else {}), } virtual_sources = list(virtual_sources_by_source.values()) if virtual_sources: query_context["virtual_sources"] = virtual_sources if temporary_tables: query_context["temporary_tables"] = [ {"name": next((name for name in temporary_source_aliases.values() if normalize(name) == temporary_name), temporary_name), "fields": fields} for temporary_name, fields in temporary_tables.items() ] if query_context: query_context["status"] = "static_query_context" candidate["query_context"] = query_context output_name = str(candidate.get("name") or candidate.get("data_name") or "").strip() output_query_text = query_text.rsplit(";", 1)[-1] if temporary_tables and ";" in query_text else query_text output_rule = re.compile( r"(?is)(?:\bВЫБРАТЬ\b|,)\s*(?P.*?)\s+КАК\s+" + re.escape(output_name) + r"(?=\s*,|\s+ИЗ\b|$)" ).search(output_query_text) if output_name else None if output_rule: expression = output_rule.group("expression").strip() candidate["expression"] = expression references = [] seen_references: set[tuple[str, str]] = set() for reference in re.finditer(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", expression): alias = reference.group("alias") field_name = reference.group("field") reference_key = (normalize(alias), normalize(field_name)) if reference_key in seen_references: continue seen_references.add(reference_key) source = source_bindings.get(normalize(alias)) item_reference: dict[str, Any] = { "alias": alias, "field": field_name, } if source: item_reference.update({"source": source, "public_path": f"{source}.{field_name}"}) else: derived_source = derived_field_bindings.get((normalize(alias), normalize(field_name))) if derived_source and derived_source[0] and derived_source[1]: item_reference.update({ "source": derived_source[0], "public_path": f"{derived_source[0]}.{derived_source[1]}", "derived_from": {"alias": alias, "field": field_name}, "resolution": "derived_table_projection", }) else: temporary_name = temporary_source_aliases.get(normalize(alias)) temporary_source = temporary_field_bindings.get((normalize(temporary_name), normalize(field_name))) if temporary_name else None if temporary_source and temporary_source[0] and temporary_source[1]: item_reference.update({ "source": temporary_source[0], "public_path": f"{temporary_source[0]}.{temporary_source[1]}", "derived_from": {"temporary_table": temporary_name, "field": field_name}, "resolution": "temporary_table_projection", }) references.append(item_reference) if references: candidate["source_references"] = references unresolved_aliases = sorted({ str(reference.get("alias") or "") for reference in references if not reference.get("source") } - {""}) complex_constructs: list[str] = [] # This is deliberately a small, transparent # parser. It can prove simple ``source.field AS # output`` bindings, but must not claim that a # derived table, union, package query or virtual # table has been followed to its physical source. for token, code in ( (r"\bОБЪЕДИНИТЬ\b|\bUNION\b", "union"), (r"\bПОМЕСТИТЬ\b|\bINTO\b", "temporary_table"), (r"\bВЫБРАТЬ\b|\bSELECT\b", "nested_query"), (r"\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\s*\(", "virtual_or_function_source"), ): # One SELECT is the outer query itself; two # or more indicate a nested data source. if code == "nested_query": if len(re.findall(token, query_text, flags=re.IGNORECASE)) > 1: complex_constructs.append(code) elif re.search(token, query_text, flags=re.IGNORECASE): complex_constructs.append(code) diagnostic_codes: list[str] = [] if unresolved_aliases: diagnostic_codes.append("source_alias_unresolved") if complex_constructs: diagnostic_codes.append("complex_query_not_fully_traced") if not references: diagnostic_codes.append("expression_has_no_direct_source_reference") direct_reference = bool(re.fullmatch( r"\s*[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\." r"[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\s*", expression, )) if not direct_reference: # Formula inputs are statically identified, # but the SQL-only adapter neither evaluates # 1C query expressions nor proves the value # for a particular row. diagnostic_codes.append("calculated_expression") union_branches: list[dict[str, Any]] = [] union_parts = re.split(r"(?is)\b(?:ОБЪЕДИНИТЬ(?:\s+ВСЕ)?|UNION(?:\s+ALL)?)\b", query_text) if len(union_parts) > 1 and output_name: branch_rule = re.compile( r"(?is)\bВЫБРАТЬ\s+(?P.*?)\s+КАК\s+" + re.escape(output_name) + r"(?=\s*,|\s+ИЗ\b)\s+ИЗ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"(?:\s*(?:\([^()]*\))?)\s+(?:КАК\s+)?(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" ) for branch_number, branch_text in enumerate(union_parts, start=1): branch_match = branch_rule.search(branch_text) if not branch_match: union_branches.append({ "branch": branch_number, "status": "partial", "diagnostics": ["union_branch_output_not_parsed"], }) continue branch_expression = str(branch_match.group("expression") or "").strip() branch_source = str(branch_match.group("source") or "").strip() branch_alias = str(branch_match.group("alias") or "").strip() branch_references = [] for branch_ref in re.finditer(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", branch_expression): if normalize(branch_ref.group("alias")) != normalize(branch_alias): continue field_name = branch_ref.group("field") branch_references.append({ "alias": branch_alias, "field": field_name, "source": branch_source, "public_path": f"{branch_source}.{field_name}", }) union_branches.append({ "branch": branch_number, "status": "confirmed" if branch_references else "partial", "expression": branch_expression, "source_references": branch_references, **({"diagnostics": ["union_branch_has_no_direct_source_reference"]} if not branch_references else {}), }) candidate["query_lineage"] = { "status": "confirmed" if not diagnostic_codes else "partial", "parser": "simple_select_alias_v1", "output_field": output_name, "expression": expression, "binding_kind": "direct_field" if direct_reference else "calculated_expression", **({"union_branches": union_branches} if union_branches else {}), **({"unresolved_aliases": unresolved_aliases} if unresolved_aliases else {}), **({"complex_constructs": complex_constructs} if complex_constructs else {}), **({"diagnostics": diagnostic_codes} if diagnostic_codes else {}), } output_id = f"query_output:{candidate.get('dynamic_list') or 'dynamic_list'}.{output_name}" field_id = f"form_field:{candidate.get('data_path') or candidate.get('path_to_data') or output_name}" graph_nodes: list[dict[str, Any]] = [ {"id": field_id, "kind": "form_dynamic_list_field", "name": output_name, **({"data_path": candidate.get("data_path") or candidate.get("path_to_data")} if candidate.get("data_path") or candidate.get("path_to_data") else {})}, {"id": output_id, "kind": "query_output", "name": output_name, "expression": expression}, ] graph_edges: list[dict[str, Any]] = [{ "from": field_id, "to": output_id, "kind": "dynamic_list_column_binding", }] graph_references = list(references) for branch in union_branches: if isinstance(branch, dict): graph_references.extend(item for item in (branch.get("source_references") or []) if isinstance(item, dict)) seen_graph_sources: set[str] = set() for source_reference in graph_references: public_path = str(source_reference.get("public_path") or "").strip() if not public_path or public_path in seen_graph_sources: continue seen_graph_sources.add(public_path) source_id = f"source:{public_path}" graph_nodes.append({ "id": source_id, "kind": "metadata_field", "public_path": public_path, "source": source_reference.get("source"), "field": source_reference.get("field"), }) graph_edges.append({ "from": output_id, "to": source_id, "kind": "query_source_reference", **({"resolution": source_reference.get("resolution")} if source_reference.get("resolution") else {}), }) candidate["origin_graph"] = { "status": candidate["query_lineage"]["status"], "nodes": graph_nodes, "edges": graph_edges, } else: candidate["query_lineage"] = { "status": "partial", "parser": "simple_select_alias_v1", "output_field": output_name, "diagnostics": ["output_field_expression_not_parsed"], } except Exception: # Form lineage remains available even for a malformed # query text or an unavailable optional parser import. candidate["expression"] = query_text candidate["query_lineage"] = { "status": "partial", "parser": "simple_select_alias_v1", "diagnostics": ["query_parse_failed"], } dynamic_list_fields.append(candidate) if dynamic_list_fields: candidate_sections.append(("dynamic_list_fields", dynamic_list_fields)) # A managed-form control often has a human-facing name different # from the dynamic-list output alias (for example # ``Себестоимость`` -> ``Список.FoodCost``). The decoded data # path is a direct static binding, so preserve it instead of # relying on a fragile name comparison. fields_by_path: dict[str, dict[str, Any]] = {} for dynamic_field in dynamic_list_fields: list_name = str(dynamic_field.get("dynamic_list") or "").strip() field_name = str(dynamic_field.get("name") or dynamic_field.get("data_name") or "").strip() field_path = str(dynamic_field.get("data_path") or dynamic_field.get("path_to_data") or "").strip() for path in (field_path, f"{list_name}.{field_name}" if list_name and field_name else ""): if path: fields_by_path[normalize(path)] = dynamic_field for element in item.get("elements") or []: if not isinstance(element, dict): continue element_path = str(element.get("data_path") or element.get("path_to_data") or "").strip() dynamic_field = fields_by_path.get(normalize(element_path)) if element_path else None if not dynamic_field: continue dynamic_graph = dynamic_field.get("origin_graph") if isinstance(dynamic_field.get("origin_graph"), dict) else None visual_origin_graph: dict[str, Any] | None = None if dynamic_graph: graph_nodes = [dict(node) for node in (dynamic_graph.get("nodes") or []) if isinstance(node, dict)] graph_edges = [dict(edge) for edge in (dynamic_graph.get("edges") or []) if isinstance(edge, dict)] dynamic_field_node = next((node for node in graph_nodes if node.get("kind") == "form_dynamic_list_field"), None) if dynamic_field_node and dynamic_field_node.get("id"): visual_node_id = f"form_element:{element.get('id') or element.get('name') or element_path}" graph_nodes.insert(0, { "id": visual_node_id, "kind": "form_element", "name": element.get("name"), "data_path": element_path, }) graph_edges.insert(0, { "from": visual_node_id, "to": dynamic_field_node["id"], "kind": "visual_element_dynamic_list_binding", "evidence": "decoded_form_data_path", }) visual_origin_graph = {"status": dynamic_graph.get("status"), "nodes": graph_nodes, "edges": graph_edges} element["dynamic_list_binding"] = { "status": "confirmed", "source": "decoded_form_data_path", "data_path": element_path, "dynamic_list": dynamic_field.get("dynamic_list"), "field": dynamic_field.get("name") or dynamic_field.get("data_name"), **({"query_lineage": dynamic_field["query_lineage"]} if isinstance(dynamic_field.get("query_lineage"), dict) else {}), **({"origin_graph": visual_origin_graph} if visual_origin_graph else {}), } for section, candidates in candidate_sections: for candidate in candidates: if not include_related and str(candidate.get("type_name") or "").casefold() in { "контекстное меню", "расширенная подсказка", }: continue identity_values = [str(candidate.get(key) or "") for key in ( "name", "title", "synonym", "data_name", "data_path", "path_to_data", )] exact_match = any(normalize(value) == needle for value in identity_values if value) haystack = " ".join(identity_values + [str(candidate.get(key) or "") for key in ("expression", "query_text", "handler")]) type_name = str(candidate.get("type_name") or "").casefold() semantic_match = ( needle in normalize(" ".join(identity_values)) and section not in {"commands"} and "кнопка" not in type_name and "командн" not in type_name ) matched = exact_match if match_mode == "exact" else semantic_match if match_mode == "semantic" else needle in normalize(haystack) if matched: if title_match_status == "confirmed": title_confirmed_forms.add(str(form_name or "")) elif title_match_status == "unavailable": title_unavailable_forms.add(str(form_name or "")) matched_forms.add(form_name) compact = compact_candidate(candidate) matches.append({ "form": form_name, "section": section, "item": compact, "match_by": "exact" if exact_match else "semantic" if semantic_match and match_mode == "semantic" else "contains", "evidence": "decoded_dynamic_list_field" if section == "dynamic_list_fields" else "decoded_form_payload", **({"form_title_match": title_match_status} if form_title else {}), }) if section == "commands": handler = str(candidate.get("handler") or candidate.get("action") or "").strip() if handler: handler_candidates.setdefault(form_name, []).append(handler) binding_inference_count = 0 if bool(include_binding_inference) and matches: needs_name_inference = any( match.get("section") == "elements" and isinstance(match.get("item"), dict) and not str(match["item"].get("data_path") or "").strip() for match in matches ) object_fields = metadata_object_attributes({**payload, "only": "all", "include_storage": False}) if needs_name_inference else {} tabular_sections = object_fields.get("tabular_sections") if isinstance(object_fields.get("tabular_sections"), list) else [] object_attributes = object_fields.get("attributes") if isinstance(object_fields.get("attributes"), list) else [] for match in matches: if match.get("section") != "elements" or not isinstance(match.get("item"), dict): continue item = match["item"] element_name = str(item.get("name") or "") data_path = str(item.get("data_path") or "").strip() if data_path: if data_path.casefold().startswith("объект."): item["binding"] = { "status": "confirmed", "object_member_ref": data_path, "source": "decoded_form_data_path", } binding_inference_count += 1 continue if not element_name: continue for tabular_section in tabular_sections: if not isinstance(tabular_section, dict): continue section_name = str(tabular_section.get("name") or "") if not section_name or not normalize(element_name).startswith(normalize(section_name)): continue column_name = element_name[len(section_name):] column = next((value for value in (tabular_section.get("columns") or []) if isinstance(value, dict) and normalize(str(value.get("name") or "")) == normalize(column_name)), None) if column: item["binding"] = { "status": "inferred_confirmed", "object_member_ref": f"Объект.{section_name}.{column.get('name')}", "member": { "name": column.get("name"), "synonym": column.get("synonym"), "type": column.get("type"), }, "source": "element_name + decoded_tabular_section", } binding_inference_count += 1 break if item.get("binding"): continue object_attribute = next((value for value in object_attributes if isinstance(value, dict) and normalize(str(value.get("name") or "")) == normalize(element_name)), None) if object_attribute: item["binding"] = { "status": "inferred_confirmed", "object_member_ref": f"Объект.{object_attribute.get('name')}", "member": { "name": object_attribute.get("name"), "synonym": object_attribute.get("synonym"), "type": object_attribute.get("type"), }, "source": "element_name + decoded_object_attribute", } binding_inference_count += 1 call_chains: list[dict[str, Any]] = [] if bool(include_call_chain) and matches: modules_result = metadata_object_modules({**payload, "include_storage": True}) modules = modules_result.get("modules") if isinstance(modules_result.get("modules"), list) else [] for form_name in sorted(matched_forms): form_module = next((module for module in modules if isinstance(module, dict) and module.get("kind") == "form_module" and isinstance(module.get("form"), dict) and normalize(str(module["form"].get("name") or "")) == normalize(form_name)), None) if not form_module: continue module_ref = str(form_module.get("module_id") or "") if not module_ref: continue # A command matching the requested field is the most explicit link # between visual control and BSL. Resolve only its first handler. handlers = handler_candidates.get(form_name) or [] if not handlers: continue handler = handlers[0] chain = diagnostics_call_chain({ "base_id": payload.get("base_id"), "module_ref": module_ref, "entry_method": handler, "depth": int(call_chain_depth), "max_nodes": 20, "timeout_seconds": int(call_timeout), }) call_chains.append({"form": form_name, "entry_handler": handler, "chain": chain}) calculation: dict[str, Any] | None = None # A concise calculation explanation is more useful than raw BSL when the # form field is populated by the standard product-cost routine. The # routine is read through the already supplied object selector only. is_cost_field = "себестоимость" in needle is_price_field = "ценапродажи" in needle and not is_cost_field if bool(include_calculation) and (is_cost_field or is_price_field) and matches: calculation_read = code_read({ **payload, "routine_name": "ОбновитьСебестоимостьПродукцииСПодготовленнымМВТ", "include_text": True, "include_routines": False, "include_summary": False, "max_chars": 100000, }) calculation_text = str(calculation_read.get("text") or "") if calculation_read.get("status") in {"ok", "summary", "text"} and calculation_text: normalized_lines = [line.strip().lstrip("|").strip() for line in calculation_text.splitlines()] marker = next((index for index, line in enumerate(normalized_lines) if "КАК Себестоимость" in line), -1) formula_lines: list[str] = [] if marker >= 0: start = max(0, marker - 18) formula_lines = [line for line in normalized_lines[start: marker + 1] if line] price_match = re.search(r"([\wА-Яа-яЁё]+)\.(ПолучитьЦеныПоТаблицеЦен)\s*\(", calculation_text) price_source: dict[str, Any] | None = None # The product-cost routine delegates price lookup through the UOP # helper into the standard pricing module. Both are configuration # BSL modules, so the register can be identified without runtime # execution or a global search. if price_match: pricing_bridge = code_read({ "base_id": payload.get("base_id"), "ref": f"CommonModule.{price_match.group(1)}", "routine_name": price_match.group(2), "include_text": True, "include_routines": False, "include_summary": False, "max_chars": 100000, }) bridge_text = str(pricing_bridge.get("text") or "") standard_call = re.search(r"ЦенообразованиеСервер\.(ПолучитьЦеныТабличнойЧастиПоВидуЦен)\s*\(", bridge_text) if standard_call: pricing_read = code_read({ "base_id": payload.get("base_id"), "ref": "CommonModule.ЦенообразованиеСервер", "routine_name": standard_call.group(1), "include_text": True, "include_routines": False, "include_summary": False, "max_chars": 200000, }) pricing_text = str(pricing_read.get("text") or "") register_match = re.search(r"РегистрСведений\.([\wА-Яа-яЁё]+)\.СрезПоследних", pricing_text) if register_match: extension_filter: dict[str, Any] | None = None uop_filter_read = code_read({ "base_id": payload.get("base_id"), "ref": "CommonModule.УОП_ЦенообразованиеСервер", "routine_name": "ДополнитьТекстЗапросаЦеныНоменклатуры", "include_text": True, "include_routines": False, "include_summary": False, "max_chars": 30000, }) uop_filter_text = str(uop_filter_read.get("text") or "") if "УОП_ПодразделениеЦен" in uop_filter_text: extension_filter = { "field": "УОП_ПодразделениеЦен", "when_empty": "УОП_ПодразделениеЦен = ЗНАЧЕНИЕ(Справочник.СтруктурныеЕдиницы.ПустаяСсылка)", "when_supplied": "УОП_ПодразделениеЦен В (&УОП_ПодразделениеЦен)", "parameter": "УОП_ПодразделениеЦен", "evidence": "УОП_ЦенообразованиеСервер.ДополнитьТекстЗапросаЦеныНоменклатуры", } price_source = { "status": "partial", "register": f"InformationRegister.{register_match.group(1)}", "access": "СрезПоследних", "effective_date": "ПолучитьДатуОбработкиЦен(ЭтоНовыйОбъект, СтруктураДанных.Дата)", "filters": ["ВидЦен = &ВидЦен", "Номенклатура", "Характеристика (when present)"], **({"extension_filter": extension_filter} if extension_filter else {}), "evidence": "ЦенообразованиеСервер.ПолучитьЦеныТабличнойЧастиПоВидуЦен", } calculation = { "status": "partial", "target": "Продукция.Себестоимость" if is_cost_field else "Продукция/ВидыКоличеств.ЦенаПродажи", "routine": "ОбновитьСебестоимостьПродукцииСПодготовленнымМВТ", "formula": ( "SUM((Стоимость, иначе СтоимостьРучная) × КоличествоИнгредиентаНорма × коэффициент единицы продукции / коэффициент единицы рецептуры)" if is_cost_field else "ЦенаВЕИ = Цена из регистра / коэффициент единицы цены × коэффициент единицы продукции; итог распределяется в Продукция и ВидыКоличеств" ), "formula_evidence": formula_lines if is_cost_field else [ line for line in normalized_lines if "ЦенаВЕИИнгредиента" in line or "ЗаполнитьСтоимость(" in line ][:12], **({"ingredient_cost_sources": ["втПроизводство.СтоимостьИнгредиента", "втЗамены.СтоимостьЗамены", "ручные стоимости"]} if is_cost_field else {}), "sales_price_provider": { "routine": f"{price_match.group(1)}.{price_match.group(2)}" if price_match else None, "context": ["Организация", "Подразделение", "ДатаПлана", "Номенклатура", "Характеристика", "ВидЦенПродаж"], **({"source": price_source} if price_source else {}), }, "assignment_evidence": ( "НайденныеСтроки[0].Себестоимость = ВыборкаДетальныеЗаписи.Себестоимость" if is_cost_field else 'ЗаполнитьСтоимость(..., "Продукция", ..., "ВидыКоличеств", "НомерСтрокиВида", "ЦенаПродажи")' ), "limitation": "Статически подтверждены регистр и условия отбора. Адаптер не исполняет 1С-запрос и не подменяет фактические данные расчётом.", } exact_count = sum(1 for item in matches if item.get("match_by") == "exact") disambiguation_candidates: list[dict[str, Any]] = [] seen_disambiguation_candidates: set[tuple[str, str]] = set() for item in matches: candidate_item = item.get("item") if isinstance(item.get("item"), dict) else {} field_name = str(candidate_item.get("name") or "") form_name = str(item.get("form") or "") if not field_name: continue # A form-scoped repeat call does not need separate variants for the # same visual name found in several decoded sections. candidate_key = (form_name, field_name) if candidate_key in seen_disambiguation_candidates: continue seen_disambiguation_candidates.add(candidate_key) disambiguation_candidates.append({ "form": item.get("form"), "section": item.get("section"), "field": field_name, "title": candidate_item.get("title"), }) selector_params = ( {"ref": payload.get("ref")} if payload.get("ref") else { key: payload[key] for key in ("kind", "name", "guid", "object_type", "object_name", "object_guid") if payload.get(key) not in (None, "") } ) recommended_calls = [ { "method": method, "params": { "base_id": payload.get("base_id"), **selector_params, **({"form": candidate["form"]} if candidate.get("form") else {}), "field": candidate["field"], "match_mode": "exact", "include_related": bool(include_related), "include_binding_inference": bool(include_binding_inference), "include_call_chain": bool(include_call_chain), "include_calculation": bool(include_calculation), }, } for candidate in disambiguation_candidates[:20] ] agent_paths: list[dict[str, Any]] = [] seen_agent_paths: set[tuple[str, str, str, str]] = set() for match in matches: item = match.get("item") if isinstance(match.get("item"), dict) else {} form_name = str(match.get("form") or "") field_name = str(item.get("name") or field) section = str(match.get("section") or "") binding = item.get("binding") if isinstance(item.get("binding"), dict) else {} references = item.get("source_references") if isinstance(item.get("source_references"), list) else [] if not references and isinstance(item.get("dynamic_list_binding"), dict): references = (item["dynamic_list_binding"].get("query_lineage") or {}).get("source_references") or [] public_paths = sorted({ str(reference.get("public_path") or "").strip() for reference in references if isinstance(reference, dict) and str(reference.get("public_path") or "").strip() }) if not public_paths and binding.get("object_member_ref"): public_paths = [str(binding["object_member_ref"])] if not public_paths: public_paths = [""] query_lineage = item.get("query_lineage") if isinstance(item.get("query_lineage"), dict) else {} for public_path in public_paths: path_key = (form_name, section, field_name, public_path) if path_key in seen_agent_paths: continue seen_agent_paths.add(path_key) visual_container = ( str(item.get("type_name") or "").casefold() in {"страница", "группа", "группастраниц", "commandbar", "командная панель"} and not item.get("data_path") and not item.get("expression") and not references and not binding ) action_binding = ( section == "commands" or "кнопка" in str(item.get("type_name") or "").casefold() or "командн" in str(item.get("type_name") or "").casefold() ) status = "container_only" if visual_container else "confirmed" if public_path and ( query_lineage.get("status") == "confirmed" or binding.get("status") in {"confirmed", "inferred_confirmed"} ) else "partial" limitations = [] if visual_container: limitations.append("The exact name matched a visual container, not a data-bound field.") if query_lineage.get("diagnostics"): limitations.append(", ".join(query_lineage["diagnostics"])) agent_paths.append({ "status": status, "form": form_name, "section": section, "field": field_name, "path_kind": "action" if action_binding else "data", **({"data_path": item.get("data_path")} if item.get("data_path") else {}), **({"expression": item.get("expression")} if item.get("expression") else {}), **({"source": public_path} if public_path else {}), **({"binding_source": binding.get("source")} if binding.get("source") else {}), **({"limitation": "; ".join(limitations)} if limitations else {}), }) has_confirmed_data_path = any(path.get("status") == "confirmed" and path.get("path_kind") == "data" for path in agent_paths) has_partial_path = any(path.get("status") == "partial" for path in agent_paths) agent_summary = { # A page/group match is useful navigation evidence but must not turn a # successfully resolved data field into a partial lineage result. "status": "confirmed" if has_confirmed_data_path and not has_partial_path else "partial" if agent_paths else "not_found", "confirmed_paths": [path for path in agent_paths if path.get("status") == "confirmed"], "confirmed_data_paths": [path for path in agent_paths if path.get("status") == "confirmed" and path.get("path_kind") == "data"], "confirmed_action_paths": [path for path in agent_paths if path.get("status") == "confirmed" and path.get("path_kind") == "action"], "partial_paths": [path for path in agent_paths if path.get("status") == "partial"], "container_matches": [path for path in agent_paths if path.get("status") == "container_only"], "rule": "Only static decoded bindings and direct query references are confirmed; formulas and runtime values are never evaluated.", } return { "schema": "onec_form_field_lineage.v2", "method": method, "status": "ok" if matches else "not_found", "base_id": payload.get("base_id"), "ref": payload.get("ref"), "field": field, "match_mode": match_mode, "analysis_profile": analysis_profile, "matches": matches, "match_summary": { "count": len(matches), "exact_count": exact_count, "binding_inference_count": binding_inference_count, "ambiguous": len(matches) > 1, **({"recommended_match_mode": "exact"} if len(matches) > 1 and match_mode != "exact" else {}), **({"candidates": disambiguation_candidates[:50]} if len(matches) > 1 else {}), # Exact field names can still occur in several managed forms. # Always provide a form-scoped follow-up when the result is ambiguous. **({"next_calls": recommended_calls} if len(matches) > 1 else {}), }, **({"form_title_filter": { "requested": form_title, "status": "partial" if title_unavailable_forms else "confirmed", "confirmed_forms": sorted(name for name in title_confirmed_forms if name), "unavailable_forms": sorted(name for name in title_unavailable_forms if name), **({"limitation": "Some matched forms have no statically decoded title; title filter could not be verified for them."} if title_unavailable_forms else {}), }} if form_title else {}), "call_chains": call_chains, "agent_summary": agent_summary, **({"next_calls": [{ "method": "form.field.lineage", "params": { key: payload.get(key) for key in ("base_id", "ref", "kind", "name", "guid", "form", "form_title", "field", "match_mode") if payload.get(key) not in {None, ""} } | {"analysis_profile": "full", "include_calculation": True, "include_call_chain": True}, "reason": "Fast profile confirmed the data path; request full only when static calculation or call-chain evidence is needed.", }]} if analysis_profile == "fast" else {}), **({"calculation": calculation} if calculation else {}), "limitations": ["Static decoded form and BSL evidence only; no runtime evaluation is performed."] } def dcs_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: """Static, object-scoped provenance for one SCD output field.""" method = "dcs.field.lineage" field = str(payload.get("field") or "").strip() if not field: return invalid_argument(method, "field", "field is required.") inspected = scd_inspect({**payload, "sections": ["datasets", "fields", "calculated_fields", "resources"], "resolve_metadata": False}) if inspected.get("status") != "ok": return {"schema": "onec_dcs_field_lineage.v1", "method": method, "status": inspected.get("status"), "base_id": payload.get("base_id"), "inspect": inspected} matches: list[dict[str, Any]] = [] alias_pattern = re.compile(r"(?is)(?P[^,;]+?)\\s+КАК\\s+" + re.escape(field) + r"(?![A-Za-zА-Яа-я0-9_])") source_pattern = re.compile(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)") bindings_by_dataset: dict[str, dict[str, str]] = {} def direct_references(expression: str, bindings: dict[str, str], temporary_bindings: dict[tuple[str, str], tuple[str, str]] | None = None, temporary_expressions: dict[tuple[str, str], str] | None = None, virtual_arguments: dict[str, str] | None = None, where_clause: str | None = None, joins_by_alias: dict[str, list[dict[str, str]]] | None = None) -> list[dict[str, Any]]: references: list[dict[str, Any]] = [] for item in source_pattern.finditer(expression): alias, name = item.group("alias"), item.group("name") reference: dict[str, Any] = {"alias": alias, "field": name} source = bindings.get(normalize(alias)) temporary = (temporary_bindings or {}).get((normalize(alias), normalize(name))) temporary_expression = (temporary_expressions or {}).get((normalize(alias), normalize(name))) if temporary_expression and not temporary: # A temporary-table field can be a static expression rather than # a plain projection. Follow its direct input fields once; # retain the table/field in evidence and never evaluate BSL/SQL. nested_references = direct_references( temporary_expression, bindings, temporary_bindings, {}, virtual_arguments, where_clause, joins_by_alias, ) if nested_references: for nested in nested_references: nested["via_temporary_table"] = {"alias": alias, "field": name, "expression": temporary_expression} nested["resolution"] = "temporary_table_expression" references.append(nested) continue if temporary: source, name = temporary reference.update({"field": name, "resolution": "temporary_table_projection"}) elif normalize(alias).startswith("вт"): reference["unresolved_reason"] = "temporary_table_projection_not_resolved" if source: reference.update({"source": source, "public_path": f"{source}.{name}"}) if (joins_by_alias or {}).get(normalize(alias)): reference["joins"] = joins_by_alias[normalize(alias)] source_parts = source.split(".") if len(source_parts) >= 3 and normalize(source_parts[-1]) in {"срезпоследних", "остатки", "обороты", "остаткииобороты"}: reference["virtual_source"] = { "register": ".".join(source_parts[:-1]), "virtual_table": source_parts[-1], "status": "static_source_confirmed", **({"arguments": virtual_arguments[normalize(source)]} if (virtual_arguments or {}).get(normalize(source)) else {}), **({"filter": where_clause} if where_clause else {}), } references.append(reference) return references for dataset in (inspected.get("sections") or {}).get("datasets") or []: query = str(dataset.get("query") or "") dataset_name = str(dataset.get("name") or "") bindings: dict[str, str] = {} source_rule = re.compile( r"(?is)\b(?:ИЗ|СОЕДИНЕНИЕ)\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"(?:\s*\([^()]*\))?(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?" ) for source_match in source_rule.finditer(query): source = str(source_match.group("source") or "").strip() alias = str(source_match.group("alias") or source.rsplit(".", 1)[-1]).strip() if source and alias: bindings[normalize(alias)] = source virtual_arguments: dict[str, str] = {} for virtual_match in re.finditer( r"(?is)(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)\s*\((?P[^()]*)\)", query, ): virtual_arguments[normalize(str(virtual_match.group("source") or ""))] = str(virtual_match.group("arguments") or "").strip() where_match = re.search(r"(?is)\bГДЕ\b\s*(.*?)(?=\b(?:СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)", query) where_clause = str(where_match.group(1) or "").strip() if where_match else "" joins_by_alias: dict[str, list[dict[str, str]]] = {} join_rule = re.compile( r"(?is)\b(?P(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)\s+)?СОЕДИНЕНИЕ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" r"(?:\s*\([^()]*\))?\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПО\s+" r"(?P.*?)(?=\b(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)?\s*СОЕДИНЕНИЕ\b|\b(?:ГДЕ|СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)" ) for join_match in join_rule.finditer(query): alias = normalize(str(join_match.group("alias") or "")) if alias: joins_by_alias.setdefault(alias, []).append({ "kind": " ".join(str(join_match.group("kind") or "inner").split()).casefold(), "condition": str(join_match.group("condition") or "").strip(), }) bindings_by_dataset[dataset_name] = bindings temporary_bindings: dict[tuple[str, str], tuple[str, str]] = {} temporary_rule = re.compile( r"(?is)\bВЫБРАТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\." r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПОМЕСТИТЬ\s+" r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" ) for temporary_match in temporary_rule.finditer(query): source = bindings.get(normalize(str(temporary_match.group("source_alias") or ""))) if source: temporary_bindings[(normalize(str(temporary_match.group("temporary") or "")), normalize(str(temporary_match.group("output") or "")))] = (source, str(temporary_match.group("source_field") or "")) # Package queries commonly project many columns before ПОМЕСТИТЬ. # Capture only direct ``Alias.Field AS Output`` projections, then # iteratively resolve chains such as втШапка -> втПродукция -> # ВТ_ПродукцияИтоговый. Expressions and aggregates deliberately stay # outside this bounded static rule. temporary_links: dict[tuple[str, str], tuple[str, str]] = {} temporary_expressions: dict[tuple[str, str], str] = {} for package_part in re.split(r"(?is);", query): target_match = re.search(r"(?is)\bПОМЕСТИТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", package_part) select_match = re.search(r"(?is)\bВЫБРАТЬ\s+(?P
:#stream:.") if module_table in SAVED_STATE_TARGET_BY_SOURCE: resolved_payload = metadata_write_resolve_module_target( {**payload, "module_ref": module_ref, "include_storage": True}, target_selector, mode, ) if isinstance(resolved_payload, tuple): resolved_payload = resolved_payload[0] if not isinstance(resolved_payload, dict) or resolved_payload.get("error"): result = dict(resolved_payload) if isinstance(resolved_payload, dict) else {} result.update({"schema": "onec_module_write_apply.v1", "method": method, "base_id": base_id}) result.setdefault("status", "blocked") result.setdefault("error", "extension_saved_state_prepare_protocol_unproven") return result payload = resolved_payload module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() module_table, module_file_name, module_stream_index = parse_module_id(module_ref) if module_table not in FORM_ELEMENT_SAVED_STATE_TABLES or not module_file_name: return { "schema": "onec_module_write_apply.v1", "method": method, "base_id": base_id, "status": "blocked", "error": "extension_saved_state_prepare_protocol_unproven", "diagnostics": {"message": "The active module did not resolve to one proven saved-state target."}, } if module_table in SAVED_STATE_TARGET_BY_SOURCE: # Active Config/ConfigCAS evidence is exact but never writable. # Resolve the proven copy transition here rather than falling back # to a broad saved-module search or asking a caller for a key. resolved_payload = metadata_write_resolve_module_target( {**payload, "module_ref": module_ref, "include_storage": True}, target_selector, mode, ) if isinstance(resolved_payload, tuple): resolved_payload = resolved_payload[0] if not isinstance(resolved_payload, dict): return { "schema": "onec_module_write_apply.v1", "method": method, "base_id": base_id, "status": "blocked", "error": "extension_saved_state_prepare_protocol_unproven", } if resolved_payload.get("error"): result = dict(resolved_payload) result["method"] = method return result payload = resolved_payload module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() module_table, module_file_name, module_stream_index = parse_module_id(module_ref) if module_table not in FORM_ELEMENT_SAVED_STATE_TABLES or not module_file_name: return { "schema": "onec_module_write_apply.v1", "method": method, "base_id": base_id, "status": "blocked", "error": "extension_saved_state_prepare_protocol_unproven", "diagnostics": {"message": "The active module did not resolve to one proven saved-state target."}, } stream_index = module_stream_index if stream_index is None and "stream_index" in payload: stream_index, stream_index_error = parse_int_argument(payload, "stream_index", method=method, default=0, minimum=0) if stream_index_error: return stream_index_error table = str(payload.get("table") or module_table or "ConfigCASSave") file_name = str(payload.get("file_name") or module_file_name or "") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Module saved-state write only targets ConfigSave/ConfigCASSave.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) if not file_name or Path(file_name).name != file_name: return invalid_argument(method, "file_name", "Pass a safe saved-state module file_name or module_ref.") if is_apply_mode: saved_state_apply_module_trace(phase="module_target_resolved", base_id=base_id, module_ref=f"{table}:{file_name}#stream:{stream_index}") codec_error = metadata_module_write_stream_codec_guard( base_id=base_id, table=table, file_name=file_name, stream_index=stream_index, timeout_seconds=int(timeout_seconds or 30), method=method, ) if codec_error: return codec_error payload = metadata_module_write_scope_fragment_payload( payload, base_id=base_id, table=table, file_name=file_name, stream_index=stream_index, timeout_seconds=int(timeout_seconds or 30), method=method, ) if isinstance(payload, dict) and payload.get("schema") == "onec_module_write_apply.v1": return payload if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload edit = module_write_apply_edit(payload, method, stream_index) if isinstance(edit, dict) and edit.get("status") == "invalid_argument": return edit proposal_payload = { "base_id": base_id, "source": { "base_id": base_id, "table": table, "file_name": file_name, **({"module_id": f"{table}:{file_name}#stream:{stream_index}"} if stream_index is not None else {}), **({"expected_sha1": payload.get("expected_sha1")} if payload.get("expected_sha1") else {}), }, "edits": [edit], "include_text": bool(payload.get("include_text") is True), "include_payload": bool(include_payload or mode in {"apply", "apply_and_verify", "apply_and_rollback"}), "timeout_seconds": int(timeout_seconds or 30), "summary": payload.get("summary") or "Saved-state module stream write proposal", } if is_apply_mode: saved_state_apply_module_trace(phase="changes_propose", base_id=base_id, module_ref=f"{table}:{file_name}#stream:{stream_index}") proposal = changes_propose(proposal_payload) if is_apply_mode: saved_state_apply_module_trace(phase="changes_propose_complete", base_id=base_id, module_ref=f"{table}:{file_name}#stream:{stream_index}") result: dict[str, Any] = { "schema": "onec_module_write_apply.v1", "method": method, "status": "planned", "execution_mode": mode, "base_id": base_id, "module_ref": f"{table}:{file_name}#stream:{stream_index}" if stream_index is not None else f"{table}:{file_name}", "write_mode": { "requested": "saved_state", "target_table": table, "sql_write_performed": False, "requires_apply_gate": True, }, "proposal": proposal if mode == "plan" else sanitize_proposal_for_response(proposal), } if isinstance(payload.get("_fragment_scope"), dict): scope = {key: value for key, value in payload["_fragment_scope"].items() if key != "occurrences"} result["scope"] = scope result["counts"] = {"occurrences": int(payload["_fragment_scope"].get("occurrences") or 0)} if proposal.get("status") not in {"accepted_for_review", "ok"}: result["status"] = proposal.get("status") or "error" result["diagnostics"] = proposal.get("diagnostics") return result if is_apply_mode: saved_state_apply_module_trace(phase="write_gate", base_id=base_id, module_ref=f"{table}:{file_name}#stream:{stream_index}") write_plan, write_plan_error = metadata_write_apply_plan_gate( method, payload, target_kind="module", target={ "kind": "module", "table": table, "file_name": file_name, **({"module_ref": f"{table}:{file_name}#stream:{stream_index}"} if stream_index is not None else {}), }, ) result["write_plan"] = write_plan if write_plan_error: result.update(write_plan_error) return result if mode == "plan": return result allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if not allow_apply: return invalid_argument(method, "allow_sql_saved_state_apply", "Apply mode is opt-in; pass allow_sql_saved_state_apply=true.") saved_state_apply_module_trace(phase="storage_apply", base_id=base_id, module_ref=f"{table}:{file_name}#stream:{stream_index}") storage_apply_payload = { **repository_write_context(payload), # This call is an internal continuation of the authorization that # has already guarded preparation and proposal creation above. # Preserve its signed public scope, otherwise the low-level # apply helper re-runs support discovery with only a saved file # name and can spend the entire job timeout scanning the extension. **{ key: payload[key] for key in ( "extension", "extension_guid", "layer_id", "canonical_path", "routine_name", ) if payload.get(key) is not None and payload.get(key) != "" }, **{ key: payload[key] for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "support_object_guid") if payload.get(key) not in {None, ""} }, "base_id": base_id, "module_ref": f"{table}:{file_name}#stream:{stream_index}", "allow_sql_saved_state_apply": True, "proposal": proposal, "timeout_seconds": int(timeout_seconds or 30), } # The module gate above has just validated this public write. Bind a fresh # process-local proof to its exact final ConfigCASSave stream payload. # The incoming proof may legitimately be absent for a name-first public # call, so conditioning this on it caused the low-level helper to repeat # support discovery after the route had already been proved. storage_apply_payload["_adapter_write_authorization"] = adapter_write_authorization_proof(storage_apply_payload) apply_result = storage_saved_state_apply_proposal(storage_apply_payload) result["apply_result"] = apply_result result["status"] = apply_result.get("status") or "error" result["applied"] = bool(apply_result.get("applied")) result["write_mode"]["sql_write_performed"] = bool(result["applied"]) if mode == "apply": return result if mode == "apply_and_verify": readback = apply_result.get("readback") if isinstance(apply_result.get("readback"), dict) else {} verified = bool(result["applied"] and readback.get("verified") is not False) result["verification"] = { "status": "ok" if verified else "failed", "saved_state_readback": bool(readback), "expected_payload_sha1": readback.get("expected_sha1"), "actual_payload_sha1": readback.get("actual_sha1"), } result["status"] = "verified" if verified else result["status"] return result allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "apply_and_rollback mode is opt-in; pass allow_sql_saved_state_rollback=true.") if apply_result.get("schema") == "onec_extension_saved_state_pair_apply.v1": rollback_result = rollback_extension_saved_state_pair(base_id, apply_result, timeout_seconds=int(timeout_seconds or 30)) result["rollback_result"] = rollback_result result["rolled_back"] = bool(rollback_result.get("applied")) result["status"] = "verified_and_rolled_back" if result["applied"] and result["rolled_back"] else "applied_rollback_failed" return result backup_id = ((apply_result.get("backup") or {}).get("backup_id") if isinstance(apply_result.get("backup"), dict) else None) if not backup_id: result["status"] = "rollback_unavailable" result["diagnostics"] = {"message": "Apply result did not return backup.backup_id; cannot rollback automatically."} return result rollback_payload = { **repository_write_context(payload), **{ key: payload[key] for key in ( "extension", "extension_guid", "layer_id", "canonical_path", "routine_name", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "support_object_guid", ) if payload.get(key) not in {None, ""} }, "base_id": base_id, "allow_sql_saved_state_rollback": True, "backup_id": backup_id, "timeout_seconds": int(timeout_seconds or 30), } # Rollback is the same already-authorized public operation, bound here to # the backup created by this exact module write. Do not make it rediscover # support for a storage-only backup id. rollback_payload["_adapter_write_authorization"] = adapter_write_authorization_proof(rollback_payload) rollback_result = storage_saved_state_rollback(rollback_payload) result["rollback_result"] = rollback_result result["rolled_back"] = bool(rollback_result.get("applied")) if result["applied"] and result["rolled_back"]: result["status"] = "verified_and_rolled_back" elif result["applied"]: result["status"] = "applied_rollback_failed" return result def metadata_write_embedded_form_module_payload( write_payload: dict[str, Any], *, module_table: str, module_file_name: str, method: str, ) -> tuple[dict[str, Any], dict[str, Any] | None] | dict[str, Any]: handler_name = str(write_payload.get("routine_name") or write_payload.get("handler_name") or "").strip() requested_operation = str(write_payload.get("operation") or write_payload.get("routine_operation") or "").strip().casefold() wants_fragment = bool(write_payload.get("_force_fragment_replace")) or requested_operation in {"fragment", "fragment_replace", "replace_fragment"} has_fragment = (write_payload.get("old") is not None or write_payload.get("new") is not None) and (wants_fragment or not handler_name) has_module_text = write_payload.get("module_text") is not None or (write_payload.get("text") is not None and not handler_name) has_routine_text = write_payload.get("routine_text") is not None or write_payload.get("handler_routine_text") is not None or ( write_payload.get("text") is not None and bool(handler_name) ) or (write_payload.get("new") is not None and bool(handler_name) and not wants_fragment) if has_fragment: if write_payload.get("old") is None or write_payload.get("new") is None: return invalid_argument(method, "old/new", "Pass both old and new for an embedded form module container fragment write.") operation_kind = "fragment" elif has_module_text: operation_kind = "module_text" else: operation_kind = "routine" if not handler_name: return invalid_argument(method, "routine_name", "Pass routine_name for an embedded form module routine write.") if not has_routine_text: return invalid_argument(method, "routine_text", "Pass routine_text for an embedded form module routine write.") routine_text = "" if has_routine_text: routine_text = str( write_payload.get("handler_routine_text") if write_payload.get("handler_routine_text") is not None else ( write_payload.get("routine_text") if write_payload.get("routine_text") is not None else (write_payload.get("text") if write_payload.get("text") is not None else write_payload.get("new") or "") ) ) write_payload.update( { "_embedded_form_module": True, "_embedded_form_module_operation": operation_kind, "table": module_table, "file_name": module_file_name, "handler_name": handler_name, "handler_routine_operation": write_payload.get("routine_operation") or write_payload.get("operation") or "replace", } ) if operation_kind == "routine": write_payload["routine_text"] = routine_text write_payload["handler_routine_text"] = routine_text elif operation_kind == "module_text" and write_payload.get("module_text") is None: write_payload["module_text"] = str(write_payload.get("text") or "") return write_payload, {"method": "form_embedded_module_handler_write_apply", "reason": "saved_state_form_payload_container"} def metadata_write_resolve_public_object_module_saved_ref(write_payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Resolve one extension/base object-module stream from public ownership. Saved extension payload keys are hashes, so a generic SQL module scan cannot prove their owner. Start from the already-resolved active object module list, map its exact file/stream to the prepared save layer, and require the caller fragment to occur in exactly one saved stream. """ base_id = str(write_payload.get("base_id") or "") extension = str(write_payload.get("extension") or write_payload.get("preferred_extension") or "").strip() owner_ref = str(write_payload.get("ref") or "").strip() owner_kind = str(write_payload.get("object_type") or write_payload.get("kind") or "").strip() owner_name = str(write_payload.get("object_name") or write_payload.get("name") or "").strip() fragment = str(write_payload.get("old") or write_payload.get("expected_contains") or write_payload.get("query") or "").strip() requested_ordinal = first_non_empty_arg(write_payload, "module_ordinal", "module_index", "module_number") if requested_ordinal is not None: try: requested_ordinal = int(requested_ordinal) except (TypeError, ValueError): return None, {"method": "metadata.object.modules", "status": "invalid_argument", "argument": "module_ordinal"} if not base_id or not (owner_ref or (owner_kind and owner_name)): return None, None modules_result = metadata_object_modules({ "base_id": base_id, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), **({"extension": extension} if extension else {}), "include_storage": True, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), }) if modules_result.get("status") != "ok": return None, {"method": "metadata.object.modules", "result": modules_result} candidates: list[dict[str, Any]] = [] for module in modules_result.get("modules") or []: if not isinstance(module, dict): continue if requested_ordinal is not None and int(module.get("module_ordinal") or -1) != requested_ordinal: continue active_ref = str(module.get("module_id") or "") active_table, file_name, stream_index = parse_module_id(active_ref) if not file_name or stream_index is None: continue role_unconfirmed = str(module.get("role_status") or "").casefold() == "unconfirmed" if role_unconfirmed: # A role-less module must never be selected by ordinal alone. # If there is already a saved extension overlay, it is the current # Designer working state. Prefer that exact mapped stream over # ConfigCAS: falling back to active text here rejects a perfectly # valid edit whose old fragment was read from pending work. if active_table == "ConfigCAS" and extension: saved_probe = metadata_saved_state_prepare({ "base_id": base_id, "layer": "extension_saved_state", "extension": extension, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), "mode": "plan", "include_storage": True, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), }) mapped_names = [ str(row.get("file_name") or "") for row in (saved_probe.get("source_rows") or []) if isinstance(row, dict) and str(row.get("source_file_name") or "") == file_name ] target_names = { str(row.get("file_name") or "") for row in (saved_probe.get("target_collisions") or []) if isinstance(row, dict) } if len(mapped_names) == 1 and mapped_names[0] in target_names: saved_ref = f"ConfigCASSave:{mapped_names[0]}#stream:{stream_index}" saved_read = read_module({ "base_id": base_id, "module_ref": saved_ref, "resolve_owner": False, "max_chars": 10_000_000, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), }) saved_text = str(saved_read.get("text") or "") if saved_read.get("status") == "ok": candidates.append({ "module_ref": saved_ref, "file_name": mapped_names[0], "stream_index": stream_index, "owner_ref": owner_ref or object_selector_ref(owner_kind, owner_name), "extension": extension, "active_source": False, "expected_text_sha1": str((saved_read.get("write_preconditions") or {}).get("expected_text_sha1") or saved_read.get("content_sha1") or ""), }) continue return None, { "method": "metadata.saved_state.prepare", "status": "unresolved", "error": "saved_state_fragment_not_confirmed", "target_kind": "module", "module_ordinal": requested_ordinal, "diagnostics": { "message": "The selected object has pending saved-state code, but that current stream did not contain the requested old fragment exactly once.", "next_action": "reread_current_working_module", }, } # There is one narrow, evidenced alternative for an extension # write: metadata.object.modules has already bound the stream to # the caller's exact public object and extension, and the exact # old fragment occurs once in that active ConfigCAS stream. Carry # that active source into the normal prepare path; it will create # ConfigCASSave internally and retain the same old/new guard. if active_table != "ConfigCAS" or not extension or str(module.get("kind") or "").casefold() != "bsl_module": return None, { "method": "metadata.object.modules", "status": "unresolved", "error": "module_owner_unconfirmed", "target_kind": "module", "module_ordinal": requested_ordinal, "diagnostics": { "message": "The selected BSL stream has no confirmed Configurator-tree owner or module role and no exact extension-stream fallback.", "next_action": "adapter_metadata_decoder_required", }, } active_read = read_module({ "base_id": base_id, "module_ref": active_ref, "resolve_owner": False, "max_chars": 10_000_000, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), }) active_text = str(active_read.get("text") or "") if active_read.get("status") != "ok": return None, { "method": "metadata.object.modules", "status": "unresolved", "error": "module_owner_unconfirmed", "target_kind": "module", "module_ordinal": requested_ordinal, "diagnostics": {"message": "The role-less extension BSL stream could not be read for the confirmed public object.", "next_action": "adapter_stream_decoder_required"}, } candidates.append({ "module_ref": active_ref, "file_name": file_name, "stream_index": stream_index, "owner_ref": owner_ref or object_selector_ref(owner_kind, owner_name), "extension": extension, "active_source": True, "expected_text_sha1": str((active_read.get("write_preconditions") or {}).get("expected_text_sha1") or active_read.get("content_sha1") or ""), }) continue if str(module.get("kind") or "").casefold() != "object_module": continue # Once a saved copy exists metadata.object.modules can legitimately # return ConfigSave/ConfigCASSave directly. Treat it as the already # prepared target instead of discarding it as if it were an unknown # active table. if active_table in FORM_ELEMENT_SAVED_STATE_TABLES: saved_ref = active_ref elif active_table == "ConfigCAS" and extension: # Ask the preparation resolver for the saved key. Hash-keyed # overlays preserve the active key; alternate layouts may return # an observed mapping. The caller never sees either detail. saved_probe = metadata_saved_state_prepare( { "base_id": base_id, "layer": "extension_saved_state", "extension": extension, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), "mode": "plan", "include_storage": True, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } ) mapped_names = [ str(row.get("file_name") or "") for row in (saved_probe.get("source_rows") or []) if isinstance(row, dict) and str(row.get("source_file_name") or "") == file_name ] if saved_probe.get("status") == "extension_saved_state_prepare_protocol_unproven": return None, { "method": "metadata.saved_state.prepare", "status": "protocol_incomplete", "error": "extension_saved_state_prepare_protocol_unproven", "diagnostics": { "message": "The adapter has not yet proven a safe saved-state preparation transform for this extension layout. No technical coordinates are required from the caller.", "next_action": "adapter_protocol_development_required", }, } if len(mapped_names) != 1: continue saved_ref = f"ConfigCASSave:{mapped_names[0]}#stream:{stream_index}" elif active_table in SAVED_STATE_TARGET_BY_SOURCE: saved_ref = f"{SAVED_STATE_TARGET_BY_SOURCE[active_table]}:{file_name}#stream:{stream_index}" else: continue read_result = read_module({ "base_id": base_id, "module_ref": saved_ref, "resolve_owner": False, "max_chars": 10_000_000, "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), }) read_text = str(read_result.get("text") or "") # Public modules.read normalizes line endings, whereas fragment_replace # preserves the original container text. Compare semantically here and # keep the caller's original `old` bytes for the eventual proposal. # Stream ownership comes from the exact public object selector, not # from a mutable old fragment. Binding the two made a successful # replacement look like a route failure on a normal idempotent retry. # The module writer below still enforces the exact old/new guard. if read_result.get("status") != "ok": continue candidates.append({ "module_ref": saved_ref, "file_name": file_name, "stream_index": stream_index, "owner_ref": owner_ref or object_selector_ref(owner_kind, owner_name), "extension": extension or None, "expected_text_sha1": str( (read_result.get("write_preconditions") or {}).get("expected_text_sha1") or read_result.get("content_sha1") or "" ), }) if len(candidates) == 1: return candidates[0], {"method": "metadata.object.modules", "status": "resolved_public_object_module"} return None, { "method": "metadata.object.modules", "status": "not_found" if not candidates else "ambiguous", "candidates": len(candidates), } def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[str, Any], mode: str) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any] | None]: method = METADATA_WRITE_METHOD resolution_timings: dict[str, int] = {} strict_working_target = isinstance(payload.get("write_target"), dict) auto_prepare = mode != "plan" and (strict_working_target or payload.get("allow_sql_saved_state_apply") is True) and payload.get("auto_prepare_saved_state") is not False write_payload = dict(payload) for key, value in target.items(): if key in {"kind", "area"}: continue write_payload.setdefault(key, value) requested_path = str(target.get("canonical_path") or payload.get("canonical_path") or target.get("path") or payload.get("path") or "").strip() if requested_path: path_resolution = metadata_write_plan_path_parts(requested_path) if path_resolution.get("kind") and path_resolution.get("name"): write_payload.setdefault("object_type", path_resolution.get("kind")) write_payload.setdefault("object_name", path_resolution.get("name")) if path_resolution.get("form_name"): write_payload.setdefault("form", path_resolution.get("form_name")) if path_resolution.get("kind") == "CommonForm" and path_resolution.get("name"): write_payload.setdefault("object_type", "CommonForm") write_payload.setdefault("object_name", path_resolution.get("name")) if path_resolution.get("routine_name"): write_payload.setdefault("routine_name", path_resolution.get("routine_name")) if "target" in write_payload: write_payload.pop("target", None) for key in ("kind", "area"): if str(write_payload.get(key) or "").strip().casefold() in {"module", "модуль", "bsl"}: write_payload.pop(key, None) write_payload["execution_mode"] = mode write_payload["allow_saved_state_write"] = True # `concrete_reference` is the opaque exact active stream issued by # code.search/code.read. Treat it equivalently to module_ref here; falling # through to metadata.object.modules would discard proven provenance and # can fail for extension streams whose Configurator role is unavailable. module_ref_value = str( write_payload.get("module_ref") or write_payload.get("module_id") or write_payload.get("concrete_reference") or "" ).strip() if module_ref_value: module_table, module_file_name, module_stream_index = parse_module_id(module_ref_value) # Preparation creates ConfigCASSave state and is therefore a write. # Authorize it before invoking the copy codec; otherwise an # apply-and-verify request without repository confirmation can spend # its timeout in preparation (or mutate saved state) before the # guarded module writer gets a chance to reject it. if auto_prepare and module_table in SAVED_STATE_TARGET_BY_SOURCE: saved_state_apply_module_trace(phase="module_resolution_before_authorization", base_id=str(payload.get("base_id") or ""), module_ref=module_ref_value) preparation_gate = repository_apply_gate(write_payload, METADATA_WRITE_METHOD, mode) saved_state_apply_module_trace(phase="module_resolution_after_authorization", base_id=str(payload.get("base_id") or ""), module_ref=module_ref_value) if preparation_gate: return preparation_gate # Freeze this successfully checked public authorization for every # internal phase of the same apply. The saved target has not been # created yet, so a later gate must not rediscover it as a new # owner/support route. write_payload["_adapter_write_authorization"] = adapter_write_authorization_proof(write_payload) if module_table in SAVED_STATE_TARGET_BY_SOURCE and module_file_name: target_table = SAVED_STATE_TARGET_BY_SOURCE[module_table] prepared_ref = f"{target_table}:{module_file_name}" + (f"#stream:{module_stream_index}" if module_stream_index is not None else "") prepare_payload = { **repository_write_context(write_payload), "base_id": payload.get("base_id"), "source_table": module_table, "target_table": target_table, "module_ref": module_ref_value, "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), "_adapter_write_authorization": write_payload.get("_adapter_write_authorization"), "_resolved_source_module_ref": module_ref_value, } for key in ( "extension", "extension_guid", "layer_id", "ref", "kind", "name", "object_type", "object_name", "canonical_path", "routine_name", "query", ): if write_payload.get(key) is not None: prepare_payload[key] = write_payload.get(key) # An active exact stream is evidence for the final BSL target, not # the complete extension working-copy boundary. When the caller # also supplied its public extension owner, resolve preparation # from that owner so the proven hash-keyed route includes the # extension root manifest and every required object part. if ( module_table == "ConfigCAS" and str(write_payload.get("extension") or write_payload.get("preferred_extension") or "").strip() and any(write_payload.get(key) for key in ("ref", "kind", "name", "object_type", "object_name")) ): prepare_payload.pop("module_ref", None) if auto_prepare or payload.get("allow_sql_saved_state_prepare"): prepare_payload["allow_sql_saved_state_prepare"] = True saved_state_apply_module_trace(phase="module_resolution_before_prepare", base_id=str(payload.get("base_id") or ""), module_ref=module_ref_value) prepare_result = metadata_saved_state_prepare(prepare_payload) saved_state_apply_module_trace(phase="module_resolution_after_prepare", base_id=str(payload.get("base_id") or ""), module_ref=module_ref_value) # ``already_prepared`` is a verified existing ConfigCASSave copy, # not a request for the caller to prepare it again. Continue on # the deterministically mapped saved stream with the normal # owner/layer/SHA/old-new guards. if prepare_result.get("status") in {"applied", "verified", "already_prepared"} or (prepare_result.get("status") == "blocked_target_collision" and payload.get("allow_existing_saved_state_target")): # Preparation may map active hash keys to an already proven # Configurator descriptor family. Use the exact mapped name # returned by that operation; retaining the active key here # silently bypasses the paired __configinfo writer. mapped_names = [ str(row.get("file_name") or "") for row in (prepare_result.get("source_rows") or []) if isinstance(row, dict) and str(row.get("source_file_name") or "") == module_file_name ] if len(mapped_names) == 1: prepared_ref = f"{target_table}:{mapped_names[0]}" + (f"#stream:{module_stream_index}" if module_stream_index is not None else "") write_payload["module_ref"] = prepared_ref write_payload.pop("module_id", None) receipt = (prepare_result.get("apply_result") or {}).get("prepare_receipt") if isinstance(prepare_result.get("apply_result"), dict) else {} if isinstance(receipt, dict) and receipt.get("receipt_id"): write_payload["_auto_prepare_receipt_id"] = str(receipt["receipt_id"]) return write_payload, {"method": "metadata.saved_state.prepare", "result": prepare_result} return { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "target_kind": "module", "base_id": payload.get("base_id"), "error": "saved_state_prepare_required", "diagnostics": {"message": "Module write target points to active storage. Prepare the saved-state layer first, then write to the saved-state module_ref."}, "next_resolution": { "method": "metadata.saved_state.prepare", "params": public_saved_state_prepare_call_payload(prepare_payload), }, "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), "prepared_target": {"layer": SAVED_STATE_LAYER_BY_TABLE[target_table]}, **({"prepared_module_ref": prepared_ref} if payload.get("include_storage") is True else {}), } if module_table in FORM_ELEMENT_SAVED_STATE_TABLES and module_file_name and module_stream_index is None and write_payload.get("stream_index") is None: return metadata_write_embedded_form_module_payload( write_payload, module_table=module_table, module_file_name=module_file_name, method=method, ) return write_payload, None # A pending extension stream can have a different saved-state key after an # edit. Ask the preparation resolver for the observed saved mapping first; # this is a live SQL read in plan mode and never creates rows here. extension_name = str(write_payload.get("extension") or write_payload.get("preferred_extension") or "").strip() if ( extension_name and bool(write_payload.get("form") or write_payload.get("form_name")) and any(write_payload.get(key) for key in ("ref", "object_type", "object_name", "kind", "name", "guid")) ): saved_probe_payload = { "base_id": payload.get("base_id"), "extension": extension_name, "target_table": "ConfigCASSave", "include_storage": True, "mode": "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "form", "form_name"): if write_payload.get(key) is not None: saved_probe_payload[key] = write_payload.get(key) saved_probe = metadata_saved_state_prepare(saved_probe_payload) saved_files = saved_probe.get("file_names") if isinstance(saved_probe.get("file_names"), list) else [] saved_module_files = [str(name) for name in saved_files if str(name).endswith(".0")] if saved_probe.get("status") in {"already_prepared", "applied", "verified"} and len(saved_module_files) == 1: saved_module_ref = f"ConfigCASSave:{saved_module_files[0]}" write_payload["module_ref"] = saved_module_ref return metadata_write_embedded_form_module_payload( write_payload, module_table="ConfigCASSave", module_file_name=saved_module_files[0], method=method, ) public_resolution_started = time.time() public_module_target, public_module_resolution = metadata_write_resolve_public_object_module_saved_ref(write_payload) resolution_timings["public_module_resolution"] = int((time.time() - public_resolution_started) * 1000) if public_module_target: write_payload["module_ref"] = str(public_module_target["module_ref"]) expected_text_sha1 = str(public_module_target.get("expected_text_sha1") or "").strip().lower() supplied_sha1 = str(write_payload.get("expected_sha1") or "").strip().lower() # Older callers commonly copied modules.read.content_sha1 into # expected_sha1. That is the BSL text hash, not the binary ConfigSave # payload hash. Preserve an actual payload guard unchanged, but move a # matching public text hash to the correct precondition automatically. if expected_text_sha1 and supplied_sha1 == expected_text_sha1: write_payload.pop("expected_sha1", None) write_payload.setdefault("expected_text_sha1", expected_text_sha1) elif not write_payload.get("expected_text_sha1") and expected_text_sha1: write_payload["expected_text_sha1"] = expected_text_sha1 return write_payload, {"method": "metadata.object.modules", "result": public_module_resolution, "target": public_module_target, "timings_ms": resolution_timings} if isinstance(public_module_resolution, dict) and public_module_resolution.get("error") == "extension_saved_state_prepare_protocol_unproven": return { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "target_kind": "module", "base_id": payload.get("base_id"), "error": "extension_saved_state_prepare_protocol_unproven", "diagnostics": public_module_resolution.get("diagnostics"), "resolution": { "status": "protocol_incomplete", "timings_ms": resolution_timings, }, } if isinstance(public_module_resolution, dict) and public_module_resolution.get("error") == "module_owner_unconfirmed": return { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "target_kind": "module", "base_id": payload.get("base_id"), "error": "module_owner_unconfirmed", "diagnostics": public_module_resolution.get("diagnostics"), "resolution": { "status": "unconfirmed", "method": "metadata.object.modules", "module_ordinal": public_module_resolution.get("module_ordinal"), }, } routine_operation = str(write_payload.get("routine_operation") or write_payload.get("operation") or "").strip().casefold() search_query = write_payload.get("query") or write_payload.get("expected_contains") or write_payload.get("old") if search_query is None and routine_operation not in {"append", "upsert", "append_routine", "upsert_routine"}: search_query = write_payload.get("routine_name") if search_query is None and not write_payload.get("routine_name"): search_query = write_payload.get("text") search_payload = { "base_id": payload.get("base_id"), "tables": write_payload.get("tables") or ([write_payload.get("table")] if write_payload.get("table") else None), "owner_guid": write_payload.get("owner_guid"), "object_type": write_payload.get("object_type"), "object_name": write_payload.get("object_name"), "object_guid": write_payload.get("object_guid"), "form": write_payload.get("form") or write_payload.get("form_name"), "extension": write_payload.get("extension") or write_payload.get("preferred_extension"), "kind": write_payload.get("kind"), "name": write_payload.get("name"), "guid": write_payload.get("guid"), "prefix": write_payload.get("prefix"), "file_name": write_payload.get("file_name"), "query": search_query, "stream_index": write_payload.get("stream_index"), "limit": int(write_payload.get("search_limit") or 10), "scan_limit": int(write_payload.get("scan_limit") or 1000), "preview_chars": int(write_payload.get("preview_chars") or 200), "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), "include_storage": True, } search_payload = {key: value for key, value in search_payload.items() if value is not None} search_started = time.time() search = metadata_saved_state_modules_search(search_payload) resolution_timings["saved_state_search"] = int((time.time() - search_started) * 1000) if search.get("status") not in {"ok", "needs_prepare"}: result = dict(search) result["method"] = method return result matches: list[tuple[dict[str, Any], dict[str, Any]]] = [] for module in search.get("modules") or []: if not isinstance(module, dict): continue for stream in module.get("streams") or []: if isinstance(stream, dict) and stream.get("module_ref"): matches.append((module, stream)) if len(matches) != 1: prepare_payload = { "base_id": payload.get("base_id"), "target_table": write_payload.get("target_table") or ("ConfigCASSave" if (write_payload.get("extension") or write_payload.get("preferred_extension")) else "ConfigSave"), "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } for key in ("extension", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "form", "form_name", "file_name", "file_names"): if write_payload.get(key) is not None: prepare_payload[key] = write_payload.get(key) if auto_prepare or payload.get("allow_sql_saved_state_prepare"): prepare_payload["allow_sql_saved_state_prepare"] = True prepare_plan = None if any(prepare_payload.get(key) for key in ("extension", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "file_name", "file_names")): prepare_started = time.time() prepare_plan = metadata_saved_state_prepare(prepare_payload) resolution_timings["saved_state_prepare"] = int((time.time() - prepare_started) * 1000) if prepare_plan.get("status") in {"applied", "verified"}: retry_search_started = time.time() # Preparation has just established the one authoritative # target layer. Do not rescan active storage on retry. retry_search = metadata_saved_state_modules_search({ **search_payload, "tables": [str(prepare_payload.get("target_table") or "")], }) resolution_timings["saved_state_retry_search"] = int((time.time() - retry_search_started) * 1000) retry_matches: list[tuple[dict[str, Any], dict[str, Any]]] = [] for module in retry_search.get("modules") or []: if not isinstance(module, dict): continue for stream in module.get("streams") or []: if isinstance(stream, dict) and stream.get("module_ref"): retry_matches.append((module, stream)) if len(retry_matches) == 1: module, stream = retry_matches[0] write_payload["module_ref"] = stream["module_ref"] receipt = (prepare_plan.get("apply_result") or {}).get("prepare_receipt") if isinstance(prepare_plan.get("apply_result"), dict) else {} if isinstance(receipt, dict) and receipt.get("receipt_id"): write_payload["_auto_prepare_receipt_id"] = str(receipt["receipt_id"]) if not write_payload.get("expected_sha1") and isinstance(module.get("payload"), dict) and module["payload"].get("sha1"): write_payload["expected_sha1"] = module["payload"]["sha1"] return write_payload, {"method": "metadata.saved_state.prepare", "result": prepare_plan, "retry_search": retry_search, "timings_ms": resolution_timings} # A generic retry can still be empty when the saved overlay # uses an object-specific key layout. Resolve through the # observed active-to-saved mapping before reporting a public # target failure. prepared_public_target, prepared_public_resolution = metadata_write_resolve_public_object_module_saved_ref(write_payload) if prepared_public_target: write_payload["module_ref"] = str(prepared_public_target["module_ref"]) receipt = (prepare_plan.get("apply_result") or {}).get("prepare_receipt") if isinstance(prepare_plan.get("apply_result"), dict) else {} if isinstance(receipt, dict) and receipt.get("receipt_id"): write_payload["_auto_prepare_receipt_id"] = str(receipt["receipt_id"]) expected_text_sha1 = str(prepared_public_target.get("expected_text_sha1") or "").strip().lower() supplied_sha1 = str(write_payload.get("expected_sha1") or "").strip().lower() if expected_text_sha1 and supplied_sha1 == expected_text_sha1: write_payload.pop("expected_sha1", None) write_payload.setdefault("expected_text_sha1", expected_text_sha1) elif expected_text_sha1 and not write_payload.get("expected_text_sha1"): write_payload["expected_text_sha1"] = expected_text_sha1 return write_payload, { "method": "metadata.saved_state.prepare", "result": prepare_plan, "retry_search": retry_search, "public_module_resolution": prepared_public_resolution, "timings_ms": resolution_timings, } return { "schema": "onec_metadata_write.v1", "method": method, "status": "needs_prepare" if search.get("status") == "needs_prepare" and not matches else ("not_found" if not matches else "ambiguous"), "target_kind": "module", "base_id": payload.get("base_id"), "error": "exact_form_module_not_prepared" if search.get("status") == "needs_prepare" and not matches else "module_target_not_resolved", "diagnostics": {"message": "The exact form module is not prepared in saved-state yet; prepare its active route before writing."} if search.get("status") == "needs_prepare" and not matches else {"message": "Module write requires exactly one saved-state module stream. Pass module_ref, prepare saved-state, or narrow owner_guid/file_name/query/stream_index."}, "search": search, **( { "next_resolution": { "method": "metadata.saved_state.prepare", "params": public_saved_state_prepare_call_payload(prepare_payload), }, "prepare_plan": public_saved_state_prepare_embedded_result(prepare_plan, prepare_payload), } if prepare_plan is not None else {} ), "counts": {"stream_matches": len(matches)}, } module, stream = matches[0] write_payload["module_ref"] = stream["module_ref"] if stream.get("module_path") and not write_payload.get("module_path"): write_payload["module_path"] = stream.get("module_path") if not write_payload.get("expected_sha1") and isinstance(module.get("payload"), dict) and module["payload"].get("sha1"): write_payload["expected_sha1"] = module["payload"]["sha1"] resolved_table, resolved_file_name, resolved_stream_index = parse_module_id(str(write_payload.get("module_ref") or "")) if resolved_table in FORM_ELEMENT_SAVED_STATE_TABLES and resolved_file_name and resolved_stream_index is None and write_payload.get("stream_index") is None: return metadata_write_embedded_form_module_payload( write_payload, module_table=resolved_table, module_file_name=resolved_file_name, method=method, ) return write_payload, search def metadata_write_prepare_payload( payload: dict[str, Any], write_payload: dict[str, Any], *, target_table: str, mode: str, auto_prepare: bool, ) -> dict[str, Any]: prepare_payload = { "base_id": payload.get("base_id"), "target_table": target_table, "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } for key in ("extension", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "form", "form_name", "file_name", "file_names", "module_ref", "module_id"): if write_payload.get(key) is not None: prepare_payload[key] = write_payload.get(key) if not any(prepare_payload.get(key) for key in ("query", "name", "object_name", "guid", "object_guid")): for key in ("form", "form_name"): if write_payload.get(key): prepare_payload["query"] = write_payload.get(key) break if auto_prepare or payload.get("allow_sql_saved_state_prepare"): prepare_payload["allow_sql_saved_state_prepare"] = True return prepare_payload def metadata_write_resolve_form_target(payload: dict[str, Any], target: dict[str, Any], mode: str) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any] | None]: method = METADATA_WRITE_METHOD strict_working_target = isinstance(payload.get("write_target"), dict) auto_prepare = mode != "plan" and (strict_working_target or payload.get("allow_sql_saved_state_apply") is True) and payload.get("auto_prepare_saved_state") is not False write_payload = dict(payload) for key, value in target.items(): if key in {"kind", "area"}: continue write_payload.setdefault(key, value) if "target" in write_payload: write_payload.pop("target", None) if str(write_payload.get("kind") or "").strip().casefold() in {"form", "форма"}: write_payload.pop("kind", None) if str(write_payload.get("area") or "").strip().casefold() in {"form", "форма"}: write_payload.pop("area", None) write_payload["execution_mode"] = mode write_payload["allow_saved_state_write"] = True table = str(write_payload.get("table") or "").strip() if not table: write_payload["table"] = "ConfigCASSave" if (write_payload.get("extension") or write_payload.get("preferred_extension")) else "ConfigSave" return write_payload, None if table in FORM_ELEMENT_SAVED_STATE_TABLES: return write_payload, None if table not in SAVED_STATE_TARGET_BY_SOURCE: return invalid_argument(method, "table", "Only Config/ConfigCAS can be auto-prepared for form writes; direct writes still target ConfigSave/ConfigCASSave.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES | set(SAVED_STATE_TARGET_BY_SOURCE))) target_table = SAVED_STATE_TARGET_BY_SOURCE[table] write_payload["table"] = target_table prepare_payload = metadata_write_prepare_payload(payload, {**write_payload, "table": target_table}, target_table=target_table, mode=mode, auto_prepare=auto_prepare) prepare_payload["source_table"] = table prepare_result = metadata_saved_state_prepare(prepare_payload) if prepare_result.get("status") in {"applied", "verified"} or (prepare_result.get("status") == "blocked_target_collision" and payload.get("allow_existing_saved_state_target")): return write_payload, {"method": "metadata.saved_state.prepare", "result": prepare_result} return { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "target_kind": "form", "base_id": payload.get("base_id"), "error": "saved_state_prepare_required", "diagnostics": {"message": "Form write target points to active storage. Prepare the saved-state layer first, then write to the saved-state form payload."}, "next_resolution": { "method": "metadata.saved_state.prepare", "params": public_saved_state_prepare_call_payload(prepare_payload), }, "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), "prepared_target": ( {"table": target_table, **({"file_name": write_payload.get("file_name")} if write_payload.get("file_name") else {})} if payload.get("include_storage") is True else {"layer": SAVED_STATE_LAYER_BY_TABLE[target_table]} ), } def metadata_write_form_retry_after_prepare(payload: dict[str, Any], write_payload: dict[str, Any], mode: str, result: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: if result.get("status") not in {"not_found", "source_missing"}: return result, None auto_prepare = mode != "plan" and payload.get("allow_sql_saved_state_apply") is True and payload.get("auto_prepare_saved_state") is not False target_table = str(write_payload.get("table") or ("ConfigCASSave" if (write_payload.get("extension") or write_payload.get("preferred_extension")) else "ConfigSave")) if target_table not in FORM_ELEMENT_SAVED_STATE_TABLES: return result, None prepare_payload = metadata_write_prepare_payload(payload, write_payload, target_table=target_table, mode=mode, auto_prepare=auto_prepare) has_prepare_selector = any(prepare_payload.get(key) for key in ("extension", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "file_name", "file_names", "query")) if not has_prepare_selector: return result, None prepare_result = metadata_saved_state_prepare(prepare_payload) if prepare_result.get("status") in {"applied", "verified"}: retry_result = metadata_form_element_write_apply(write_payload) return retry_result, {"method": "metadata.saved_state.prepare", "result": prepare_result, "retry_status": retry_result.get("status")} enriched = dict(result) enriched.setdefault( "next_resolution", { "method": "metadata.saved_state.prepare", "params": public_saved_state_prepare_call_payload(prepare_payload), }, ) enriched.setdefault("prepare_plan", public_saved_state_prepare_embedded_result(prepare_result, prepare_payload)) return enriched, {"method": "metadata.saved_state.prepare", "result": prepare_result} def metadata_write_plan_path_parts(path: str) -> dict[str, Any]: parsed = parse_1c_object_path(path) parts = parsed["parts"] result: dict[str, Any] = { "input": path, "parts": parts, "is_full_path": False, "path_kind": "unknown", } if len(parts) < 2: result["reason"] = "local_or_short_name" return result kind = parsed.get("kind") name = str(parsed.get("name") or "") if not parsed.get("recognized_root") or not kind or not name: result["reason"] = "unknown_object_kind" return result member_path = list(parsed.get("member_path") or []) path_kind = "metadata_object" if not member_path else "metadata_member" section = member_path[0] if member_path else None section_class = normalize(section or "") extra: dict[str, Any] = {} if section_class in {normalize("Форма"), normalize("Формы"), "form", "forms"}: path_kind = "form_member" if len(member_path) > 2 else "form" extra["section"] = "form" if len(member_path) > 1: extra["form_name"] = member_path[1] if len(member_path) > 2: extra["form_member_path"] = member_path[2:] if normalize(member_path[2]) in {normalize("МодульФормы"), normalize("Модуль формы"), "formmodule", "form_module"}: path_kind = "form_module" extra["section"] = "form_module" elif section_class in {normalize("Модуль"), normalize("Модули"), "module", "modules"}: path_kind = "module_routine" if len(member_path) > 2 else "module" extra["section"] = "module" if len(member_path) > 1: extra["module_name"] = member_path[1] if len(member_path) > 2: extra["routine_name"] = member_path[-1] elif kind == "CommonModule" and member_path: path_kind = "module_routine" extra["section"] = "module" extra["module_name"] = name extra["routine_name"] = member_path[-1] elif kind == "CommonForm" and member_path: common_form_section = normalize(member_path[0]) if common_form_section in {normalize("Команда"), normalize("Команды"), "command", "commands"}: path_kind = "form_command" extra["section"] = "form_command" extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["command_name"] = member_path[-1] elif common_form_section in {normalize("Кнопка"), normalize("Кнопки"), normalize("Элемент"), normalize("Элементы"), "button", "buttons", "element", "elements"}: path_kind = "form_element" extra["section"] = "form_element" extra["form_member_kind"] = "button" if common_form_section in {normalize("Кнопка"), normalize("Кнопки"), "button", "buttons"} else "element" extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["element_name"] = member_path[-1] elif common_form_section in {normalize("Атрибут"), normalize("Атрибуты"), normalize("Реквизит"), normalize("Реквизиты"), "attribute", "attributes"}: path_kind = "form_attribute" extra["section"] = "form_attribute" extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["attribute_name"] = member_path[-1] else: path_kind = "module_routine" extra["section"] = "form_module" extra["form_name"] = name extra["routine_name"] = member_path[-1] result.update( { "is_full_path": True, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "name": name, "canonical_path": ".".join([RU_KIND.get(kind, kind), name, *member_path]), "code_path": ".".join([ONEC_CODE_ROOT_BY_KIND.get(kind, RU_KIND.get(kind, kind)), name, *member_path]), "member_path": member_path, "path_kind": path_kind, **extra, } ) if len(member_path) >= 2: result["context_path"] = ".".join(member_path) return result def metadata_write_plan_operation(intent: dict[str, Any], payload: dict[str, Any]) -> str: routine = payload.get("routine") if isinstance(payload.get("routine"), dict) else intent.get("routine") routine_operation = routine.get("operation") if isinstance(routine, dict) else None raw = ( intent.get("operation") or payload.get("operation") or payload.get("routine_operation") or routine_operation or ("property_change" if (intent.get("property") or payload.get("property") or payload.get("edits")) else "") or ("replace" if (payload.get("old") is not None and payload.get("new") is not None) else "") or ("replace" if isinstance(payload.get("replace"), dict) else "") or ("replace" if payload.get("text") is not None else "") or "unknown" ) return str(raw).strip().casefold() def metadata_write_plan_extension_action(payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any]) -> dict[str, Any] | None: for source in (intent, target, payload): action = source.get("extension_action") if isinstance(source, dict) else None if isinstance(action, dict): return action actions = payload.get("extension_actions") if isinstance(actions, list) and len(actions) == 1 and isinstance(actions[0], dict): return actions[0] return None def metadata_write_plan_extension_actions(payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any]) -> list[dict[str, Any]]: for source in (intent, target, payload): actions = source.get("extension_actions") if isinstance(source, dict) else None if isinstance(actions, list): return [item for item in actions if isinstance(item, dict)] return [] def metadata_write_plan_infer_target_kind(path_resolution: dict[str, Any], explicit_kind: str) -> str: normalized_explicit = str(explicit_kind or "").strip().casefold() if normalized_explicit not in {"", "metadata", "метаданные"}: return normalized_explicit path_kind = str(path_resolution.get("path_kind") or "") if path_kind.startswith("form"): return "form" if path_kind.startswith("module"): return "module" return normalized_explicit or "metadata" def metadata_write_plan_operation_class(operation: str) -> str: normalized = re.sub(r"[\s._-]+", " ", str(operation or "").strip().casefold()) aliases = { "replace with control": "replace_with_control", "replace with check": "replace_with_control", "вместо с контролем": "replace_with_control", "заменить с контролем": "replace_with_control", "замена с контролем": "replace_with_control", "replace": "replace", "вместо": "replace", "заменить": "replace", "замена": "replace", "insert before": "insert_before", "before": "insert_before", "вставить до": "insert_before", "вставка до": "insert_before", "до": "insert_before", "insert after": "insert_after", "after": "insert_after", "вставить после": "insert_after", "вставка после": "insert_after", "после": "insert_after", "append routine": "append_routine", "append": "append_routine", "добавить процедуру": "append_routine", "добавить функцию": "append_routine", "upsert routine": "upsert_routine", "upsert": "upsert_routine", "add": "add", "добавить": "add", "property change": "property_change", "property_change": "property_change", "изменить свойство": "property_change", "move form item": "move_form_item", "move": "move_form_item", "переместить": "move_form_item", } return aliases.get(normalized, str(operation or "").strip().casefold()) def metadata_write_plan_layer_class(value: str) -> str: normalized = re.sub(r"[\s._-]+", " ", str(value or "").strip().casefold()) aliases = { "": "auto", "auto": "auto", "авто": "auto", "base": "base", "configuration": "base", "config": "base", "конфигурация": "base", "основная конфигурация": "base", "основная": "base", "extension": "extension", "extensions": "extension", "расширение": "extension", "расширения": "extension", "generated extension source": "generated_extension_source", "generated extension": "generated_extension_source", "сгенерированное расширение": "generated_extension_source", } return aliases.get(normalized, normalized.replace(" ", "_")) def metadata_write_plan_extension_action_problem( extension_action: dict[str, Any] | None, operation_class: str, operation_was_inferred: bool, ) -> dict[str, Any] | None: if not extension_action: return None action_operation = metadata_write_plan_operation_class(str(extension_action.get("operation_class") or extension_action.get("operation") or "")) if str(extension_action.get("status") or "").strip().casefold() == "unknown" or action_operation in {"", "unknown_extension_action"}: return { "code": "extension_action_unknown", "message": "Extension routine action is not resolved. Resolve whether it is insert_before, insert_after, replace, or replace_with_control before planning a code write.", "extension_action": extension_action, } if action_operation in {"base_definition", "extension_definition"}: return None if action_operation in {"insert_before", "insert_after", "replace", "replace_with_control"} and not operation_was_inferred and operation_class != action_operation: return { "code": "extension_action_operation_mismatch", "message": "Requested code operation does not match the extension action evidence. Preserve insert_before, insert_after, replace, or replace_with_control semantics.", "requested_operation": operation_class, "extension_operation": action_operation, "extension_action": extension_action, } return None def metadata_write_plan_extension_actions_problem(extension_actions: list[dict[str, Any]]) -> dict[str, Any] | None: if len(extension_actions) <= 1: return None return { "code": "extension_action_ambiguous", "message": "Multiple extension routine actions were provided. Narrow the extension/module before planning a code write.", "extension_actions": extension_actions, } def metadata_write_plan_required_guards(target_kind: str, operation: str) -> list[str]: guards = ["canonical_path_or_concrete_reference", "layer_provenance", "semantic_diff", "semantic_readback"] operation_class = metadata_write_plan_operation_class(operation) if target_kind == "module": guards.extend(["expected_sha1", "expected_old_text_or_guard_fragment", "bsl_syntax_check"]) if operation_class == "replace_with_control": guards.append("controlled_fragment_matches_current_source") elif target_kind == "form": guards.extend(["saved_state_sha1", "property_registry_resolution"]) else: guards.extend(["origin_read", "extension_conflict_scan"]) return list(dict.fromkeys(guards)) def metadata_write_plan_first_value(payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any], *names: str) -> Any: for source in (intent, target, payload): for name in names: if isinstance(source, dict) and source.get(name) not in (None, ""): return source.get(name) return None def metadata_write_plan_code_precondition_problems( payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any], *, target_kind: str, operation: str, ) -> list[dict[str, Any]]: if target_kind != "module": return [] normalized_operation = metadata_write_plan_operation_class(operation) problems: list[dict[str, Any]] = [] has_expected_stream_guard = metadata_write_plan_first_value(payload, target, intent, "expected_contains", "expected_sha1") is not None has_expected_routine_guard = metadata_write_plan_first_value(payload, target, intent, "expected_old_contains", "expected_old_sha1") is not None has_old_fragment = metadata_write_plan_first_value(payload, target, intent, "old") is not None has_new_fragment = metadata_write_plan_first_value(payload, target, intent, "new", "text", "routine_text") is not None has_anchor = metadata_write_plan_first_value(payload, target, intent, "anchor", "before", "after", "expected_contains") is not None control_fragment_value = metadata_write_plan_first_value( payload, target, intent, "control_fragment", "controlled_fragment", "expected_old_contains", ) has_control_fragment = control_fragment_value is not None current_text_value = metadata_write_plan_first_value( payload, target, intent, "current_text", "current_source", "source_text", "current_module_text", "module_text", ) if normalized_operation == "replace_with_control" and not has_control_fragment: problems.append( { "code": "missing_control_fragment", "message": "replace_with_control requires control_fragment, controlled_fragment, or expected_old_contains.", } ) if ( normalized_operation == "replace_with_control" and has_control_fragment and current_text_value is not None and str(control_fragment_value or "") not in str(current_text_value or "") ): problems.append( { "code": "control_fragment_drift", "message": "replace_with_control control fragment does not match the provided current source evidence.", } ) if normalized_operation in {"replace", "replace_with_control"}: if not (has_old_fragment or has_control_fragment or has_expected_routine_guard or has_expected_stream_guard): problems.append( { "code": "missing_expected_old_guard", "message": "Code replacement requires old, expected_old_contains, expected_old_sha1, expected_contains, or expected_sha1.", } ) if not has_new_fragment: problems.append( { "code": "missing_new_code", "message": "Code replacement requires new code through new, text, or routine_text.", } ) if normalized_operation in {"insert_before", "insert_after"} and not has_anchor: problems.append( { "code": "missing_insert_anchor", "message": "Code insertion requires an anchor through anchor, before, after, or expected_contains.", } ) return problems def metadata_write_plan_apply_payload_hint( payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any], *, path_resolution: dict[str, Any], target_kind: str, operation_class: str, concrete_reference: str, ) -> dict[str, Any] | None: concrete_reference_info = metadata_write_concrete_reference_info(payload, target) selector_kind = str( path_resolution.get("kind") or target.get("kind") or target.get("object_type") or payload.get("kind") or payload.get("object_type") or "" ).strip() selector_name = str( path_resolution.get("name") or target.get("name") or target.get("object_name") or payload.get("name") or payload.get("object_name") or "" ).strip() selector_ref = str(target.get("ref") or payload.get("ref") or object_selector_ref(selector_kind, selector_name) or "").strip() public_resolution_params: dict[str, Any] = { "base_id": payload.get("base_id"), **({"ref": selector_ref} if selector_ref and not is_guid_text(selector_ref) else {}), **({"kind": canonical_kind(selector_kind)} if selector_kind else {}), **({"name": selector_name} if selector_name else {}), **( {"extension": target.get("extension") or payload.get("extension")} if isinstance(target.get("extension") or payload.get("extension"), str) and (target.get("extension") or payload.get("extension")) else {} ), } if target_kind == "module": hint: dict[str, Any] = { "method": MODULE_WRITE_APPLY_METHOD, "payload": { "base_id": payload.get("base_id"), "allow_saved_state_write": True, "mode": "plan", }, } if concrete_reference: reference_field = concrete_reference_info.get("field") or "module_ref" if reference_field in {"module_ref", "module_id", "file_name"}: hint["payload"][reference_field] = concrete_reference else: hint["payload"]["module_ref"] = concrete_reference hint["ready_for_apply_method"] = True elif path_resolution.get("kind") and path_resolution.get("name"): hint["payload"]["kind"] = path_resolution.get("kind") hint["payload"]["name"] = path_resolution.get("name") hint["ready_for_apply_method"] = False hint["next_resolution"] = { "method": SAVED_STATE_MODULES_SEARCH_METHOD, "reason": "metadata.module.write_apply requires module_ref or saved-state file_name/stream_index.", "params": { **public_resolution_params, **({"query": path_resolution.get("routine_name")} if path_resolution.get("routine_name") else {}), }, } if path_resolution.get("routine_name") and "routine_name" not in hint["payload"]: hint["payload"]["routine_name"] = path_resolution.get("routine_name") for field in ( "expected_sha1", "expected_text_sha1", "expected_contains", "expected_old_sha1", "expected_old_contains", "table", "stream_index", "old", "new", "text", "routine_name", "routine_text", ): value = metadata_write_plan_first_value(payload, target, intent, field) if value is not None: hint["payload"][field] = value if hint.get("ready_for_apply_method") is True and not module_apply_payload_has_concrete_stream(hint["payload"]): hint["method"] = CODE_WRITE_METHOD hint["ready_for_apply_method"] = True hint["routing"] = { "reason": "container_module_requires_agent_code_write", "message": "The module is embedded in a container; code.write resolves and verifies the safe saved-state route.", } control_fragment = metadata_write_plan_first_value(payload, target, intent, "control_fragment", "controlled_fragment") if operation_class == "replace_with_control" and control_fragment is not None and "expected_old_contains" not in hint["payload"]: hint["payload"]["expected_old_contains"] = control_fragment if operation_class in {"insert_before", "insert_after"}: hint["payload"]["operation"] = operation_class anchor = metadata_write_plan_first_value(payload, target, intent, "anchor", "before", "after", "expected_contains") if anchor is not None and "expected_contains" not in hint["payload"]: hint["payload"]["expected_contains"] = anchor if operation_class in {"append_routine", "upsert_routine"} and "routine_operation" not in hint["payload"]: hint["payload"]["routine_operation"] = "append" if operation_class == "append_routine" else "upsert" return hint if target_kind == "form": form_member_path = path_resolution.get("form_member_path") if isinstance(path_resolution.get("form_member_path"), list) else [] resolution_element = path_resolution.get("element_name") or (form_member_path[-1] if form_member_path else None) hint = { "method": FORM_ELEMENT_WRITE_APPLY_METHOD, "payload": { "base_id": payload.get("base_id"), "allow_saved_state_write": True, "mode": "plan", }, } if concrete_reference: reference_field = concrete_reference_info.get("field") or "file_name" if reference_field in {"file_name", "form_guid"}: hint["payload"][reference_field] = concrete_reference else: hint["payload"]["file_name"] = concrete_reference hint["ready_for_apply_method"] = True elif path_resolution.get("kind") and path_resolution.get("name"): hint["payload"]["kind"] = path_resolution.get("kind") hint["payload"]["name"] = path_resolution.get("name") hint["ready_for_apply_method"] = False hint["next_resolution"] = { "method": FORM_WRITE_TARGET_RESOLVE_METHOD, "reason": "metadata.form.element.write_apply requires saved-state table/file/form target resolution.", "params": { **public_resolution_params, **({"form": path_resolution.get("form_name")} if path_resolution.get("form_name") else {}), **({"command": path_resolution.get("command_name")} if path_resolution.get("command_name") else {}), **({"element": resolution_element} if resolution_element else {}), **({"attribute": path_resolution.get("attribute_name")} if path_resolution.get("attribute_name") else {}), }, } if path_resolution.get("form_name"): hint["payload"]["form"] = path_resolution.get("form_name") if path_resolution.get("command_name"): hint["payload"]["command"] = path_resolution.get("command_name") elif path_resolution.get("element_name"): hint["payload"]["element"] = path_resolution.get("element_name") elif path_resolution.get("attribute_name"): hint["payload"]["attribute"] = path_resolution.get("attribute_name") elif form_member_path: hint["payload"]["element"] = form_member_path[-1] for field in ( "extension", "ref", "object_ref", "form", "form_name", "command", "element", "element_name", "attribute", "element_path", "element_id", "id", ): value = target.get(field) if target.get(field) not in (None, "") else payload.get(field) if value not in (None, ""): hint["payload"][field] = value edits = payload.get("edits") if isinstance(payload.get("edits"), list) else intent.get("edits") if isinstance(edits, list) and edits: hint["payload"]["edits"] = edits for field in ("table", "property", "value"): value = metadata_write_plan_first_value(payload, target, intent, field) if value is not None: hint["payload"][field] = value return hint return None def metadata_write_plan_origin_query(path_resolution: dict[str, Any], target_kind: str) -> dict[str, Any]: member_path = path_resolution.get("member_path") if isinstance(path_resolution.get("member_path"), list) else [] if member_path: areas = ["object", "extensions"] if target_kind == "module": areas = ["modules", "extensions"] elif target_kind == "form": areas = ["form", "extensions"] query = str(path_resolution.get("routine_name") or (path_resolution.get("form_member_path") or [None])[-1] or member_path[-1] or "") else: areas = ["metadata", "extensions"] query = str(path_resolution.get("canonical_path") or path_resolution.get("name") or "") return { "query": query, "kind": path_resolution.get("kind"), "name": path_resolution.get("name"), "areas": areas, "exact_only": True, } def metadata_write_plan_compact_origin_lookup(result: dict[str, Any]) -> dict[str, Any]: matches = [] for item in result.get("matches") or []: if not isinstance(item, dict): continue matches.append( { "area": item.get("area"), "kind": item.get("kind"), "name": item.get("name"), "synonym": item.get("synonym"), "match_by": item.get("match_by"), "location": item.get("location"), "origin": item.get("origin"), "read_selector": item.get("read_selector"), } ) if len(matches) >= 5: break related_selectors = result.get("related_selectors") if isinstance(result.get("related_selectors"), dict) else {} return { "method": "metadata.definition.find", "status": result.get("status"), "object": result.get("object"), "matches": matches, "related_selectors": related_selectors, "counts": result.get("counts"), "diagnostics": result.get("diagnostics") or [], } def metadata_write_plan_surface_from_origin(origin_lookup: dict[str, Any] | None, target_kind: str) -> dict[str, Any]: if not origin_lookup: return {"write_surface": "requires_origin_lookup", "status": "unknown", "reason": "origin_lookup_missing"} if origin_lookup.get("status") != "ok": return {"write_surface": "blocked_unknown", "status": "blocked", "reason": "origin_not_found"} layer_keys = set() extension_names = set() unresolved = 0 for match in origin_lookup.get("matches") or []: if not isinstance(match, dict): continue origin = match.get("origin") if isinstance(match.get("origin"), dict) else {} source = str(origin.get("source") or "").strip().casefold() status = str(origin.get("status") or "").strip().casefold() if source in {"configuration", "base"}: layer_keys.add("configuration") elif source == "extension": extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} extension_name = str(extension.get("name") or extension.get("guid") or "").strip() layer_keys.add(f"extension:{extension_name or 'unknown'}") if extension_name: extension_names.add(extension_name) else: unresolved += 1 elif source == "saved_state": layer_keys.add("saved_state") else: unresolved += 1 if status and status not in {"ok"}: unresolved += 1 if not layer_keys: return {"write_surface": "blocked_unknown", "status": "blocked", "reason": "origin_layer_not_resolved", "unresolved": unresolved} if len(layer_keys) > 1: return { "write_surface": "blocked_conflict", "status": "blocked", "reason": "multiple_origin_layers", "layers": sorted(layer_keys), "extensions": sorted(extension_names), "unresolved": unresolved, } layer = next(iter(layer_keys)) if layer == "configuration": return {"write_surface": "base_saved_state", "status": "recommended", "reason": "configuration_origin", "table": "ConfigSave"} if layer.startswith("extension:"): extension_name = layer.split(":", 1)[1] if extension_name == "unknown": return {"write_surface": "blocked_unknown", "status": "blocked", "reason": "extension_owner_unresolved", "unresolved": unresolved} return { "write_surface": "extension_saved_state", "status": "recommended", "reason": "extension_origin", "table": "ConfigCASSave", "extension": {"name_or_guid": extension_name}, } if layer == "saved_state": return {"write_surface": "saved_state", "status": "recommended", "reason": "already_saved_state_origin"} return {"write_surface": "blocked_unknown", "status": "blocked", "reason": "unsupported_origin_layer", "layers": sorted(layer_keys)} def metadata_write_plan_origin_lookup_from_evidence( origin: dict[str, Any], *, path_resolution: dict[str, Any], target_kind: str, ) -> dict[str, Any] | None: if not isinstance(origin, dict) or not origin: return None canonical_path = str(path_resolution.get("canonical_path") or path_resolution.get("input") or "").strip() parts = path_resolution.get("parts") if isinstance(path_resolution.get("parts"), list) else [] return { "method": "provided_origin_evidence", "status": "ok", "object": None, "matches": [ { "area": target_kind, "kind": parts[0] if parts else None, "name": parts[1] if len(parts) > 1 else None, "canonical_path": canonical_path or None, "location": {"presentation": canonical_path or None}, "origin": dict(origin), "read_selector": None, "match_by": "provided_origin", } ], "related_selectors": {}, "counts": {"matches": 1}, "diagnostics": [ { "message": "Origin evidence was provided by a prior read/search result; planner did not need to repeat metadata.definition.find for layer selection.", } ], } def metadata_write_plan_origin_ambiguity_problem(origin_lookup: dict[str, Any] | None) -> dict[str, Any] | None: if not origin_lookup or origin_lookup.get("status") != "ok": return None matches = [item for item in (origin_lookup.get("matches") or []) if isinstance(item, dict)] if len(matches) <= 1: return None locations = [] for item in matches[:5]: location = item.get("location") if isinstance(item.get("location"), dict) else {} locations.append( { "area": item.get("area"), "kind": item.get("kind"), "name": item.get("name"), "presentation": location.get("presentation"), "origin": item.get("origin"), } ) return { "code": "ambiguous_origin_matches", "message": "The target path resolved to multiple definitions. Narrow the object/form/module/routine or selector before planning a write.", "match_count": len(matches), "candidates": locations, } def metadata_write_plan_preferred_layer_problem(preferred_layer: str, recommended_write: dict[str, Any] | None) -> dict[str, Any] | None: layer = metadata_write_plan_layer_class(preferred_layer) if layer == "auto" or not recommended_write: return None surface = str(recommended_write.get("write_surface") or "") if surface == "base_saved_state": recommended_layer = "base" elif surface == "extension_saved_state": recommended_layer = "extension" elif surface == "saved_state": recommended_layer = "saved_state" elif surface in {"blocked_conflict", "blocked_unknown"}: return None else: recommended_layer = surface if layer == recommended_layer: return None if layer == "generated_extension_source" and recommended_layer == "extension": return None return { "code": "preferred_layer_conflict", "message": "Requested preferred_layer does not match the resolved origin layer.", "preferred_layer": layer, "recommended_layer": recommended_layer, "recommended_write_surface": surface, } def metadata_write_plan_preferred_extension_problem(preferred_extension: str, recommended_write: dict[str, Any] | None) -> dict[str, Any] | None: wanted = str(preferred_extension or "").strip() if not wanted or not recommended_write: return None if str(recommended_write.get("write_surface") or "") != "extension_saved_state": return { "code": "preferred_extension_without_extension_origin", "message": "preferred_extension was requested, but the resolved origin is not a single extension.", "preferred_extension": wanted, "recommended_write_surface": recommended_write.get("write_surface"), } extension = recommended_write.get("extension") if isinstance(recommended_write.get("extension"), dict) else {} actual = str(extension.get("name_or_guid") or "").strip() if actual and normalize(actual) == normalize(wanted): return None return { "code": "preferred_extension_conflict", "message": "Requested preferred_extension does not match the resolved extension owner.", "preferred_extension": wanted, "recommended_extension": actual or None, } def metadata_write_concrete_reference(payload: dict[str, Any], target: dict[str, Any]) -> str: reference = metadata_write_concrete_reference_info(payload, target) return str(reference.get("value") or "").strip() if reference else "" def metadata_write_concrete_reference_info(payload: dict[str, Any], target: dict[str, Any]) -> dict[str, str]: for source_name, source in (("target", target), ("payload", payload)): if not isinstance(source, dict): continue for field in ("module_ref", "module_id", "concrete_reference", "file_name", "form_guid"): value = str(source.get(field) or "").strip() if value: return {"source": source_name, "field": field, "value": value} return {} def metadata_write_plan_apply_hint(plan: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(plan, dict): return None route = plan.get("route") if isinstance(plan.get("route"), dict) else {} hint = route.get("apply_payload_hint") if isinstance(route.get("apply_payload_hint"), dict) else None return hint if isinstance(hint, dict) else None def module_ref_has_stream_index(module_ref: str) -> bool: table, file_name, stream_index = parse_module_id(module_ref) return bool(table and file_name and stream_index is not None) def module_apply_payload_has_concrete_stream(payload: dict[str, Any]) -> bool: module_ref = str(payload.get("module_ref") or payload.get("module_id") or payload.get("concrete_reference") or "").strip() if module_ref and module_ref_has_stream_index(module_ref): return True return payload.get("stream_index") is not None def metadata_write_apply_hint_payload(plan: dict[str, Any] | None) -> dict[str, Any]: hint = metadata_write_plan_apply_hint(plan) if not hint: return {} hint_payload = hint.get("payload") if isinstance(hint.get("payload"), dict) else {} return dict(hint_payload) def metadata_write_plan_target_kind(plan: dict[str, Any] | None) -> str: if not isinstance(plan, dict): return "" target = plan.get("target") if isinstance(plan.get("target"), dict) else {} return str(target.get("target_kind") or "").strip().casefold() def metadata_write_concrete_reference_problem(reference_info: dict[str, str], target_kind: str) -> dict[str, Any] | None: field = str(reference_info.get("field") or "").strip() if not field: return None allowed_by_kind = { "module": {"module_ref", "module_id", "concrete_reference", "file_name"}, "form": {"file_name", "form_guid"}, } allowed = allowed_by_kind.get(str(target_kind or "").strip().casefold()) if not allowed or field in allowed: return None return { "code": "concrete_reference_kind_mismatch", "message": "Concrete saved-state reference field does not match target_kind.", "target_kind": target_kind, "concrete_reference_field": field, "allowed_fields": sorted(allowed), } def metadata_write_plan_resolve_public_module_target( payload: dict[str, Any], target: dict[str, Any], ) -> tuple[dict[str, Any], dict[str, Any]]: """Resolve a public 1C module selector to an internal saved-state handle.""" selector_ref = str(target.get("ref") or target.get("object_ref") or payload.get("ref") or payload.get("object_ref") or "").strip() selector_kind = str(target.get("object_kind") or target.get("object_type") or payload.get("object_kind") or payload.get("object_type") or "").strip() selector_name = str(target.get("object_name") or payload.get("object_name") or "").strip() requested_form = str(target.get("form") or target.get("form_name") or payload.get("form") or payload.get("form_name") or "").strip() requested_module = str(target.get("module") or target.get("module_name") or payload.get("module") or payload.get("module_name") or "").strip() requested_qualified_name = str(target.get("qualified_name") or payload.get("qualified_name") or "").strip() requested_ordinal_raw = target.get("stream_ordinal") if target.get("stream_ordinal") is not None else payload.get("stream_ordinal") try: requested_ordinal = int(requested_ordinal_raw) if requested_ordinal_raw is not None else None except (TypeError, ValueError): requested_ordinal = None public_selector = { **({"ref": selector_ref} if selector_ref else {}), **({"kind": canonical_kind(selector_kind)} if selector_kind else {}), **({"name": selector_name} if selector_name else {}), **({"form": requested_form} if requested_form else {}), **({"module": requested_module} if requested_module else {}), **({"qualified_name": requested_qualified_name} if requested_qualified_name else {}), **({"stream_ordinal": requested_ordinal} if requested_ordinal is not None else {}), } if not (selector_ref or (selector_kind and selector_name)): return target, { "status": "not_attempted", "error": "public_object_selector_missing", "selector": public_selector, } search_payload: dict[str, Any] = { "base_id": payload.get("base_id"), "include_storage": True, "limit": int(payload.get("search_limit") or 50), "scan_limit": int(payload.get("scan_limit") or 1000), "timeout_seconds": int(payload.get("timeout_seconds") or 60), } if selector_ref: search_payload["ref"] = selector_ref else: search_payload["object_type"] = selector_kind search_payload["object_name"] = selector_name for key in ("extension", "layer", "tables"): value = target.get(key) if target.get(key) is not None else payload.get(key) if value is not None: search_payload[key] = value search = metadata_saved_state_modules_search(search_payload) if search.get("status") != "ok": return target, { "status": "error", "error": "saved_state_module_search_failed", "selector": public_selector, "search_status": search.get("status"), } matches: list[tuple[dict[str, Any], dict[str, Any]]] = [] for module_row in search.get("modules") or []: if not isinstance(module_row, dict): continue row_owner = module_row.get("owner") if isinstance(module_row.get("owner"), dict) else {} row_form = module_row.get("form") if isinstance(module_row.get("form"), dict) else {} row_module = module_row.get("module") if isinstance(module_row.get("module"), dict) else {} row_qualified_name = str(module_row.get("qualified_name") or module_row.get("display_name") or "").strip() if requested_form and normalize(str(row_form.get("name") or "")) != normalize(requested_form): continue if requested_module and normalize(str(row_module.get("name") or "")) != normalize(requested_module): continue if requested_qualified_name and normalize(row_qualified_name) != normalize(requested_qualified_name): continue for stream in module_row.get("streams") or []: if not isinstance(stream, dict) or not stream.get("module_ref"): continue stream_form = stream.get("form") if isinstance(stream.get("form"), dict) else row_form stream_module = stream.get("module") if isinstance(stream.get("module"), dict) else row_module stream_qualified_name = str(stream.get("qualified_name") or stream.get("display_name") or row_qualified_name).strip() if requested_form and normalize(str(stream_form.get("name") or "")) != normalize(requested_form): continue if requested_module and normalize(str(stream_module.get("name") or "")) != normalize(requested_module): continue if requested_qualified_name and normalize(stream_qualified_name) != normalize(requested_qualified_name): continue matches.append((module_row, stream)) if requested_ordinal is not None: matches = [matches[requested_ordinal - 1]] if 1 <= requested_ordinal <= len(matches) else [] if len(matches) != 1: return target, { "status": "not_found" if not matches else "ambiguous", "error": "public_module_target_not_resolved", "selector": public_selector, "counts": {"matches": len(matches)}, } module_row, stream = matches[0] payload_meta = module_row.get("payload") if isinstance(module_row.get("payload"), dict) else {} resolved_target = { **target, "module_ref": stream.get("module_ref"), **({"module_path": stream.get("module_path")} if stream.get("module_path") else {}), **({"expected_sha1": payload_meta.get("sha1")} if payload_meta.get("sha1") else {}), } return resolved_target, { "status": "resolved", "method": SAVED_STATE_MODULES_SEARCH_METHOD, "selector": public_selector, "counts": {"matches": 1}, } def metadata_write_plan_resolve_public_form_target( payload: dict[str, Any], target: dict[str, Any], intent: dict[str, Any], ) -> tuple[dict[str, Any], dict[str, Any]]: """Resolve a public 1C form selector to an internal saved-state handle.""" selector_ref = str(target.get("ref") or target.get("object_ref") or payload.get("ref") or payload.get("object_ref") or "").strip() selector_kind = str( target.get("object_kind") or target.get("object_type") or payload.get("object_kind") or payload.get("object_type") or "" ).strip() selector_name = str(target.get("object_name") or payload.get("object_name") or "").strip() form_name = str(target.get("form") or target.get("form_name") or payload.get("form") or payload.get("form_name") or "").strip() parsed_ref = parse_1c_object_path(selector_ref) if selector_ref else {} if not selector_kind and parsed_ref.get("kind"): selector_kind = str(parsed_ref.get("kind") or "") if not selector_name and parsed_ref.get("name"): selector_name = str(parsed_ref.get("name") or "") if not form_name and canonical_kind(selector_kind) == "CommonForm": form_name = selector_name extension = str(target.get("extension") or payload.get("extension") or "").strip() member_selector: dict[str, Any] = {} for key in ("command", "element", "element_name", "attribute", "element_path", "element_id", "id"): value = target.get(key) if target.get(key) not in (None, "") else payload.get(key) if value not in (None, ""): member_selector[key] = value public_selector = { **({"extension": extension} if extension else {}), **({"ref": selector_ref} if selector_ref else {}), **({"kind": canonical_kind(selector_kind)} if selector_kind and not selector_ref else {}), **({"name": selector_name} if selector_name and not selector_ref else {}), **({"form": form_name} if form_name else {}), **member_selector, } if not (selector_ref or (selector_kind and selector_name)): return target, { "status": "not_attempted", "error": "public_object_selector_missing", "selector": public_selector, } if not form_name and canonical_kind(selector_kind) != "CommonForm": return target, { "status": "not_attempted", "error": "public_form_selector_missing", "selector": public_selector, } first_edit = next( (item for item in payload.get("edits") or [] if isinstance(item, dict)), {}, ) property_name = metadata_write_plan_first_value(payload, target, intent, "property") if property_name in (None, ""): property_name = first_edit.get("property") value_present = any( isinstance(source, dict) and "value" in source for source in (intent, target, payload, first_edit) ) property_value = metadata_write_plan_first_value(payload, target, intent, "value") if "value" in first_edit and property_value is None: property_value = first_edit.get("value") resolve_payload: dict[str, Any] = { "base_id": payload.get("base_id"), "table": str(target.get("table") or payload.get("table") or ("ConfigCASSave" if extension else "ConfigSave")), "include_storage": True, "search_limit": int(payload.get("search_limit") or 10), "scan_limit": int(payload.get("scan_limit") or 1000), "timeout_seconds": int(payload.get("timeout_seconds") or 60), **public_selector, **({"property": property_name} if property_name not in (None, "") else {}), **({"value": property_value} if value_present else {}), } resolved = metadata_form_write_target_resolve(resolve_payload) if resolved.get("status") != "ok": return target, { "status": str(resolved.get("status") or "error"), "error": "public_form_target_not_resolved", "selector": public_selector, "resolution_status": resolved.get("status"), **({"counts": resolved.get("counts")} if isinstance(resolved.get("counts"), dict) else {}), } source = resolved.get("source") if isinstance(resolved.get("source"), dict) else {} file_name = str(source.get("file_name") or "").strip() table = str(source.get("table") or resolve_payload.get("table") or "").strip() if not file_name or table not in FORM_ELEMENT_SAVED_STATE_TABLES: return target, { "status": "error", "error": "public_form_target_storage_handle_missing", "selector": public_selector, } resolved_target = { **target, "table": table, "file_name": file_name, "_internal_resolved_handle": True, } return resolved_target, { "status": "resolved", "method": FORM_WRITE_TARGET_RESOLVE_METHOD, "selector": public_selector, "counts": {"matches": int((resolved.get("counts") or {}).get("matches") or 1)}, } def metadata_write_plan_normalize_public_target(payload: dict[str, Any], target: dict[str, Any]) -> dict[str, Any]: """Accept public write selectors both in target and at the RPC top level. The API documentation permits name-first selectors. Keep a concrete target authoritative, but do not discard a top-level ref/form/module from older agents: otherwise the public resolver is never entered and the plan incorrectly reports ``target_not_resolved``. """ normalized_target = dict(target) selector_fields = ( "ref", "object_ref", "object_kind", "object_type", "object_name", "form", "form_name", "module", "module_name", "qualified_name", "stream_ordinal", "extension", "layer", "tables", "command", "element", "element_name", "attribute", "element_path", "element_id", "id", ) for field in selector_fields: if normalized_target.get(field) in (None, "") and payload.get(field) not in (None, ""): normalized_target[field] = payload[field] return normalized_target def metadata_write_plan(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_PLAN_METHOD target = metadata_write_plan_normalize_public_target( payload, dict(payload.get("target")) if isinstance(payload.get("target"), dict) else {}, ) intent = payload.get("intent") if isinstance(payload.get("intent"), dict) else {} target_kind_raw = ( payload.get("target_kind") or payload.get("kind") or target.get("kind") or target.get("area") or payload.get("area") or "metadata" ) target_kind_hint = str(target_kind_raw or "").strip().casefold() module_name_resolution: dict[str, Any] | None = None form_name_resolution: dict[str, Any] | None = None has_public_module_selector = bool( target.get("ref") or target.get("object_ref") or ((target.get("object_kind") or target.get("object_type")) and target.get("object_name")) ) and bool(target.get("module") or target.get("module_name") or target.get("qualified_name")) if ( target_kind_hint in {"module", "модуль", "bsl"} and not metadata_write_concrete_reference_info(payload, target) and has_public_module_selector ): target, module_name_resolution = metadata_write_plan_resolve_public_module_target(payload, target) has_public_form_selector = bool( target.get("ref") or target.get("object_ref") or ((target.get("object_kind") or target.get("object_type")) and target.get("object_name")) ) and bool(target.get("form") or target.get("form_name") or target.get("command") or target.get("element") or target.get("attribute")) if ( target_kind_hint in {"form", "форма"} and not metadata_write_concrete_reference_info(payload, target) and has_public_form_selector ): target, form_name_resolution = metadata_write_plan_resolve_public_form_target(payload, target, intent) canonical_path = str( target.get("canonical_path") or payload.get("canonical_path") or target.get("path") or payload.get("path") or "" ).strip() concrete_reference_info = metadata_write_concrete_reference_info(payload, target) concrete_reference = str(concrete_reference_info.get("value") or "").strip() path_resolution = metadata_write_plan_path_parts(canonical_path) if canonical_path else { "input": "", "parts": [], "is_full_path": False, "path_kind": "concrete_reference" if concrete_reference else "unknown", "reason": "concrete_reference" if concrete_reference else "missing_target", } target_kind = metadata_write_plan_infer_target_kind(path_resolution, str(target_kind_raw or "metadata")) if target_kind in {"форма"}: target_kind = "form" elif target_kind in {"модуль", "bsl"}: target_kind = "module" elif target_kind in {"metadata", "метаданные", ""}: target_kind = "metadata" operation = metadata_write_plan_operation(intent, payload) operation_class = metadata_write_plan_operation_class(operation) operation_was_inferred_from_extension_action = False extension_actions = metadata_write_plan_extension_actions(payload, target, intent) extension_action = metadata_write_plan_extension_action(payload, target, intent) if extension_action and operation_class == "unknown": action_operation = metadata_write_plan_operation_class(str(extension_action.get("operation_class") or extension_action.get("operation") or "")) if action_operation not in {"", "unknown_extension_action", "base_definition", "extension_definition"}: operation = action_operation operation_class = action_operation operation_was_inferred_from_extension_action = True preferred_layer = str(payload.get("preferred_layer") or target.get("preferred_layer") or "auto").strip().casefold() preferred_layer = metadata_write_plan_layer_class(preferred_layer) preferred_extension = str(payload.get("preferred_extension") or target.get("preferred_extension") or target.get("extension") or payload.get("extension") or "").strip() resolve_origin = not (payload.get("resolve_origin") is False or str(payload.get("resolve_origin") or "").strip().casefold() in {"false", "0", "no", "off", "нет"}) provided_origin = target.get("origin") if isinstance(target.get("origin"), dict) else payload.get("origin") provided_origin = provided_origin if isinstance(provided_origin, dict) else None problems = [] if module_name_resolution and module_name_resolution.get("status") != "resolved": problems.append( { "code": str(module_name_resolution.get("error") or "public_module_target_not_resolved"), "message": "The public 1C module selector did not resolve to exactly one saved-state module stream.", "resolution_status": module_name_resolution.get("status"), } ) if form_name_resolution and form_name_resolution.get("status") != "resolved": problems.append( { "code": str(form_name_resolution.get("error") or "public_form_target_not_resolved"), "message": "The public 1C form selector did not resolve to exactly one saved-state form target.", "resolution_status": form_name_resolution.get("status"), } ) if not path_resolution.get("is_full_path") and not concrete_reference: problems.append( { "code": "target_not_resolved", "message": "Write planning requires a full 1C canonical path or a concrete saved-state/module reference.", } ) if canonical_path and not path_resolution.get("is_full_path"): problems.append( { "code": str(path_resolution.get("reason") or "invalid_canonical_path"), "message": "Target path is not a full 1C metadata path.", } ) if operation == "unknown": problems.append({"code": "operation_not_classified", "message": "Write intent operation is not classified."}) extension_actions_problem = metadata_write_plan_extension_actions_problem(extension_actions) if extension_actions_problem: problems.append(extension_actions_problem) extension_action_problem = metadata_write_plan_extension_action_problem( extension_action, operation_class, operation_was_inferred_from_extension_action, ) if extension_action_problem: problems.append(extension_action_problem) concrete_problem = metadata_write_concrete_reference_problem(concrete_reference_info, target_kind) if concrete_problem: problems.append(concrete_problem) problems.extend( metadata_write_plan_code_precondition_problems( payload, target, intent, target_kind=target_kind, operation=operation_class, ) ) origin_lookup = metadata_write_plan_origin_lookup_from_evidence( provided_origin or {}, path_resolution=path_resolution, target_kind=target_kind, ) if origin_lookup is None and resolve_origin and path_resolution.get("is_full_path"): origin_query = metadata_write_plan_origin_query(path_resolution, target_kind) try: origin_result = metadata_definition_find( { "base_id": payload.get("base_id"), **origin_query, "max_matches": int(payload.get("origin_max_matches") or 20), "timeout_seconds": int(payload.get("timeout_seconds") or 60), "include_storage": False, } ) origin_lookup = metadata_write_plan_compact_origin_lookup(origin_result) except Exception as exc: origin_lookup = { "method": "metadata.definition.find", "status": "error", "error": "origin_lookup_exception", "diagnostics": {"message": str(exc)}, } route = { "target_kind": target_kind, "operation": operation, "operation_class": operation_class, "preferred_layer": preferred_layer, "preferred_extension": preferred_extension or None, "write_surface": "saved_state" if concrete_reference else "requires_origin_lookup", "apply_method": None, } if module_name_resolution: route["name_resolution"] = module_name_resolution elif form_name_resolution: route["name_resolution"] = form_name_resolution if extension_action: route["extension_action"] = extension_action if operation_was_inferred_from_extension_action: route["operation_inferred_from"] = "extension_action" if extension_actions: route["extension_actions"] = extension_actions if target_kind == "form": route["apply_method"] = FORM_ELEMENT_WRITE_APPLY_METHOD elif target_kind == "module": route["apply_method"] = MODULE_WRITE_APPLY_METHOD elif target_kind == "metadata": route["apply_method"] = "extension_source_or_saved_state_metadata_writer" recommended_write = metadata_write_plan_surface_from_origin(origin_lookup, target_kind) if origin_lookup and origin_lookup.get("status") == "ok" else None if recommended_write: route["recommended_write"] = recommended_write ambiguity_problem = metadata_write_plan_origin_ambiguity_problem(origin_lookup) if ambiguity_problem: problems.append(ambiguity_problem) preferred_problem = metadata_write_plan_preferred_layer_problem(preferred_layer, recommended_write) if preferred_problem: problems.append(preferred_problem) preferred_extension_problem = metadata_write_plan_preferred_extension_problem(preferred_extension, recommended_write) if preferred_extension_problem: problems.append(preferred_extension_problem) apply_payload_hint = metadata_write_plan_apply_payload_hint( payload, target, intent, path_resolution=path_resolution, target_kind=target_kind, operation_class=operation_class, concrete_reference=concrete_reference, ) if apply_payload_hint: route["apply_payload_hint"] = apply_payload_hint if apply_payload_hint.get("method"): route["apply_method"] = apply_payload_hint.get("method") if concrete_reference and target_kind in {"form", "module"}: route["write_surface"] = "saved_state" allowed = not problems status = "planned" if allowed else "blocked" else: allowed = False status = "needs_route" if origin_lookup and origin_lookup.get("status") == "ok" else "needs_origin" if not any(problem.get("code") == "target_not_resolved" for problem in problems): if origin_lookup and origin_lookup.get("status") == "ok": problem_code = "write_route_required" problem_message = "Origin was found, but a concrete saved-state or extension-source write route is still required before apply." if recommended_write and str(recommended_write.get("status") or "") == "blocked": problem_code = str(recommended_write.get("write_surface") or "write_route_blocked") problem_message = "Origin was found, but the write route is blocked until the layer conflict or unresolved owner is handled." problems.append( { "code": problem_code, "message": problem_message, } ) else: problems.append( { "code": "origin_lookup_required", "message": "Effective targets are read-only until origin/layer evidence selects base, extension, or generated extension source.", } ) # SQL handles are implementation detail. The agent repeats only this # logical selector to metadata.write; storage is resolved internally. hide_internal_handle = bool(target.get("_internal_resolved_handle")) public_route = copy.deepcopy(route) public_hint = public_route.get("apply_payload_hint") if isinstance(public_route.get("apply_payload_hint"), dict) else None if hide_internal_handle and public_hint and isinstance(public_hint.get("payload"), dict): for internal_key in ("table", "file_name", "form_guid", "module_ref", "module_id", "stream_index"): public_hint["payload"].pop(internal_key, None) for logical_key in ("extension", "ref", "kind", "name", "form", "element", "attribute", "command", "edits"): logical_value = target.get(logical_key) if target.get(logical_key) not in (None, "") else payload.get(logical_key) if logical_value not in (None, "") and logical_key not in public_hint["payload"]: public_hint["payload"][logical_key] = logical_value public_hint["routing"] = {"mode": "logical_selector", "message": "The adapter resolves saved-state storage internally from this logical selector."} logical_target = { key: (target.get(key) if target.get(key) not in (None, "") else payload.get(key)) for key in ("extension", "ref", "kind", "name", "form", "element", "attribute", "command") if (target.get(key) if target.get(key) not in (None, "") else payload.get(key)) not in (None, "") } return { "schema": "onec_metadata_write_plan.v1", "method": method, "status": status, "allowed": allowed, "base_id": payload.get("base_id"), "target": { "canonical_path": path_resolution.get("canonical_path"), "input_path": canonical_path or None, "path_kind": path_resolution.get("path_kind"), "target_kind": target_kind, **({"logical_selector": logical_target} if hide_internal_handle else { "concrete_reference": concrete_reference or None, "concrete_reference_field": concrete_reference_info.get("field") or None, "concrete_reference_source": concrete_reference_info.get("source") or None, }), }, "path_resolution": path_resolution, **({"origin_lookup": origin_lookup} if origin_lookup is not None else {}), "route": public_route, "required_guards": metadata_write_plan_required_guards(target_kind, operation_class), "problems": problems, "diagnostics": { "read_only": True, "message": "metadata.write.plan does not apply changes. Use metadata.write only after this plan has a concrete saved-state or extension-source route.", }, } def metadata_write_apply_plan_gate( routed_method: str, payload: dict[str, Any], *, target_kind: str, target: dict[str, Any], ) -> tuple[dict[str, Any], dict[str, Any] | None]: plan_payload = dict(payload) plan_target = dict(target) if isinstance(payload.get("target"), dict): for key, value in payload["target"].items(): plan_target.setdefault(key, value) plan_target["kind"] = target_kind plan_payload["target"] = plan_target plan_payload["target_kind"] = target_kind plan_payload["resolve_origin"] = False plan = metadata_write_plan(plan_payload) if plan.get("allowed") is True: return plan, None return plan, { "status": "blocked", "error": "write_plan_blocked", "routed_method": METADATA_WRITE_PLAN_METHOD, "problems": plan.get("problems") if isinstance(plan.get("problems"), list) else [], "diagnostics": { "message": f"{routed_method} will not apply while metadata.write.plan reports blocking problems.", }, } def metadata_write_path_can_resolve_saved_state_module(path_plan: dict[str, Any] | None) -> bool: if not isinstance(path_plan, dict): return False if metadata_write_plan_target_kind(path_plan) != "module": return False path_resolution = path_plan.get("path_resolution") if isinstance(path_plan.get("path_resolution"), dict) else {} if path_resolution.get("section") != "form_module": return False apply_hint = metadata_write_plan_apply_hint(path_plan) next_resolution = apply_hint.get("next_resolution") if isinstance(apply_hint, dict) else None return isinstance(next_resolution, dict) and next_resolution.get("method") == SAVED_STATE_MODULES_SEARCH_METHOD def metadata_write_save_first_payload(payload: dict[str, Any], mode: str) -> dict[str, Any]: if str(mode or "").strip().casefold() not in {"apply", "apply_and_verify", "apply_and_rollback"}: return payload result = dict(payload) result.setdefault("allow_sql_saved_state_apply", True) result.setdefault("allow_sql_saved_state_prepare", True) result.setdefault("auto_prepare_saved_state", True) if str(mode or "").strip().casefold() == "apply_and_rollback": result.setdefault("allow_sql_saved_state_rollback", True) return result def metadata_write_preflight_saved_target(payload: dict[str, Any], plan: dict[str, Any]) -> dict[str, Any]: target = payload.get("target") if isinstance(payload.get("target"), dict) else {} hint_payload = metadata_write_apply_hint_payload(plan) result: dict[str, Any] = {} module_ref = str( hint_payload.get("module_ref") or hint_payload.get("module_id") or target.get("module_ref") or target.get("module_id") or payload.get("module_ref") or payload.get("module_id") or "" ).strip() if module_ref: module_table, module_file_name, stream_index = parse_module_id(module_ref) if module_table and module_file_name: result.update({"table": module_table, "file_name": module_file_name, "module_ref": module_ref}) if stream_index is not None: result["stream_index"] = stream_index return result table = str( hint_payload.get("table") or target.get("table") or target.get("target_table") or payload.get("table") or payload.get("target_table") or "" ).strip() file_name = str( hint_payload.get("file_name") or target.get("file_name") or payload.get("file_name") or "" ).strip() if table in SAVED_STATE_SOURCE_BY_TARGET and file_name and Path(file_name).name == file_name: result.update({"table": table, "file_name": file_name}) return result def metadata_write_preflight_status(plan: dict[str, Any], saved_state: dict[str, Any] | None) -> str: if plan.get("allowed") is not True: if any(str(problem.get("code") or "") == "origin_lookup_required" for problem in plan.get("problems") or [] if isinstance(problem, dict)): return "needs_resolution" return "blocked" hint = metadata_write_plan_apply_hint(plan) if isinstance(hint, dict) and hint.get("ready_for_apply_method") is False: return "needs_resolution" if saved_state and saved_state.get("needs_prepare") is True: return "needs_prepare" if saved_state and saved_state.get("status") in {"error"}: return "blocked" return "ready" def live_sql_support_gate(payload: dict[str, Any]) -> dict[str, Any]: """Evaluate support from live SQL, falling back fail-closed to configured policy.""" configured = repository_control.support_gate(payload) # A repository-less disposable base explicitly opts out of both repository # and supplier-support controls for extensions that inherit that profile. # Do not replace this deterministic policy with a slow, partial SQL support # decode: the test base must remain writable even when the new extension # has not yet been added to adapter-owned layer configuration. if configured.get("status") == "not_on_support_inherited_no_repository": return configured layer_id = repository_control.development_layer_id(payload) target = payload.get("target") if isinstance(payload.get("target"), dict) else {} object_guid = str( payload.get("support_object_guid") or payload.get("object_guid") or target.get("object_guid") or target.get("guid") or "" ).strip().lower() normalized = normalize_object_selector_aliases(payload, "metadata.support.decode") if not (isinstance(normalized, dict) and normalized.get("status") == "invalid_argument"): if not object_guid: object_guid = str(normalized.get("guid") or "").strip().lower() if not object_guid and normalized.get("name"): if layer_id == "base": card = get_object( normalized.get("kind"), str(normalized.get("name") or ""), base_id=str(payload.get("base_id") or ""), include_semantic=False, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) object_guid = str((card.get("object") or {}).get("guid") or "").lower() else: extension_guid = layer_id.split(":", 1)[1] found = extension_objects_find( { "base_id": payload.get("base_id"), "extension_guid": extension_guid, "query": normalized.get("name"), "kind": normalized.get("kind"), "limit": 20, "scan_limit": 5000, } ) name_folded = str(normalized.get("name") or "").casefold() exact = next( ( item for item in (found.get("objects") or []) if str(item.get("name") or "").casefold() == name_folded ), None, ) object_guid = str((exact or {}).get("guid") or "").lower() if not is_guid_text(object_guid): return configured live = metadata_support_decode( { "base_id": payload.get("base_id"), "layer_id": layer_id, "object_guid": object_guid, "timeout_seconds": payload.get("timeout_seconds", 30), } ) object_support = live.get("object_support") if isinstance(live.get("object_support"), dict) else None if live.get("status") != "ok" or not object_support: return { **configured, "live_sql": { "status": live.get("status"), "diagnostics": live.get("diagnostics"), }, } allowed = object_support.get("edit_allowed_by_support") is True return { "required": object_support.get("status") != "not_supported", "allowed": allowed, "status": "support_live_sql_editable" if allowed else "blocked_by_support_live_sql", "layer_id": layer_id, "mode": "live_sql", "object_guid": object_guid, "rule": object_support.get("status"), "evidence": object_support.get("evidence"), "source_discovery": (live.get("diagnostics") or {}).get("source_discovery"), } def extension_saved_state_support_override(payload: dict[str, Any], support: dict[str, Any], repository_gate: dict[str, Any]) -> dict[str, Any] | None: """Permit a guarded extension development stream when support is unknown. This does not weaken base-configuration support controls. The writer still proves old-once/BSL validity/readback later; this narrow override only supplies the missing support policy for an already confirmed extension ConfigCASSave stream. """ if repository_gate.get("allowed") is not True or support.get("allowed") is True: return None if str(support.get("mode") or "").casefold() != "unknown": return None module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() table, file_name, stream_index = parse_module_id(module_ref) # Form modules can be stored as a root BSL string in a ConfigCASSave # container rather than as a CAS stream. A concrete saved-state file is # therefore an equally valid selector here, but never broaden this to a # table-only or inferred target. table = str(payload.get("table") or table or "").strip() file_name = str(payload.get("file_name") or file_name or "").strip() if table != "ConfigCASSave" or not file_name: return None expected_text_sha1 = str(payload.get("expected_text_sha1") or "").strip().lower() expected_payload_sha1 = str(payload.get("expected_sha1") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{40}", expected_text_sha1) and not re.fullmatch(r"[0-9a-f]{40}", expected_payload_sha1): return None proposal = payload.get("proposal") if isinstance(payload.get("proposal"), dict) else {} proposal_source = proposal.get("source") if isinstance(proposal.get("source"), dict) else {} proposal_is_exact_stream_edit = ( proposal.get("status") in {"accepted_for_review", "ok"} and int((proposal.get("counts") or {}).get("edits") or 0) == 1 and ( str(proposal_source.get("module_id") or "") == module_ref or ( str(proposal_source.get("table") or "") == "ConfigCASSave" and str(proposal_source.get("file_name") or "") == file_name ) ) ) if (payload.get("old") is None or payload.get("new") is None) and not proposal_is_exact_stream_edit: return None layer_id = str(payload.get("layer_id") or repository_gate.get("layer_id") or "").strip().lower() if not layer_id.startswith("extension:"): return None extension_guid = layer_id.split(":", 1)[1] if not is_guid_text(extension_guid): return None if not file_name.casefold().startswith(f"{extension_guid}__"): return None extension = extension_map_by_guid(str(payload.get("base_id") or "")).get(extension_guid) if not isinstance(extension, dict) or not str(extension.get("name") or "").strip(): return None repository_capture_required = repository_gate.get("required") is not False if repository_capture_required and ( not str(payload.get("lock_session_id") or "").strip() or not str(payload.get("repository_object") or "").strip() ): return None return { "required": True, "allowed": True, "status": "extension_development_layer", "mode": "extension_development_layer", "scope": "saved_state_stream" if stream_index is not None else "saved_state_container", "layer_id": layer_id, "extension": {"guid": extension_guid, "name": extension.get("name")}, "guards": [ *( ["repository_lock"] if repository_capture_required else ["repository_not_connected"] ), "configcassave_stream" if stream_index is not None else "configcassave_container", "expected_text_sha1" if expected_text_sha1 else "expected_payload_sha1", "old_new_exact", "writer_structural_validation", "saved_state_readback", ], } def extension_active_stream_support_override(payload: dict[str, Any], repository_gate: dict[str, Any]) -> dict[str, Any] | None: """Bound support decision for a proven extension BSL stream. Before the first edit there is no ConfigCASSave row to inspect. A global support decode is both broader than the requested object and can be very slow. The exact ConfigCAS stream issued by code.search/read, together with one resolved extension layer and a guarded fragment replacement, is enough to defer support to the saved-state writer's structural/readback checks. """ if repository_gate.get("allowed") is not True: return None target = payload.get("target") if isinstance(payload.get("target"), dict) else {} module_ref = str( payload.get("module_ref") or payload.get("module_id") or payload.get("concrete_reference") or target.get("module_ref") or target.get("module_id") or target.get("concrete_reference") or "" ).strip() table, _file_name, stream_index = parse_module_id(module_ref) layer_id = str(payload.get("layer_id") or target.get("layer_id") or repository_gate.get("layer_id") or "").strip().casefold() extension_guid = layer_id.split(":", 1)[1] if layer_id.startswith("extension:") else "" has_guarded_fragment = payload.get("old") is not None and payload.get("new") is not None has_public_owner = bool(payload.get("ref") or target.get("ref")) if table not in {"ConfigCAS", "ConfigCASSave"} or stream_index is None or not is_guid_text(extension_guid) or not has_guarded_fragment or not has_public_owner: return None requested_extension = str(payload.get("extension") or target.get("extension") or "").strip() if not requested_extension: return None resolved_guid, resolution_error = extension_filter_to_guid(str(payload.get("base_id") or ""), requested_extension, method="write.support") if resolution_error or str(resolved_guid or "").casefold() != extension_guid: return None return { "required": True, "allowed": True, "status": "extension_active_stream_guarded" if table == "ConfigCAS" else "extension_saved_stream_guarded", "mode": "extension_active_stream_guarded" if table == "ConfigCAS" else "extension_saved_stream_guarded", "scope": "active_extension_stream" if table == "ConfigCAS" else "saved_extension_stream", "layer_id": layer_id, "guards": ["repository_scope", "extension_layer", "exact_active_stream" if table == "ConfigCAS" else "exact_saved_stream", "old_new_exact", "saved_state_readback"], } def extension_saved_state_prepare_support_override(payload: dict[str, Any], repository_gate: dict[str, Any]) -> dict[str, Any] | None: """Allow the bounded first-copy stage without a whole-extension support decode. A save preparation has no user supplied payload and cannot alter active configuration: later in ``metadata_saved_state_prepare`` it copies only proven ConfigCAS parts for one public object into ConfigCASSave. Requiring ``metadata_support_decode`` here used to scan the whole extension before that narrow resolver ran, which could leave a normal first ``code.write`` waiting indefinitely. Keep the repository gate and exact development layer; the source/object mapping and atomic-copy verification remain the subsequent preparation gates. """ if repository_gate.get("allowed") is not True: return None extension = str(payload.get("extension") or "").strip() ref = str(payload.get("ref") or "").strip() layer_id = str(payload.get("layer_id") or repository_gate.get("layer_id") or "").strip().casefold() if not extension or not ref or not layer_id.startswith("extension:"): return None extension_guid = layer_id.split(":", 1)[1] if not is_guid_text(extension_guid): return None resolved_guid, resolution_error = extension_filter_to_guid( str(payload.get("base_id") or ""), extension, method="saved_state.prepare.support" ) if resolution_error or str(resolved_guid or "").casefold() != extension_guid: return None target_table = str(payload.get("target_table") or payload.get("table") or "ConfigCASSave").strip() if target_table != "ConfigCASSave": return None return { "required": True, "allowed": True, "status": "extension_saved_state_prepare_guarded", "mode": "extension_saved_state_prepare_guarded", "scope": "extension_object_copy", "layer_id": layer_id, "extension": {"guid": extension_guid, "name": extension}, "guards": [ "repository_scope", "extension_layer", "public_object_selector", "proven_configcas_source_parts", "configcassave_only", "atomic_copy_readback", ], } def repository_layers_audit(payload: dict[str, Any]) -> dict[str, Any]: """Report policy per real configuration layer without consulting local caches.""" base_id_or_error = require_base_id(payload, "repository.layers.audit") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error extensions_result = list_extensions({"base_id": base_id, "timeout_seconds": payload.get("timeout_seconds", 30)}) extensions = extensions_result.get("extensions") if extensions_result.get("status") == "ok" else [] candidates = [{"layer_id": "base", "kind": "base", "name": None, "guid": None}] for extension in extensions if isinstance(extensions, list) else []: if not isinstance(extension, dict) or not is_guid_text(str(extension.get("guid") or "")): continue guid = str(extension["guid"]).lower() candidates.append({"layer_id": f"extension:{guid}", "kind": "extension", "name": extension.get("name"), "guid": guid}) layers: list[dict[str, Any]] = [] for candidate in candidates: layer_id = str(candidate["layer_id"]) repository = repository_control.status({"base_id": base_id, "layer_id": layer_id}) support = metadata_support_decode({"base_id": base_id, "layer_id": layer_id, "timeout_seconds": payload.get("timeout_seconds", 30)}) repository_status = str(repository.get("status") or "unknown") if repository_status == "repository_not_connected": next_write_action = "repository_lock_not_required" elif repository_status in {"repository_connection_unknown", "repository_state_unknown"}: next_write_action = "configure_repository_connection_state" else: next_write_action = "request_and_confirm_repository_capture" suppliers = support.get("suppliers") if isinstance(support.get("suppliers"), list) else [] support_overview = { "status": support.get("status"), "supplier_count": len(suppliers), "general_modes": sorted({str(item.get("general_mode")) for item in suppliers if isinstance(item, dict) and item.get("general_mode")}), "support_payload_present": (support.get("source") or {}).get("support_payload_present"), "source_discovery": (support.get("diagnostics") or {}).get("source_discovery"), } layers.append({ **candidate, "repository": repository, "support": { **support_overview, "object_support": support.get("object_support"), "source": support.get("source"), }, "safe_next_write_action": next_write_action, }) return { "schema": "onec_repository_layers_audit.v1", "method": "repository.layers.audit", "status": "ok" if extensions_result.get("status") == "ok" else "partial", "base_id": base_id, "layers": layers, "extensions_discovery": {"status": extensions_result.get("status"), "diagnostics": extensions_result.get("diagnostics")}, } def repository_apply_gate(payload: dict[str, Any], method: str, mode: str) -> dict[str, Any] | None: if mode not in {"apply", "apply_and_verify", "apply_and_rollback"}: return None # A public code.write apply is authorized once, before it can prepare a # saved copy. The subsequent prepare and module-write calls are internal # stages of that same request, so they carry a process-signed proof rather # than repeating owner/support discovery against a different SQL layer. # Clients cannot manufacture this proof: its MAC uses a non-exported # process-local secret and binds the public write scope. if adapter_write_authorization_is_valid(payload): return None saved_state_apply_module_trace(phase="authorization_before_context", base_id=str(payload.get("base_id") or "")) payload, context_error = normalize_repository_write_context(payload, method) if context_error: return { "schema": "onec_repository_write_gate.v1", "method": method, "base_id": payload.get("base_id"), **context_error, } # Resolve an extension name to its live development layer before evaluating # repository/support policy. Without this, a name-first code.write can be # treated as base scope and trigger an expensive whole-extension search. payload = resolve_write_gate_context(payload) saved_state_apply_module_trace(phase="authorization_after_context", base_id=str(payload.get("base_id") or "")) layer_block = development_layer_write_block(payload) if layer_block and mode in {"apply", "apply_and_verify", "apply_and_rollback"}: return { "schema": "onec_repository_write_gate.v1", "method": method, "status": "blocked", "base_id": payload.get("base_id"), **layer_block, "diagnostics": { "message": "Saved-state apply is blocked because the public extension selector did not resolve to one matching development layer.", }, } gate = repository_control.write_gate(payload) saved_state_apply_module_trace(phase="authorization_after_repository_gate", base_id=str(payload.get("base_id") or "")) # Support decoding can be expensive for a large extension. It cannot make # an unauthorized write permissible, so never perform it until the # repository gate has accepted the exact development scope. if gate.get("allowed") is not True: return { "schema": "onec_repository_write_gate.v1", "method": method, "status": "blocked", "error": str(gate.get("status") or "write_authorization_required"), "base_id": payload.get("base_id"), "repository": gate, "support": {"required": True, "allowed": False, "status": "deferred_until_repository_lock"}, "diagnostics": {"message": "Saved-state apply requires a confirmed repository scope before support inspection or saved-state preparation."}, } # A module writer reaches this gate after it has resolved one concrete # ConfigCASSave file and its compare-and-set hash. Check that narrow, # evidence-bearing extension route *before* parsing the whole extension's # support metadata. The previous order always performed the expensive # parse first, so a normal code.write apply could time out without ever # reaching its atomic saved-state writer. support = extension_active_stream_support_override(payload, gate) if support is None and method in {"metadata.saved_state.prepare", "metadata.saved_state.ensure"}: support = extension_saved_state_prepare_support_override(payload, gate) support = support or extension_saved_state_support_override( payload, {"allowed": False, "mode": "unknown", "status": "support_unknown"}, gate, ) if support is None: support = live_sql_support_gate(payload) support = extension_saved_state_support_override(payload, support, gate) or support if support.get("allowed") is True: return None return { "schema": "onec_repository_write_gate.v1", "method": method, "status": "blocked", "error": str(support.get("status") or "write_authorization_required"), "base_id": payload.get("base_id"), "repository": gate, "support": support, "diagnostics": {"message": "Saved-state apply requires both repository and support gates to allow the exact development layer and target."}, } def adapter_write_authorization_scope(payload: dict[str, Any]) -> dict[str, str]: target = payload.get("target") if isinstance(payload.get("target"), dict) else {} repository_lock = payload.get("repository_lock") if isinstance(payload.get("repository_lock"), dict) else {} write_context = payload.get("write_context") if isinstance(payload.get("write_context"), dict) else {} return { "base_id": str(payload.get("base_id") or ""), "ref": str(payload.get("ref") or target.get("ref") or ""), "extension": str(payload.get("extension") or target.get("extension") or "").casefold(), "extension_guid": str(payload.get("extension_guid") or target.get("extension_guid") or "").casefold(), "layer_id": str(payload.get("layer_id") or target.get("layer_id") or "").casefold(), "canonical_path": str(payload.get("canonical_path") or target.get("canonical_path") or target.get("path") or ""), "routine_name": str(payload.get("routine_name") or target.get("routine_name") or ""), "lock_session_id": str(payload.get("lock_session_id") or repository_lock.get("lock_session_id") or write_context.get("lock_session_id") or ""), } def adapter_write_authorization_proof(payload: dict[str, Any]) -> dict[str, Any]: scope = adapter_write_authorization_scope(payload) encoded = json.dumps(scope, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") signature = hmac.new(ADAPTER_WRITE_AUTHORIZATION_SECRET, encoded, hashlib.sha256).hexdigest() return {"schema": "onec_internal_write_authorization.v1", "scope": scope, "signature": signature} def adapter_write_authorization_is_valid(payload: dict[str, Any]) -> bool: proof = payload.get("_adapter_write_authorization") if not isinstance(proof, dict) or proof.get("schema") != "onec_internal_write_authorization.v1": return False supplied_scope = proof.get("scope") if isinstance(proof.get("scope"), dict) else {} expected_scope = adapter_write_authorization_scope(payload) if supplied_scope != expected_scope: return False encoded = json.dumps(expected_scope, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") expected_signature = hmac.new(ADAPTER_WRITE_AUTHORIZATION_SECRET, encoded, hashlib.sha256).hexdigest() return hmac.compare_digest(str(proof.get("signature") or ""), expected_signature) def metadata_write_is_schedule_target(payload: dict[str, Any]) -> bool: target = payload.get("target") if isinstance(payload.get("target"), dict) else {} value = str(target.get("kind") or payload.get("target_kind") or "").strip().casefold() return value in {"schedule", "scheduled_job", "scheduledjob", "расписание", "регламентноезадание", "регламентное_задание"} def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_PREFLIGHT_METHOD payload, context_error = normalize_repository_write_context(payload, method) if context_error: return { "schema": "onec_metadata_write_preflight.v1", "method": method, "base_id": payload.get("base_id"), "allowed": False, **context_error, } payload = resolve_write_gate_context(payload) if metadata_write_is_schedule_target(payload): schedule_plan = metadata_scheduled_job_schedule_write( { **payload, "allow_saved_state_write": True, "execution_mode": "plan", "include_payload": False, } ) object_card = schedule_plan.get("object") if isinstance(schedule_plan.get("object"), dict) else {} gate_payload = { **payload, **({"object_guid": object_card.get("guid")} if object_card.get("guid") else {}), } repository_gate = repository_control.write_gate(gate_payload) support_gate = live_sql_support_gate(gate_payload) layer_block = development_layer_write_block(payload) schedule_status = str(schedule_plan.get("status") or "blocked") status = "ready" if schedule_status in {"planned", "unchanged"} else schedule_status if layer_block: status = "blocked" elif repository_gate.get("allowed") is not True: status = str(repository_gate.get("status") or "blocked") elif support_gate.get("allowed") is not True: status = str(support_gate.get("status") or "blocked") source = schedule_plan.get("source") if isinstance(schedule_plan.get("source"), dict) else {} return { "schema": "onec_metadata_write_preflight.v1", "method": method, "status": status, "allowed": status == "ready", "base_id": payload.get("base_id"), "target": { "target_kind": "schedule", "ref": object_card.get("ref"), "guid": object_card.get("guid"), }, "route": { "writer": "metadata.write:scheduled_job_schedule", "write_surface": "saved_state", "ready_for_apply_method": status == "ready", "auto_resolves_saved_state": True, }, "saved_state": { "status": schedule_status, "target": { **({"table": source.get("table")} if source.get("table") else {}), **({"file_name": source.get("file_name")} if source.get("file_name") else {}), }, "needs_prepare": schedule_status == "needs_prepare", "freshness": {"source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True}, }, "repository": repository_gate, "support": support_gate, **( {"development_layer_resolution": payload["development_layer_resolution"]} if isinstance(payload.get("development_layer_resolution"), dict) else {} ), "guards": { "required": ["allow_saved_state_write", "expected_sha1", "backup", "readback_verification"], "requires_saved_state_prepare": schedule_status == "needs_prepare", "requires_backup": True, "rollback_available": schedule_status in {"planned", "unchanged"}, "expected_sha1": ((schedule_plan.get("proposal") or {}).get("original") or {}).get("sha1"), }, "plan": { "method": METADATA_WRITE_METHOD, "status": schedule_status, "allowed": schedule_status in {"planned", "unchanged"}, "problems": [] if schedule_status in {"planned", "unchanged"} else [schedule_plan.get("diagnostics") or {"message": schedule_status}], }, "diagnostics": { "read_only": True, "message": "metadata.write.preflight resolved the scheduled job by public 1C name and verified its ConfigSave schedule payload without applying SQL writes.", }, } plan_payload = dict(payload) plan_payload.setdefault("resolve_origin", payload.get("resolve_origin", False)) plan = metadata_write_plan(plan_payload) path_resolution = plan.get("path_resolution") if isinstance(plan.get("path_resolution"), dict) else {} command_button_route = ( str(path_resolution.get("path_kind") or "") == "form_command" or ( str(path_resolution.get("path_kind") or "") == "form_element" and str(path_resolution.get("form_member_kind") or "") == "button" ) ) saved_target = metadata_write_preflight_saved_target(payload, plan) saved_state: dict[str, Any] | None = None module_prepare: dict[str, Any] | None = None saved_target_is_active_source = str(saved_target.get("table") or "") in SAVED_STATE_TARGET_BY_SOURCE if saved_target.get("table") and saved_target.get("file_name") and not saved_target_is_active_source: diff_payload = { "base_id": payload.get("base_id"), "table": saved_target.get("table"), "file_name": saved_target.get("file_name"), "timeout_seconds": payload.get("timeout_seconds", 30), "max_changes": 1, "max_text_diff_lines": 0, "include_text_diff": False, "include_tree_diff": False, } if saved_target.get("module_ref"): diff_payload["module_ref"] = saved_target.get("module_ref") diff = metadata_saved_state_diff(diff_payload) saved_state = { "status": diff.get("status"), "target": diff.get("target") or saved_target, "source": diff.get("source"), "current_state": diff.get("current_state"), "needs_prepare": diff.get("needs_prepare") is True, "prepare_payload": diff.get("prepare_payload"), "freshness": diff.get("freshness") or { "source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True, }, } if diff.get("error"): saved_state["error"] = diff.get("error") if diff.get("comparison"): saved_state["comparison"] = diff.get("comparison") else: saved_state = { "status": "unresolved", "target": None, "needs_prepare": False, "auto_prepare_on_write": bool(command_button_route), "freshness": { "source": "live_sql", "status": "live_sql_verified" if command_button_route else ("vector_candidate_unverified" if plan.get("allowed") is not True else "live_sql_verified"), "verified_against_sql": bool(command_button_route), }, } # A raw active module reference has enough information to plan the # Config -> ConfigSave transition without writing. Present that concrete # transition instead of the generic origin-resolution failure. target_for_module = payload.get("target") if isinstance(payload.get("target"), dict) else {} module_ref_for_prepare = str( target_for_module.get("module_ref") or target_for_module.get("module_id") or target_for_module.get("concrete_reference") or payload.get("module_ref") or payload.get("module_id") or payload.get("concrete_reference") or "" ).strip() if module_ref_for_prepare: # An active Config/ConfigCAS reference from code.search is exact # source evidence, not a writable saved-state target. Resolve its # documented prepare transition even when the caller supplied only the # public write selector; do not require a second owner search. module_resolution = metadata_write_resolve_module_target( {**payload, "module_ref": module_ref_for_prepare, "include_storage": True}, target_for_module, "plan" ) module_plan_result = module_resolution[0] if isinstance(module_resolution, tuple) else module_resolution if isinstance(module_plan_result, dict) and module_plan_result.get("error") == "saved_state_prepare_required": module_prepare = module_plan_result prepared_ref = str(module_plan_result.get("prepared_module_ref") or "").strip() prepared_table, prepared_file_name, prepared_stream_index = parse_module_id(prepared_ref) saved_target = { "table": prepared_table or "ConfigSave", "file_name": prepared_file_name or None, "module_ref": prepared_ref or None, **({"stream_index": prepared_stream_index} if prepared_stream_index is not None else {}), } saved_state = { "status": "needs_prepare", "target": saved_target, "needs_prepare": True, "prepare_payload": module_plan_result.get("next_resolution"), "prepare_plan": module_plan_result.get("prepare_plan"), "freshness": {"source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True}, } status = metadata_write_preflight_status(plan, saved_state) if module_prepare: # Preparation is an adapter-internal first-write step. A proven # active->saved mapping makes the *apply route* ready even though the # saved row is absent now; metadata.module.write_apply will create it # atomically under the same repository/support/SHA guards. prepare_status = str((module_prepare.get("prepare_plan") or {}).get("status") or "") # A previously interrupted prepare can leave an already-proven saved # copy. It is as routable as a fresh plan_ready transition; do not # force the caller to orchestrate it or downgrade a safe route. status = "ready" if prepare_status in {"plan_ready", "plan_ready_merge", "already_prepared", "applied", "verified"} else "needs_prepare" if status == "ready" and isinstance(saved_state, dict): # The caller never has to orchestrate ConfigCASSave creation. An # absent saved row is normal first-write state: apply creates it # from the exact active stream, then continues with the guarded # module edit in one adapter-owned workflow. saved_state = { **saved_state, "status": "will_prepare", "needs_prepare": False, "adapter_managed": True, "preparation_required": True, } if command_button_route: status = "ready" # The metadata resolver is the authority for the configuration layer. Do # not require callers to select base/extension manually once origin is known. gate_payload = dict(payload) plan_target = plan.get("target") if isinstance(plan.get("target"), dict) else {} if plan_target: caller_target = payload.get("target") if isinstance(payload.get("target"), dict) else {} gate_payload["target"] = {**caller_target, **plan_target} repository_gate = repository_control.write_gate(gate_payload) # A public form target has ``kind: form`` for routing, while support is a # property of its owning metadata object. Resolve that owner from the # public ref internally so support preflight does not fail closed merely # because callers correctly avoided SQL/form-container coordinates. support_gate_payload = dict(gate_payload) logical_selector = plan_target.get("logical_selector") if isinstance(plan_target.get("logical_selector"), dict) else {} owner_ref = str(logical_selector.get("ref") or gate_payload.get("ref") or "").strip() owner_kind, owner_name = parse_object_query(None, owner_ref) if owner_ref else (None, "") if owner_kind and owner_name: support_gate_payload["kind"] = owner_kind support_gate_payload["name"] = owner_name if repository_gate.get("allowed") is not True: # A manual capture is a hard prerequisite for apply. Avoid an # expensive extension-manifest/support decode before that prerequisite # is met; it cannot make the operation writable and used to make an # otherwise useful preflight exceed its caller timeout. support_gate = { "required": True, "allowed": False, "status": "deferred_until_repository_lock", "layer_id": repository_gate.get("layer_id") or repository_control.development_layer_id(support_gate_payload), "reason": "repository_lock_required", } else: support_gate = live_sql_support_gate(support_gate_payload) layer_block = development_layer_write_block(payload) if layer_block: status = "blocked" elif repository_gate.get("allowed") is not True: status = str(repository_gate.get("status") or "blocked") elif support_gate.get("allowed") is not True: status = str(support_gate.get("status") or "blocked") repository_problem = repository_gate.get("problem") if isinstance(repository_gate.get("problem"), dict) else {} layer_configuration_required = None if str(repository_problem.get("status") or "") == "layer_not_configured": logical_selector = plan_target.get("logical_selector") if isinstance(plan_target.get("logical_selector"), dict) else {} extension_name = str(logical_selector.get("extension") or gate_payload.get("extension") or "").strip() layer_configuration_required = { "status": "required", "error": "development_layer_not_configured", "layer_id": repository_gate.get("layer_id"), "extension": extension_name or None, "message": "This exact extension has no repository/support development-layer policy in the adapter configuration. The adapter will not substitute another extension policy.", "next_action": "configure_exact_extension_development_layer", } layer_configuration_plan = ( repository_layer_connection_plan( { "base_id": payload.get("base_id"), **({"extension": layer_configuration_required["extension"]} if layer_configuration_required and layer_configuration_required.get("extension") else {}), "connection_state": "configured", } ) if layer_configuration_required and layer_configuration_required.get("extension") else None ) hint = metadata_write_plan_apply_hint(plan) hint_payload = hint.get("payload") if isinstance(hint, dict) and isinstance(hint.get("payload"), dict) else {} guards = { "required": plan.get("required_guards") if isinstance(plan.get("required_guards"), list) else [], "requires_saved_state_prepare": saved_state.get("needs_prepare") is True if isinstance(saved_state, dict) else False, "requires_backup": True, "rollback_available": bool(saved_target.get("table") and saved_target.get("file_name")), "expected_sha1": hint_payload.get("expected_sha1"), "expected_text_sha1": hint_payload.get("expected_text_sha1"), } route = plan.get("route") if isinstance(plan.get("route"), dict) else {} write_context = { **repository_write_context(gate_payload), **( {"layer_id": support_gate.get("layer_id")} if support_gate.get("layer_id") not in {None, ""} else {} ), **( {"support_object_guid": support_gate.get("object_guid")} if support_gate.get("object_guid") not in {None, ""} else {} ), **( {"module_ref": saved_target.get("module_ref")} if saved_target.get("module_ref") not in {None, ""} else {} ), **({"table": saved_target.get("table")} if saved_target.get("table") not in {None, ""} else {}), **({"file_name": saved_target.get("file_name")} if saved_target.get("file_name") not in {None, ""} else {}), **( {"stream_index": saved_target.get("stream_index")} if saved_target.get("stream_index") is not None else {} ), **( {"expected_sha1": guards.get("expected_sha1")} if guards.get("expected_sha1") not in {None, ""} else {} ), **( {"expected_text_sha1": guards.get("expected_text_sha1")} if guards.get("expected_text_sha1") not in {None, ""} else {} ), } return { "schema": "onec_metadata_write_preflight.v1", "method": method, "status": status, "allowed": status == "ready", "base_id": payload.get("base_id"), "target": plan.get("target"), "route": { "writer": FORM_COMMAND_BUTTON_WRITE_METHOD if command_button_route else ( "metadata.module.write_apply" if module_prepare else route.get("apply_method") ), "write_surface": "saved_state" if module_prepare else route.get("write_surface"), "preparation": ( { "status": "adapter_managed", "required": True, "caller_action_required": False, "source": "exact_active_module_reference", "target": "extension_saved_state", } if module_prepare else None ), "ready_for_apply_method": True if module_prepare and status == "ready" else (hint.get("ready_for_apply_method") if isinstance(hint, dict) else None), "apply_payload_hint": ( { "method": "metadata.module.write_apply", "payload": { "base_id": payload.get("base_id"), "target": target_for_module, "concrete_reference": module_ref_for_prepare, "allow_saved_state_write": True, "mode": "apply", **({"old": payload.get("old"), "new": payload.get("new")} if payload.get("old") is not None and payload.get("new") is not None else {}), }, "ready_for_apply_method": status == "ready", } if module_prepare else hint ), "auto_resolves_saved_state": bool(command_button_route or module_prepare), }, "saved_state": saved_state, "repository": repository_gate, "support": support_gate, **({"development_layer_configuration": layer_configuration_required} if layer_configuration_required else {}), **({"development_layer_configuration_plan": layer_configuration_plan} if layer_configuration_plan else {}), **( {"development_layer_resolution": payload["development_layer_resolution"]} if isinstance(payload.get("development_layer_resolution"), dict) else {} ), **({"owner_resolution": payload["owner_resolution"]} if isinstance(payload.get("owner_resolution"), dict) else {}), "guards": guards, "write_context": write_context, "plan": { "method": METADATA_WRITE_PLAN_METHOD, "status": plan.get("status"), "allowed": plan.get("allowed"), "problems": plan.get("problems") if isinstance(plan.get("problems"), list) else [], }, "diagnostics": { "read_only": True, "message": ( "The exact extension development layer is not configured; no SQL write or fallback to another extension was attempted." if layer_configuration_required else "metadata.write.preflight verifies route and saved-state freshness without applying SQL writes." ), }, } def metadata_scheduled_job_schedule_write(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error target = payload.get("target") if isinstance(payload.get("target"), dict) else {} mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) if allow_write_error: return allow_write_error include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error timeout_seconds = int(timeout_seconds or 30) table = str(target.get("table") or payload.get("table") or "ConfigSave") if table != "ConfigSave": return invalid_argument(method, "target.table", "Scheduled-job schedule writes currently target ConfigSave only.", allowed_values=["ConfigSave"]) schedule = payload.get("schedule") if schedule is None: schedule = target.get("schedule") if not isinstance(schedule, dict) or not schedule: return invalid_argument(method, "schedule", "Pass schedule as a non-empty JSON object with named schedule fields.") ref = str(target.get("ref") or payload.get("ref") or "").strip() requested_kind = None ref_name = "" if ref: requested_kind, ref_name = parse_object_query(None, ref) if requested_kind != "ScheduledJob" or not ref_name: return invalid_argument(method, "target.ref", "Use a scheduled-job reference such as РегламентныеЗадания.ОбменДанными.") guid = str(target.get("guid") or target.get("object_guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() if guid and not is_guid_text(guid): return invalid_argument(method, "target.guid", "guid must be a GUID string.") name = str( ref_name or target.get("name") or target.get("object") or target.get("object_name") or payload.get("object_name") or payload.get("name") or "" ).strip() selector = guid or name if not selector: return invalid_argument(method, "target", "Pass target.ref, target.name, or target.guid for the scheduled job.") object_result = get_object( "ScheduledJob", selector, base_id=base_id, view="effective", limit=int(payload.get("limit") or 20), include_storage=True, include_semantic=False, timeout_seconds=timeout_seconds, table=table, ) if object_result.get("status") != "ok": result = dict(object_result) result.update({"schema": "onec_scheduled_job_schedule_write.v1", "method": method, "execution_mode": mode}) return result object_card = object_result.get("object") if isinstance(object_result.get("object"), dict) else {} object_guid = str(object_card.get("guid") or guid).lower() if not is_guid_text(object_guid): return { "schema": "onec_scheduled_job_schedule_write.v1", "method": method, "status": "blocked", "error": "scheduled_job_guid_unresolved", "base_id": base_id, "object": public_metadata_row(object_card, include_storage=bool(include_storage)), "diagnostics": {"message": "The scheduled job was found, but its public metadata GUID could not be resolved."}, } file_name = str(target.get("file_name") or payload.get("file_name") or f"{object_guid}.0") if Path(file_name).name != file_name or file_name != f"{object_guid}.0": return invalid_argument(method, "target.file_name", "The scheduled-job schedule file must be .0.") data, config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) if read_error: return { "schema": "onec_scheduled_job_schedule_write.v1", "method": method, "status": "needs_prepare", "execution_mode": mode, "base_id": base_id, "object": public_metadata_row(object_card, include_storage=bool(include_storage)), "diagnostics": { "message": "No saved-state schedule payload was found. Prepare the scheduled job in ConfigSave before writing its schedule.", "source_boundary": "The adapter does not write active Config rows.", }, "next_call": { "method": "metadata.saved_state.prepare", "params": { "base_id": base_id, "ref": object_card.get("ref") or object_selector_ref("ScheduledJob", str(object_card.get("name") or name)), "layer": "base_saved_state", }, }, } tree = parse_config_tree_from_bytes(data or b"") edit_plan = scheduled_job_schedule_write_edits(tree, schedule) if edit_plan.get("status") != "ok": return { "schema": "onec_scheduled_job_schedule_write.v1", "method": method, "execution_mode": mode, "base_id": base_id, "object": public_metadata_row(object_card, include_storage=bool(include_storage)), **edit_plan, } public_object = public_metadata_row(object_card, include_storage=bool(include_storage)) source = { "kind": "live_sql", **({"database": config.get("database")} if isinstance(config, dict) and config.get("database") and include_storage else {}), **({"table": table, "file_name": file_name} if include_storage else {}), } result: dict[str, Any] = { "schema": "onec_scheduled_job_schedule_write.v1", "method": method, "status": "blocked" if not allow_write else ("unchanged" if not edit_plan["edits"] else "planned"), "execution_mode": mode, "base_id": base_id, "object": public_object, "source": source, "current_schedule": edit_plan["current"], "requested_schedule": edit_plan["requested"], "counts": edit_plan["counts"], "write_mode": { "requested": "saved_state", "target_table": table, "sql_write_performed": False, "requires_apply_gate": True, }, } if not allow_write: result["diagnostics"] = { "message": "Schedule write planning is opt-in; pass allow_saved_state_write=true.", "read_only": True, } result["next_call"] = { "method": method, "params": { "base_id": base_id, "target": {"kind": "schedule", "ref": public_object.get("ref")}, "schedule": edit_plan["requested"], "allow_saved_state_write": True, "execution_mode": "plan", }, } return result if not edit_plan["edits"]: result["diagnostics"] = {"message": "The requested schedule already matches the saved-state payload."} return result proposal = changes_propose( { "base_id": base_id, "source": { "base_id": base_id, "table": table, "file_name": file_name, "expected_sha1": str(payload.get("expected_sha1") or hashlib.sha1(data or b"").hexdigest()), }, "edits": [{key: value for key, value in edit.items() if key != "field"} for edit in edit_plan["edits"]], "preserve_format": True, "include_payload": bool(payload.get("include_payload") is True or mode != "plan"), "include_text": bool(payload.get("include_text") is True), "timeout_seconds": timeout_seconds, "summary": payload.get("summary") or f"Update scheduled-job schedule {public_object.get('ref') or object_guid}", } ) result["field_edits"] = [ {key: value for key, value in edit.items() if key != "replace_root"} for edit in edit_plan["edits"] ] result["proposal"] = proposal if mode == "plan" else sanitize_proposal_for_response(proposal) if proposal.get("status") not in {"accepted_for_review", "ok"}: result["status"] = proposal.get("status") or "error" result["diagnostics"] = proposal.get("diagnostics") return result if mode == "plan": return result allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if not allow_apply: return invalid_argument(method, "allow_sql_saved_state_apply", "Apply mode is opt-in; pass allow_sql_saved_state_apply=true.") apply_result = storage_saved_state_apply_proposal( { **repository_write_context(payload), "base_id": base_id, "allow_sql_saved_state_apply": True, "proposal": proposal, "timeout_seconds": timeout_seconds, } ) result["apply_result"] = apply_result result["status"] = apply_result.get("status") or "error" result["applied"] = bool(apply_result.get("applied")) result["write_mode"]["sql_write_performed"] = bool(result["applied"]) if result["applied"]: readback, _, readback_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) readback_schedule = scheduled_job_sql_schedule(parse_config_tree_from_bytes(readback or b""), include_storage=False) if not readback_error else {"status": "error"} checks = [ {"field": field, "expected": expected, "actual": readback_schedule.get(field), "ok": readback_schedule.get(field) == expected} for field, expected in edit_plan["requested"].items() ] result["semantic_verification"] = { "status": "ok" if checks and all(check["ok"] for check in checks) else "mismatch", "schedule": readback_schedule, "checks": checks, } if mode in {"apply", "apply_and_verify"} and all(check["ok"] for check in checks): result["status"] = "verified" if mode in {"apply", "apply_and_verify"}: return result allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "apply_and_rollback mode is opt-in; pass allow_sql_saved_state_rollback=true.") backup_id = ((apply_result.get("backup") or {}).get("backup_id") if isinstance(apply_result.get("backup"), dict) else None) if not backup_id: result["status"] = "rollback_unavailable" result["diagnostics"] = {"message": "Apply result did not return backup.backup_id; cannot rollback automatically."} return result rollback_result = storage_saved_state_rollback( { **repository_write_context(payload), "base_id": base_id, "allow_sql_saved_state_rollback": True, "backup_id": backup_id, "timeout_seconds": timeout_seconds, } ) result["rollback_result"] = rollback_result payload_rolled_back = bool(rollback_result.get("applied")) prepare_receipt_id = str(payload.get("_auto_prepare_receipt_id") or "").strip().lower() prepare_rollback_result = None if payload_rolled_back and re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): prepare_rollback_result = rollback_saved_state_prepare_receipt( base_id, prepare_receipt_id, timeout_seconds=int(timeout_seconds or 30), ) result["prepare_rollback_result"] = prepare_rollback_result result["rolled_back"] = bool(payload_rolled_back and (not prepare_receipt_id or (prepare_rollback_result or {}).get("applied"))) result["status"] = "verified_and_rolled_back" if result["applied"] and result["rolled_back"] else ("applied_rollback_failed" if result["applied"] else result["status"]) return result def metadata_write_redact_internal_form_handle(value: Any) -> Any: """Remove SQL saved-state coordinates from a public logical form route. This is intentionally applied only when the adapter itself resolved a name-first selector. Explicit low-level calls keep their existing diagnostics for backwards compatibility and support investigation. """ internal_keys = { "table", "target_table", "source_table", "file_name", "file_names", "form_guid", "module_ref", "module_id", "stream_index", "concrete_reference", "concrete_reference_field", "concrete_reference_source", "guid", "sha1", "binary_sha1", "binarysha1", } if isinstance(value, list): return [metadata_write_redact_internal_form_handle(item) for item in value] if not isinstance(value, dict): return value return { key: metadata_write_redact_internal_form_handle(item) for key, item in value.items() if str(key).casefold() not in internal_keys } def metadata_write_cleanup_auto_prepared_failure( *, base_id: str, write_payload: dict[str, Any], result: dict[str, Any] | None, timeout_seconds: int ) -> dict[str, Any] | None: """Compensate a first-write copy when its guarded edit did not apply.""" receipt_id = str(write_payload.get("_auto_prepare_receipt_id") or "").strip().lower() if not re.fullmatch(r"[0-9a-f]{32}", receipt_id): return None if isinstance(result, dict) and result.get("applied") is True: return None cleanup = rollback_saved_state_prepare_receipt(base_id, receipt_id, timeout_seconds=max(1, int(timeout_seconds or 30))) return { "status": cleanup.get("status"), "applied": bool(cleanup.get("applied")), "counts": cleanup.get("counts"), "prepare_receipt_id": receipt_id, } def metadata_write(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_METHOD started = time.time() mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: saved_state_apply_module_trace(phase="metadata_write_enter", base_id=str(payload.get("base_id") or "")) payload, context_error = normalize_repository_write_context(payload, method) if context_error: return {"schema": "onec_metadata_write.v1", "method": method, "base_id": payload.get("base_id"), **context_error} payload = resolve_write_gate_context(payload) # A contradictory extension name/GUID is an authorization-scope error, # not a form-resolution problem. Stop before inspecting any target so an # ambiguous caller selector can never fall through to another layer. layer_block = development_layer_write_block(payload) if layer_block: return { "schema": "onec_repository_write_gate.v1", "method": method, "base_id": payload.get("base_id"), "status": "blocked", **layer_block, "diagnostics": { "message": "Saved-state write is blocked because the requested extension selector does not resolve to one development layer.", }, } # Module/form routes resolve their concrete ConfigSave/ConfigCASSave # target below. Applying the gate here used only a public object name, # caused a costly whole-layer support scan, and then ran the same gate a # second time in the routed writer. The routed writer is the authority: # it receives the exact file, expected hash and replacement proof. # Non-module routes retain their own route-specific write gates. payload = metadata_write_save_first_payload(payload, mode) target = payload.get("target") if isinstance(payload.get("target"), dict) else {} intent = payload.get("intent") if isinstance(payload.get("intent"), dict) else {} explicit_route_kind = payload.get("target_kind") or target.get("target_kind") or target.get("area") or payload.get("area") if not explicit_route_kind and str(target.get("kind") or "").strip().casefold() in { "form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные" }: explicit_route_kind = target.get("kind") if not explicit_route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}: explicit_route_kind = payload.get("kind") explicit_target_kind = explicit_route_kind target_kind = str(explicit_target_kind or "form").strip().casefold() object_operation = str( first_non_empty_arg(payload, "operation", default=target.get("operation") or intent.get("operation")) or "" ).strip().casefold() selected_handler = select_write_handler( target_kind=target_kind, operation=object_operation, is_schedule=metadata_write_is_schedule_target(payload), ) if selected_handler and selected_handler.key == "scheduled_job_schedule": result = execute_scheduled_job_handler( payload, AdapterWriteContext(legacy_scheduled_job_writer=metadata_scheduled_job_schedule_write), ) result.setdefault("routed_method", "metadata.write:scheduled_job_schedule") result.setdefault("target_kind", "schedule") return result if selected_handler and selected_handler.key in {"object_member", "object_property"}: if selected_handler.key == "object_member": add_payload: dict[str, Any] = { **payload, "template_member_ref": first_non_empty_arg( payload, "template_member_ref", default=target.get("template_member_ref") or intent.get("template_member_ref"), ), "new_member_name": first_non_empty_arg( payload, "new_member_name", default=target.get("new_member_name") or intent.get("new_member_name"), ), "allow_saved_state_write": True, "execution_mode": mode, } for optional_key in ("new_member_synonym", "new_member_comment"): if optional_key not in add_payload: optional_value = target.get(optional_key) if optional_key in target else intent.get(optional_key) if optional_value is not None: add_payload[optional_key] = optional_value add_payload.pop("target", None) add_payload.pop("intent", None) validation_error = validate_metadata_object_member_add_payload(add_payload) if validation_error: return validation_error add_result = metadata_object_member_add(add_payload) return { "schema": "onec_metadata_write.v1", "method": method, "status": add_result.get("status"), "execution_mode": mode, "target_kind": "object", "operation": "add_attribute", "base_id": payload.get("base_id"), "routed_method": OBJECT_MEMBER_ADD_METHOD, "result": add_result, } property_name = first_non_empty_arg(payload, "property", default=target.get("property") or intent.get("property")) value_present = "value" in payload or "value" in target or "value" in intent or "new" in intent requested_value = ( payload.get("value") if "value" in payload else target.get("value") if "value" in target else intent.get("value") if "value" in intent else intent.get("new") ) object_payload: dict[str, Any] = { **payload, "property": property_name, "allow_saved_state_write": True, "execution_mode": mode, } if value_present: object_payload["value"] = requested_value for selector_key in ( "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension", "layer", "member_ref", "child_ref", "member_kind", "member_name", "canonical_path", ): if target.get(selector_key) not in {None, ""}: object_payload[selector_key] = target.get(selector_key) if target.get("object_kind") not in {None, ""}: object_payload["kind"] = target.get("object_kind") if target.get("object_ref") not in {None, ""}: object_payload["ref"] = target.get("object_ref") object_payload.pop("target", None) object_payload.pop("intent", None) if str(object_payload.get("kind") or "").strip().casefold() in {"object", "объект", "metadata", "метаданные"}: object_payload.pop("kind", None) validation_error = validate_metadata_object_property_write_payload(object_payload) if validation_error: return validation_error object_result = metadata_object_property_write(object_payload) return { "schema": "onec_metadata_write.v1", "method": method, "status": object_result.get("status"), "execution_mode": mode, "target_kind": "object", "base_id": payload.get("base_id"), "routed_method": OBJECT_PROPERTY_WRITE_METHOD, "result": object_result, } if selected_handler is None: return invalid_argument(method, "target.kind", "Only form, module, object-property, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"]) requested_path = str(target.get("canonical_path") or payload.get("canonical_path") or target.get("path") or payload.get("path") or "").strip() if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: saved_state_apply_module_trace(phase="metadata_write_before_path_plan", base_id=str(payload.get("base_id") or "")) # A concrete stream issued by code.search/code.read is already a stronger # route proof than a canonical-path re-resolution. In particular an # extension object `.0` is a BSL container, not a metadata tree addressed # by `metadata_write_plan`; asking that planner to descend it can fail # before the exact saved-state route is reached. Keep the canonical path # as a consistency guard in the resolver, but do not decode it again here. has_concrete_module_route = bool(metadata_write_concrete_reference(payload, target)) direct_public_module_replace = bool( target_kind in {"module", "модуль", "bsl"} and str(payload.get("extension") or target.get("extension") or "").strip() and str(payload.get("ref") or target.get("ref") or payload.get("object_name") or target.get("object_name") or "").strip() and payload.get("old") is not None and payload.get("new") is not None ) path_plan = ( metadata_write_plan({**payload, "resolve_origin": False}) if requested_path and not has_concrete_module_route and not direct_public_module_replace else None ) if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: saved_state_apply_module_trace(phase="metadata_write_after_path_plan", base_id=str(payload.get("base_id") or "")) planned_target_kind = metadata_write_plan_target_kind(path_plan) path_can_resolve_saved_state_module = metadata_write_path_can_resolve_saved_state_module(path_plan) path_resolution_for_route = path_plan.get("path_resolution") if isinstance(path_plan, dict) and isinstance(path_plan.get("path_resolution"), dict) else {} path_can_route_command_button = ( str(path_resolution_for_route.get("path_kind") or "") == "form_command" or ( str(path_resolution_for_route.get("path_kind") or "") == "form_element" and str(path_resolution_for_route.get("form_member_kind") or "") == "button" ) ) if requested_path and not explicit_target_kind and planned_target_kind in {"form", "module"}: target_kind = planned_target_kind # A selector-only canonical path can refine the default form kind to a # module after the initial intent pass. Keep handler selection aligned # with that proven plan before any routed writer is called. selected_handler = select_write_handler( target_kind=target_kind, operation=object_operation, is_schedule=False, ) if requested_path and not has_concrete_module_route and not path_can_resolve_saved_state_module and not path_can_route_command_button: plan_target = path_plan.get("target") if isinstance(path_plan, dict) and isinstance(path_plan.get("target"), dict) else {} apply_hint = metadata_write_plan_apply_hint(path_plan) blocked_response = { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "execution_mode": mode, "target_kind": plan_target.get("target_kind") or target_kind, "base_id": payload.get("base_id"), "error": "write_plan_required", "routed_method": METADATA_WRITE_PLAN_METHOD, "plan": path_plan, "diagnostics": { "message": "metadata.write cannot write an effective canonical path directly. Resolve origin/layer evidence or pass a concrete saved-state reference.", }, } if apply_hint: blocked_response["apply_payload_hint"] = apply_hint if isinstance(apply_hint.get("next_resolution"), dict): blocked_response["next_resolution"] = apply_hint.get("next_resolution") blocked_response["preflight"] = metadata_write_preflight({**payload, "resolve_origin": False}) return blocked_response if requested_path and isinstance(path_plan, dict) and path_plan.get("allowed") is False and not path_can_resolve_saved_state_module and not path_can_route_command_button: apply_hint = metadata_write_plan_apply_hint(path_plan) blocked_response = { "schema": "onec_metadata_write.v1", "method": method, "status": "blocked", "execution_mode": mode, "target_kind": planned_target_kind or target_kind, "base_id": payload.get("base_id"), "error": "write_plan_blocked", "routed_method": METADATA_WRITE_PLAN_METHOD, "plan": path_plan, "problems": path_plan.get("problems") if isinstance(path_plan.get("problems"), list) else [], "diagnostics": { "message": "metadata.write will not call apply while metadata.write.plan reports blocking problems.", }, } if apply_hint: blocked_response["apply_payload_hint"] = apply_hint blocked_response["preflight"] = metadata_write_preflight({**payload, "resolve_origin": False}) return blocked_response if selected_handler.key == "module": module_resolution_started = time.time() if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: saved_state_apply_module_trace(phase="metadata_write_before_module_resolution", base_id=str(payload.get("base_id") or "")) resolved = metadata_write_resolve_module_target(payload, target, mode) if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: saved_state_apply_module_trace(phase="metadata_write_after_module_resolution", base_id=str(payload.get("base_id") or "")) module_resolution_duration_ms = int((time.time() - module_resolution_started) * 1000) if isinstance(resolved, dict): return resolved write_payload, search = resolved for key, value in metadata_write_apply_hint_payload(path_plan).items(): write_payload.setdefault(key, value) if write_payload.get("_embedded_form_module"): module_apply_started = time.time() result = form_embedded_module_handler_write_apply( write_payload, base_id=str(write_payload.get("base_id") or payload.get("base_id") or ""), table=str(write_payload.get("table") or "ConfigCASSave"), file_name=str(write_payload.get("file_name") or ""), handler_name=str(write_payload.get("handler_name") or write_payload.get("routine_name") or ""), mode=mode, timeout_seconds=int(write_payload.get("timeout_seconds") or 30), method_name=method, ) module_apply_duration_ms = int((time.time() - module_apply_started) * 1000) routed_method = "form_embedded_module_handler_write_apply" else: module_apply_started = time.time() try: result = metadata_module_write_apply(write_payload) except Exception as exc: cleanup = metadata_write_cleanup_auto_prepared_failure( base_id=str(payload.get("base_id") or ""), write_payload=write_payload, result=None, timeout_seconds=int(write_payload.get("timeout_seconds") or 30), ) return { "schema": "onec_metadata_write.v1", "method": method, "status": "failed", "execution_mode": mode, "target_kind": "module", "base_id": payload.get("base_id"), "error": "module_write_exception", "failed_phase": "module_write", "diagnostics": {"message": str(exc)}, **({"prepare_cleanup": cleanup} if cleanup else {}), } module_apply_duration_ms = int((time.time() - module_apply_started) * 1000) routed_method = MODULE_WRITE_APPLY_METHOD if mode in {"apply", "apply_and_verify", "apply_and_rollback"} and isinstance(result, dict) and result.get("applied") is not True: cleanup = metadata_write_cleanup_auto_prepared_failure( base_id=str(payload.get("base_id") or ""), write_payload=write_payload, result=result, timeout_seconds=int(write_payload.get("timeout_seconds") or 30), ) if cleanup: result = {**result, "prepare_cleanup": cleanup, "failed_phase": result.get("failed_phase") or "module_write"} normalized_target_kind = "module" else: path_resolution = path_plan.get("path_resolution") if isinstance(path_plan, dict) and isinstance(path_plan.get("path_resolution"), dict) else {} route_as_command_button = ( str(path_resolution.get("path_kind") or "") == "form_command" and path_resolution.get("command_name") ) or ( str(path_resolution.get("path_kind") or "") == "form_element" and str(path_resolution.get("form_member_kind") or "") == "button" and path_resolution.get("element_name") ) if route_as_command_button: command_or_button_name = path_resolution.get("command_name") or path_resolution.get("element_name") command_payload = { **payload, "object_type": path_resolution.get("kind") or payload.get("object_type") or target.get("kind"), "form": path_resolution.get("form_name") or payload.get("form") or target.get("form"), "command_name": command_or_button_name, "button_name": payload.get("button_name") or target.get("button_name") or command_or_button_name, "command_title": payload.get("command_title") or payload.get("title") or payload.get("value") or target.get("title") or command_or_button_name, "mode": mode, "allow_saved_state_write": True, } result = metadata_form_command_button_write(command_payload) routed_method = FORM_COMMAND_BUTTON_WRITE_METHOD normalized_target_kind = "form" response = { "schema": "onec_metadata_write.v1", "method": method, "status": result.get("status"), "execution_mode": mode, "target_kind": normalized_target_kind, "base_id": payload.get("base_id"), "routed_method": routed_method, "path_resolution": path_resolution, "result": result, "preflight": metadata_write_preflight({**payload, "resolve_origin": False}), } return response resolved = metadata_write_resolve_form_target(payload, target, mode) if isinstance(resolved, dict): return resolved write_payload, search = resolved for key, value in metadata_write_apply_hint_payload(path_plan).items(): write_payload.setdefault(key, value) result = metadata_form_element_write_apply(write_payload) retry_result, retry_resolution = metadata_write_form_retry_after_prepare(payload, write_payload, mode, result) result = retry_result if retry_resolution is not None: search = retry_resolution routed_method = FORM_ELEMENT_WRITE_APPLY_METHOD normalized_target_kind = "form" response = { "schema": "onec_metadata_write.v1", "method": method, "status": result.get("status"), "execution_mode": mode, "target_kind": normalized_target_kind, "base_id": payload.get("base_id"), "routed_method": routed_method, "result": result, } if target_kind in {"module", "модуль", "bsl"}: response["timings_ms"] = { "module_resolution": module_resolution_duration_ms, "module_apply": module_apply_duration_ms, } if search is not None: resolution_method = str(search.get("method") or SAVED_STATE_MODULES_SEARCH_METHOD) if isinstance(search, dict) else SAVED_STATE_MODULES_SEARCH_METHOD response["resolution"] = {"method": resolution_method, "status": search.get("status"), "counts": search.get("counts")} if isinstance(search, dict) and isinstance(search.get("result"), dict): response["resolution"]["prepare_status"] = search["result"].get("status") if isinstance(search, dict) and isinstance(search.get("retry_search"), dict): response["resolution"]["retry_search_status"] = search["retry_search"].get("status") response["resolution"]["retry_search_counts"] = search["retry_search"].get("counts") if isinstance(search, dict) and isinstance(search.get("timings_ms"), dict): response["resolution"]["timings_ms"] = search["timings_ms"] auto_prepare_receipt_id = str(write_payload.get("_auto_prepare_receipt_id") or "").strip().lower() if re.fullmatch(r"[0-9a-f]{32}", auto_prepare_receipt_id): # Opaque cleanup capability for rows created internally by this # high-level request; SQL file coordinates remain hidden. response["prepare_receipt_id"] = auto_prepare_receipt_id if payload.get("include_preflight") is True: response["preflight"] = metadata_write_preflight({**payload, "resolve_origin": False}) # A form target supplied by its 1C identity is deliberately resolved to a # Config*Save row only inside this call. Returning that row in the # generic metadata.write envelope would make an agent depend on a # transient implementation detail and could lead it to reuse a stale # physical handle on a later write. Keep concrete references visible for # the legacy low-level API, but redact them for the name-first route. public_form_selector = ( isinstance(target, dict) and normalized_target_kind == "form" and bool(target.get("extension")) and bool(target.get("ref") or target.get("object_ref")) and bool(target.get("form") or target.get("form_name")) and not metadata_write_concrete_reference(payload, target) ) if public_form_selector or (isinstance(target, dict) and bool(target.get("_internal_resolved_handle"))): response = metadata_write_redact_internal_form_handle(response) response["logical_target"] = { key: target.get(key) for key in ("kind", "extension", "ref", "form", "element", "attribute", "command") if target.get(key) not in (None, "") } response["duration_ms"] = int((time.time() - started) * 1000) return response def code_write_operation(payload: dict[str, Any]) -> str: if payload.get("old") is not None or payload.get("new") is not None: return "fragment_replace" if payload.get("routine_text") is not None or (payload.get("text") is not None and payload.get("routine_name")): return "routine_replace" if payload.get("module_text") is not None or payload.get("text") is not None: return "module_text_replace" return "code_write" def code_write_public_target(payload: dict[str, Any], target: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {"kind": "module"} for key in ("canonical_path", "path"): value = target.get(key) or payload.get(key) if value: result[key] = value break for source_key, public_key in ( ("object_type", "object_type"), ("object_name", "object_name"), ("object_guid", "object_guid"), ("form", "form"), ("form_name", "form_name"), ("routine_name", "routine_name"), ("extension", "extension"), ): value = target.get(source_key) if source_key in target else payload.get(source_key) if value: result[public_key] = value return result def code_write_public_resolution_summary(result: dict[str, Any] | None) -> dict[str, Any]: """Return a storage-free explanation of one public module resolution. `code.write` deliberately hides Config*/file/stream handles. That must not also hide the resolver phase which made a request fail: without it an audit record can only say ``public_write_route_unresolved`` and cannot distinguish no match, ambiguity, or a slow preparation attempt. """ if not isinstance(result, dict): return {} summary: dict[str, Any] = {} for key in ("method", "status", "error", "target_kind"): value = result.get(key) if value not in (None, ""): summary[key] = value candidates = result.get("candidates") if isinstance(candidates, int): summary["candidates"] = candidates counts = result.get("counts") if isinstance(counts, dict): safe_counts = {key: value for key, value in counts.items() if isinstance(value, (int, float, bool))} if safe_counts: summary["counts"] = safe_counts timings = result.get("timings_ms") if isinstance(timings, dict): safe_timings = {key: value for key, value in timings.items() if isinstance(value, (int, float))} if safe_timings: summary["timings_ms"] = safe_timings prepare_plan = result.get("prepare_plan") if isinstance(prepare_plan, dict): prepare_status = prepare_plan.get("status") if prepare_status not in (None, ""): summary["prepare_status"] = prepare_status return summary def code_write_extension_form_module_path(path: str) -> dict[str, Any] | None: """Parse the public, fully-qualified extension form-module write path. This is intentionally narrow: it accepts a concrete extension/report/form path, not an inferred object name. SQL coordinates stay internal. """ parts = [part.strip() for part in str(path or "").split(".") if part.strip()] if len(parts) != 7 or normalize(parts[0]) not in {normalize("Расширение"), "extension"}: return None section = normalize(parts[2]) if section not in {normalize("Отчеты"), normalize("Отчет"), "report", "reports"}: return None if normalize(parts[4]) not in {normalize("Формы"), normalize("Форма"), "form", "forms"}: return None if normalize(parts[6]) not in {normalize("МодульФормы"), normalize("Модуль формы"), "formmodule", "form_module"}: return None return { "extension": parts[1], "object_type": "Report", "object_name": parts[3], "form": parts[5], "canonical_path": ".".join(["Отчет", parts[3], "Формы", parts[5], "МодульФормы"]), } def code_write_metadata_payload(payload: dict[str, Any], *, mode: str) -> dict[str, Any]: target = dict(payload.get("target") or {}) explicit_target = payload.get("write_target") if isinstance(payload.get("write_target"), dict) else {} if explicit_target: target.setdefault("path", explicit_target.get("path")) target.setdefault("canonical_path", explicit_target.get("path")) target.setdefault("expected_sha1", explicit_target.get("expected_sha1")) origin = explicit_target.get("expected_origin") if isinstance(explicit_target.get("expected_origin"), dict) else {} if origin.get("extension"): target.setdefault("extension", origin.get("extension")) target.setdefault("kind", "module") for source_key, target_key in (("kind", "object_type"), ("name", "object_name"), ("guid", "object_guid")): if payload.get(source_key) is not None and target.get(target_key) is None: target[target_key] = payload.get(source_key) for key in ( "canonical_path", "path", "object_type", "object_name", "object_guid", "form", "form_name", "routine_name", "extension", "preferred_extension", "module_ref", "module_id", "concrete_reference", "file_name", "layer_id", ): if payload.get(key) is not None and target.get(key) is None: target[key] = payload.get(key) requested_path = str(target.get("canonical_path") or target.get("path") or payload.get("canonical_path") or payload.get("path") or "").strip() extension_name = str(target.get("extension") or payload.get("extension") or "").strip() full_extension_target = code_write_extension_form_module_path(requested_path) if full_extension_target: # A full public path is the authority. Do not run a global lookup or # silently change extension/object/form when the caller supplied it. for key, value in full_extension_target.items(): target[key] = value requested_path = str(full_extension_target["canonical_path"]) extension_name = str(full_extension_target["extension"]) requested_parts = [part.strip() for part in requested_path.split(".") if part.strip()] first_part_kind = KIND_ALIASES.get(normalize(requested_parts[0])) if requested_parts else None if extension_name and len(requested_parts) >= 3 and normalize(requested_parts[0]) == normalize(extension_name) and not first_part_kind: rewritten_path = ".".join(["CommonForm", *requested_parts[1:]]) target["path"] = rewritten_path target.setdefault("object_type", "CommonForm") target.setdefault("object_name", requested_parts[1]) target.setdefault("routine_name", requested_parts[-1]) requested_path = rewritten_path if requested_path and not target.get("routine_name") and not payload.get("routine_name"): path_resolution = metadata_write_plan_path_parts(requested_path) if path_resolution.get("routine_name"): target["routine_name"] = path_resolution.get("routine_name") result = { key: value for key, value in payload.items() if key not in { "include_storage", "target", "mode", "execution_mode", "full_text", "code", "fragment", } } result["target"] = target result["target_kind"] = "module" result["mode"] = mode # A Configurator activation consumes the saved-state layer. A later # public-ref write must therefore be allowed to recreate the missing # working copy from the proven active source. The generic save-first # payload below supplies the explicit preparation gate in apply modes; # callers can still opt out with auto_prepare_saved_state=false. if target.get("routine_name") and result.get("routine_name") is None: result["routine_name"] = str(target.get("routine_name") or "") if payload.get("module_text") is None: for alias in ("full_text", "code"): if payload.get(alias) is not None: result["module_text"] = str(payload.get(alias) or "") break if result.get("module_text") is not None and result.get("text") is None: result["text"] = str(result.get("module_text") or "") if payload.get("old") is not None or payload.get("new") is not None: result["_force_fragment_replace"] = True return metadata_write_save_first_payload(result, mode) def code_write_strict_target_error(payload: dict[str, Any], *, require_explicit_target: bool = False) -> dict[str, Any] | None: """Validate the explicit, non-searching public write contract.""" target = payload.get("write_target") if not isinstance(target, dict): # A public 1C object ref plus an exact one-occurrence replacement is # also a non-searching target. metadata_write_resolve_module_target # resolves its one object-module stream, reads the saved-state payload # and carries the resulting compare-and-set guard into the proposal. # Requiring an additional opaque write_target here made a successful # name-first `code.write` plan impossible to apply without exposing # storage coordinates back to the caller. ref = str(payload.get("ref") or "").strip() ref_kind, ref_name = parse_object_query(None, ref) if ref else (None, "") has_exact_public_replace = ( bool(ref_kind and ref_name) and payload.get("old") is not None and payload.get("new") is not None and int(payload.get("occurrences") or 1) == 1 ) if has_exact_public_replace: return None if require_explicit_target: return invalid_argument( CODE_WRITE_METHOD, "write_target", "Applying code.write requires the exact write_target returned by a verified read/resolve step; use metadata.write.plan for discovery.", ) return None path = str(target.get("path") or "").strip() origin = target.get("expected_origin") if isinstance(target.get("expected_origin"), dict) else {} if not path: return invalid_argument(CODE_WRITE_METHOD, "write_target.path", "Explicit writes require a full target path.") if not str(origin.get("extension") or "").strip(): return invalid_argument(CODE_WRITE_METHOD, "write_target.expected_origin.extension", "Explicit extension writes require the exact extension name.") parsed_extension_form = code_write_extension_form_module_path(path) if parsed_extension_form and normalize(str(origin.get("extension") or "")) != normalize(str(parsed_extension_form.get("extension") or "")): return invalid_argument( CODE_WRITE_METHOD, "write_target.expected_origin.extension", "Expected extension must exactly match the extension segment in the full write path.", ) if not str(target.get("expected_sha1") or payload.get("expected_sha1") or "").strip(): return invalid_argument(CODE_WRITE_METHOD, "write_target.expected_sha1", "Explicit writes require the SHA1 returned by the read step.") if payload.get("old") is None or payload.get("new") is None: return invalid_argument(CODE_WRITE_METHOD, "operation", "Explicit code writes require code_before/code_after (old/new).") if int(payload.get("occurrences") or 1) != 1: return invalid_argument(CODE_WRITE_METHOD, "occurrences", "Only exactly one matching code fragment is writable.") return None def code_write_contextual_fragment_payload(payload: dict[str, Any]) -> dict[str, Any] | dict[str, Any]: """Turn before/old/after into one exact replace without ordinal targeting. The writer still performs its normal exactly-one check, now against a stable surrounding context. Keeping that context in both old and new means only the central fragment changes while the anchor is reinserted verbatim. """ before_present = "before" in payload and payload.get("before") is not None after_present = "after" in payload and payload.get("after") is not None if not before_present and not after_present: return payload if payload.get("old") is None or payload.get("new") is None: return invalid_argument(CODE_WRITE_METHOD, "old/new", "Contextual replacement requires both old and new.") before = str(payload.get("before") or "") after = str(payload.get("after") or "") if not before and not after: return invalid_argument(CODE_WRITE_METHOD, "before/after", "Pass a non-empty before or after anchor when using contextual replacement.") old = str(payload.get("old") or "") if not old: return invalid_argument(CODE_WRITE_METHOD, "old", "old must be non-empty for contextual replacement.") result = dict(payload) result["old"] = before + old + after result["new"] = before + str(payload.get("new") or "") + after result["_contextual_fragment"] = {"before": before, "old": old, "after": after, "new": str(payload.get("new") or "")} return result def code_write_already_applied_public_fragment(payload: dict[str, Any]) -> dict[str, Any] | None: """Prove an idempotent public retry before saved-state preparation. A public ``code.write`` deliberately starts from the active owner so that the adapter can create a missing working copy. That is correct for a first apply, but it must not recreate the copy from the active source on a retry after the requested replacement has already been verified in the effective working view. Read the selected routine first and return a no-write result only for the narrow, exact old=0/new=1 case. """ old = payload.get("old") new = payload.get("new") routine_name = str(payload.get("routine_name") or "").strip() base_id = str(payload.get("base_id") or "").strip() if old is None or new is None or not routine_name or not base_id: return None old_text = str(old) new_text = str(new) if not old_text or not new_text: return None # A concrete saved stream is checked by metadata.module.write_apply. The # public path needs this earlier guard because resolving it otherwise # starts from ConfigCAS and may prepare/overwrite a working copy. if str(payload.get("concrete_reference") or payload.get("module_ref") or "").strip(): return None read_payload: dict[str, Any] = { "base_id": base_id, "routine_name": routine_name, "state": "working", "configuration_view": "effective_working", "include_text": True, "include_storage": False, "prefer_code_index": False, "timeout_seconds": int(payload.get("timeout_seconds") or 60), } for key in ("ref", "object_type", "object_name", "extension", "extension_guid", "canonical_path"): value = payload.get(key) if value not in {None, ""}: read_payload[key] = value read_result = code_read(read_payload) if not isinstance(read_result, dict) or str(read_result.get("status") or "") != "ok": return None text = str(read_result.get("text") or "") old_count = text.count(old_text) new_count = text.count(new_text) if old_count != 0 or new_count != 1: return None target = {"kind": "module"} for key in ("ref", "object_type", "object_name", "extension", "canonical_path"): if payload.get(key) not in {None, ""}: target[key] = payload.get(key) return { "schema": "onec_code_write.v1", "method": CODE_WRITE_METHOD, "status": "already_applied", "execution_mode": str(payload.get("execution_mode") or payload.get("mode") or "apply_and_verify").strip().casefold(), "base_id": base_id, "target": target, "operation": "fragment_replace", "applied": False, "counts": {"occurrences": 0, "new_occurrences": 1}, "scope": {"kind": "routine", "routine_name": routine_name}, "write_mode": { "target": "saved_state", "activation_state": "not_activated", "production_apply": False, "sql_write_performed": False, "saved_state_written": False, "saved_state_verified": True, "active_configuration_updated": False, "activation_required": False, }, "verification": {"status": "already_present", "old_occurrences": 0, "new_occurrences": 1, "readback": True}, "diagnostics": {"message": "The exact replacement is already present once in the confirmed effective working routine; no saved-state preparation or SQL write was performed."}, } def code_write_concrete_route_error(payload: dict[str, Any]) -> dict[str, Any] | None: """Reject contradictory public labels around an adapter-issued stream.""" concrete_reference = str(payload.get("concrete_reference") or payload.get("module_ref") or "").strip() if not concrete_reference: return None ref = str(payload.get("ref") or "").strip() canonical_path = str(payload.get("canonical_path") or payload.get("path") or "").strip() if ref and canonical_path: ref_kind, ref_name = parse_object_query(None, ref) path = metadata_write_plan_path_parts(canonical_path) if ref_kind and ref_name and path.get("kind") and path.get("name") and ( canonical_kind(str(ref_kind)) != canonical_kind(str(path.get("kind"))) or normalize(str(ref_name)) != normalize(str(path.get("name"))) ): return { "schema": "onec_code_write.v1", "method": CODE_WRITE_METHOD, "status": "blocked", "error": "concrete_reference_selector_conflict", "diagnostics": {"message": "ref and canonical_path identify different owners; the exact module stream will not be rerouted."}, } return None def code_write_replacement_candidate_payload(payload: dict[str, Any]) -> dict[str, Any]: """Expand one read-only code.search candidate into a code.write request.""" candidate = payload.get("replacement_candidate") if candidate is None: return payload if not isinstance(candidate, dict): return invalid_argument(CODE_WRITE_METHOD, "replacement_candidate", "replacement_candidate must be one object returned by code.search.") if str(candidate.get("status") or "") != "ready": return invalid_argument( CODE_WRITE_METHOD, "replacement_candidate.status", "Only a code.search replacement candidate with status=ready can be used for a write.", ) selector = candidate.get("write_selector") if isinstance(candidate.get("write_selector"), dict) else {} result = dict(payload) result.pop("replacement_candidate", None) selector_target = selector.get("target") if isinstance(selector.get("target"), dict) else {} # A search candidate is an adapter-issued capability, not merely a hint # for another object-module scan. Preserve its exact active stream and # public provenance so code.write can validate and route it to the # adapter-managed saved copy. for key in ( "base_id", "ref", "extension", "routine_name", "expected_text_sha1", "canonical_path", "layer_id", "concrete_reference", "module_ref", ): candidate_value = selector.get(key) if candidate_value in {None, ""}: candidate_value = selector_target.get(key) if candidate_value in {None, ""}: continue existing = result.get(key) if existing not in {None, ""} and str(existing) != str(candidate_value): return invalid_argument(CODE_WRITE_METHOD, key, f"{key} conflicts with replacement_candidate.write_selector.") result[key] = candidate_value for key in ("before", "old", "after"): if key not in candidate: return invalid_argument(CODE_WRITE_METHOD, f"replacement_candidate.{key}", "Replacement candidate is incomplete; obtain it again through code.search with prepare_replace=true.") candidate_value = candidate.get(key) existing = result.get(key) if existing not in {None, ""} and str(existing) != str(candidate_value): return invalid_argument(CODE_WRITE_METHOD, key, f"{key} conflicts with replacement_candidate.") result[key] = candidate_value return result def code_replacement_candidates(text: str, old: str, *, anchor_chars: int = 120, limit: int = 25) -> list[dict[str, Any]]: """Return copy-ready contextual selectors for exact occurrences in BSL. There is deliberately no ordinal selector: each candidate carries enough adjacent source text for code.write to prove that *its context*, rather than merely its position, is unique at apply time. """ source = str(text or "") needle = str(old or "") if not needle: return [] positions: list[int] = [] start = 0 while len(positions) < limit: index = source.find(needle, start) if index < 0: break positions.append(index) start = index + len(needle) candidates: list[dict[str, Any]] = [] for index in positions: before = source[max(0, index - anchor_chars) : index] after = source[index + len(needle) : index + len(needle) + anchor_chars] contextual_old = before + needle + after occurrences = 0 cursor = 0 while True: found = source.find(contextual_old, cursor) if found < 0: break occurrences += 1 cursor = found + max(1, len(contextual_old)) line, column = _snippet_to_line_column(source, index) candidates.append( { "line": line, "column": column, "before": before, "old": needle, "after": after, "context_occurrences": occurrences, "status": "ready" if occurrences == 1 else "needs_more_context", } ) return candidates def code_write(payload: dict[str, Any]) -> dict[str, Any]: method = CODE_WRITE_METHOD started = time.time() payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload payload = code_write_replacement_candidate_payload(payload) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload payload = code_write_contextual_fragment_payload(payload) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload concrete_route_error = code_write_concrete_route_error(payload) if concrete_route_error: return concrete_route_error # A missing mode must never turn a discovery or copied candidate into a # database write. Callers must opt into an apply mode explicitly. mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() apply_trace = mode in {"apply", "apply_and_verify", "apply_and_rollback"} if apply_trace: saved_state_apply_module_trace(phase="code_write_before_gate_context", base_id=str(payload.get("base_id") or "")) strict_error = code_write_strict_target_error( payload, require_explicit_target=mode in {"apply", "apply_and_verify", "apply_and_rollback"}, ) if strict_error: return strict_error if mode in {"apply", "apply_and_verify"}: already_applied = code_write_already_applied_public_fragment(payload) if already_applied is not None: return already_applied payload, context_error = normalize_repository_write_context(payload, method) if context_error: return { "schema": "onec_code_write.v1", "method": method, "status": "blocked", "execution_mode": mode, "base_id": payload.get("base_id"), "applied": False, "write_mode": {"target": "saved_state", "activation_state": "not_activated", "production_apply": False}, **context_error, } resolved_payload = resolve_write_gate_context(payload) supplied_layer_id = str(payload.get("layer_id") or "").strip().casefold() resolved_layer_id = str((resolved_payload.get("development_layer_resolution") or {}).get("layer_id") or "").strip().casefold() if supplied_layer_id and resolved_layer_id and supplied_layer_id != resolved_layer_id: return { "schema": "onec_code_write.v1", "method": method, "status": "blocked", "execution_mode": mode, "base_id": payload.get("base_id"), "applied": False, "error": "concrete_reference_layer_conflict", "diagnostics": {"message": "layer_id does not match the extension resolved for the exact module stream."}, } if apply_trace: saved_state_apply_module_trace(phase="code_write_gate_context_resolved", base_id=str(payload.get("base_id") or "")) write_payload = code_write_metadata_payload(resolved_payload, mode=mode) target = write_payload.get("target") if isinstance(write_payload.get("target"), dict) else {} if apply_trace: saved_state_apply_module_trace(phase="code_write_before_metadata_write", base_id=str(payload.get("base_id") or "")) metadata_result = metadata_write(write_payload) metadata_write_duration_ms = int(metadata_result.get("duration_ms") or 0) if isinstance(metadata_result, dict) else 0 if apply_trace: saved_state_apply_module_trace(phase="code_write_after_metadata_write", base_id=str(payload.get("base_id") or "")) nested_result = metadata_result.get("result") if isinstance(metadata_result.get("result"), dict) else {} apply_result = nested_result.get("apply_result") if isinstance(nested_result.get("apply_result"), dict) else {} applied = bool(nested_result.get("applied") or apply_result.get("applied")) response = { "schema": "onec_code_write.v1", "method": method, "status": metadata_result.get("status"), "execution_mode": mode, "base_id": payload.get("base_id"), "target": code_write_public_target(write_payload, target), "operation": code_write_operation(write_payload), "applied": applied, "write_mode": { "target": "saved_state", "activation_state": "not_activated", "production_apply": False, "sql_write_performed": applied, "saved_state_written": applied, "saved_state_verified": bool( applied and ( apply_result.get("readback", {}).get("verified") is not False if isinstance(apply_result.get("readback"), dict) else True ) ), "active_configuration_updated": False, "activation_required": applied, }, } error_sources = [ item for item in (metadata_result, nested_result, apply_result) if isinstance(item, dict) ] error_source = next((item for item in error_sources if item.get("error")), None) public_route_unresolved = bool( error_source and str(error_source.get("error") or "") in {"module_target_not_resolved", "exact_form_module_not_prepared", "saved_state_prepare_required"} ) public_plan_needs_prepare = public_route_unresolved and mode == "plan" if public_plan_needs_prepare: # A name-first extension module can have no ConfigCASSave row before # its first edit. Apply modes prepare that row internally once the # repository gate is satisfied. Planning must report that public # state, rather than falsely asking an agent for a module_ref/stream. response["status"] = "planned" response["diagnostics"] = { "message": "The public write route is ready. If an extension saved-state copy is absent, the adapter prepares it inside the guarded apply workflow.", "next_action": "review_plan_then_apply", } response["preparation"] = { "required": True, "performed": False, "performed_automatically_on_apply": True, "adapter_managed": True, } elif public_route_unresolved: response["error"] = "public_write_route_unresolved" route_resolution = code_write_public_resolution_summary(error_source) if route_resolution: response["route_resolution"] = route_resolution response["diagnostics"] = { "message": "The adapter could not resolve one safe writable module from the supplied public selector. No storage coordinates are required from the caller; report this adapter route gap with the public object and extension selector.", "next_action": "adapter_development_required", } elif error_source: response["error"] = error_source.get("error") elif response.get("status") == "blocked": response["error"] = "write_blocked" if isinstance(metadata_result.get("counts"), dict): response["counts"] = metadata_result.get("counts") elif isinstance(nested_result.get("counts"), dict): response["counts"] = nested_result.get("counts") if isinstance(metadata_result.get("scope"), dict): response["scope"] = metadata_result.get("scope") elif isinstance(nested_result.get("scope"), dict): response["scope"] = nested_result.get("scope") elif isinstance(nested_result.get("routine"), dict) and nested_result["routine"].get("scope") is not None: routine_scope = nested_result["routine"].get("scope") if str(routine_scope) == "routine": routine_name = ( response.get("target", {}).get("routine_name") if isinstance(response.get("target"), dict) else None ) or write_payload.get("routine_name") response["scope"] = {"kind": "routine", **({"routine_name": routine_name} if routine_name else {})} elif str(routine_scope): response["scope"] = {"kind": str(routine_scope)} if "counts" not in response and isinstance(nested_result.get("routine"), dict) and nested_result["routine"].get("occurrences") is not None: response["counts"] = {"occurrences": int(nested_result["routine"].get("occurrences") or 0)} diagnostics_source = next( (item for item in error_sources if isinstance(item.get("diagnostics"), dict)), None, ) if diagnostics_source and not public_route_unresolved: response["diagnostics"] = diagnostics_source.get("diagnostics") problems_source = next( (item for item in error_sources if isinstance(item.get("problems"), list) and item.get("problems")), None, ) if problems_source: response["failed_guards"] = problems_source.get("problems") next_call_source = next( ( item for item in error_sources if isinstance(item.get("next_call"), dict) or isinstance(item.get("next_resolution"), dict) ), None, ) if next_call_source and not public_route_unresolved: response["next_call"] = next_call_source.get("next_call") or next_call_source.get("next_resolution") if isinstance(metadata_result.get("resolution"), dict): response["resolution"] = metadata_result.get("resolution") prepare_receipt_id = str(metadata_result.get("prepare_receipt_id") or "").strip().lower() if re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): response["prepare_receipt_id"] = prepare_receipt_id routed_method = metadata_result.get("routed_method") if routed_method: response["route"] = {"method": routed_method} backup_ids = collect_backup_ids(metadata_result) if backup_ids: response["_history_evidence"] = {"backup_ids": backup_ids} # Backup ids are opaque recovery capabilities. Expose the one safe # next call instead of making a caller inspect the internal writer # result or guess whether rollback is possible. response["rollback"] = { "available": True, "method": "metadata.write.rollback", "params": { "base_id": payload.get("base_id"), "backup_id": backup_ids[0], "allow_sql_saved_state_rollback": True, }, **({"alternative_backup_ids": backup_ids[1:]} if len(backup_ids) > 1 else {}), } verification = next( ( item.get("verification") for item in (nested_result, apply_result, metadata_result) if isinstance(item.get("verification"), dict) ), None, ) if isinstance(verification, dict): response["verification"] = verification if mode == "apply_and_rollback" and response.get("status") == "verified_and_rolled_back" and re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): cleanup = rollback_saved_state_prepare_receipt( str(payload.get("base_id") or ""), prepare_receipt_id, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) response["prepare_cleanup"] = { "status": cleanup.get("status"), "applied": bool(cleanup.get("applied")), "counts": cleanup.get("counts"), } if not cleanup.get("applied"): response["status"] = "verified_and_rolled_back_prepare_cleanup_failed" if mode == "plan" and response.get("status") == "planned" and write_payload.get("old") is not None and write_payload.get("new") is not None: proposal = nested_result.get("proposal") if isinstance(nested_result.get("proposal"), dict) else {} proposal_edits = proposal.get("edits") if isinstance(proposal.get("edits"), list) else [] first_edit = proposal_edits[0] if proposal_edits and isinstance(proposal_edits[0], dict) else {} contextual = write_payload.get("_contextual_fragment") if isinstance(write_payload.get("_contextual_fragment"), dict) else None response["fragment_replace"] = { "status": "planned_exact_replace", "requested_occurrences": int(write_payload.get("occurrences") or 1), "planned_edits": int((proposal.get("counts") or {}).get("edits") or len(proposal_edits)), "old_text_sha1": first_edit.get("old_text_sha1"), "new_text_sha1": first_edit.get("new_text_sha1"), # This is an explicit, bounded text diff: it contains only the # caller-reviewed fragment, never the whole BSL module. "diff": { "removed": str((contextual or {}).get("old") if contextual else write_payload.get("old") or ""), "added": str((contextual or {}).get("new") if contextual else write_payload.get("new") or ""), }, **( {"context": {"before": contextual.get("before"), "after": contextual.get("after")}} if contextual else {} ), } if mode == "plan" and response.get("status") == "planned": # Keep the next call entirely name-first. The internal module_ref used # to construct this plan is intentionally omitted: code.write will # resolve the same owner/module stream again and its text SHA prevents # a stale plan from applying to changed BSL. contextual_hint = payload.get("_contextual_fragment") if isinstance(payload.get("_contextual_fragment"), dict) else None apply_payload: dict[str, Any] = { "base_id": payload.get("base_id"), **({"ref": payload.get("ref")} if payload.get("ref") else {}), **({"extension": payload.get("extension")} if payload.get("extension") else {}), **({"module": payload.get("module")} if payload.get("module") else {}), **({"old": (contextual_hint or {}).get("old")} if contextual_hint else ({"old": payload.get("old")} if payload.get("old") is not None else {})), **({"new": (contextual_hint or {}).get("new")} if contextual_hint else ({"new": payload.get("new")} if payload.get("new") is not None else {})), "execution_mode": "apply_and_verify", "allow_sql_saved_state_apply": True, } if contextual_hint: apply_payload["before"] = contextual_hint.get("before") apply_payload["after"] = contextual_hint.get("after") text_guard = str(write_payload.get("expected_text_sha1") or "").strip() if text_guard: apply_payload["expected_text_sha1"] = text_guard repository_lock = payload.get("repository_lock") if isinstance(payload.get("repository_lock"), dict) else None if repository_lock: apply_payload["repository_lock"] = repository_lock response["apply_hint"] = { "method": CODE_WRITE_METHOD, # `payload` is deliberately redacted by the generic response # sanitizer because it can contain raw storage bytes. This is a # public, name-first call template, so use `params` instead. "params": apply_payload, "guards": ["exact_old_once", "expected_text_sha1", "saved_state_readback"], "message": "Review the diff, then submit this name-first payload to apply the saved-state change.", } if payload.get("include_storage") is True: response["metadata_write"] = metadata_result if isinstance(resolved_payload.get("owner_resolution"), dict): response["owner_resolution"] = resolved_payload["owner_resolution"] response["duration_ms"] = int((time.time() - started) * 1000) response["timings_ms"] = { "metadata_write": metadata_write_duration_ms, "code_write_facade": max(0, int(response["duration_ms"]) - metadata_write_duration_ms), } if isinstance(metadata_result.get("timings_ms"), dict): response["timings_ms"]["metadata_write_phases"] = metadata_result["timings_ms"] return response def form_write_matrix_target_selector(target: dict[str, Any]) -> dict[str, Any]: path = str(target.get("path") or "").strip() section = str(target.get("_profile_section") or target.get("section") or "").strip() selector: dict[str, Any] = {"element_path": path} if path else {} name = target.get("name") if section == "commands" and name: selector["command"] = name elif section in {"attributes", "attribute_fields"} and name: selector["attribute"] = name elif name: selector["element"] = name return selector def form_write_matrix_infer_value_type(prop: dict[str, Any]) -> str: value_type = str(prop.get("value_type") or "").strip() if value_type: return value_type value = prop.get("value") text = str(value) if value in {True, False} or text in {"0", "1"}: return "bool_atom" if re.fullmatch(r"-?\d+", text or ""): return "integer_atom" return "string" if value is None or isinstance(value, str) else "scalar" def form_write_matrix_test_value(prop: dict[str, Any]) -> tuple[Any | None, str | None]: canonical = normalize_form_property_name(prop.get("canonical_property") or prop.get("property") or prop.get("presentation")) old = prop.get("value") value_type = form_write_matrix_infer_value_type(prop) if canonical in {"id", "name", "path_to_data"}: return None, "identity_or_binding_property" if value_type == "string": old_text = "" if old is None else str(old) if len(old_text) > 160: return None, "string_too_long_for_generic_smoke" suffix = "_SMOKE" if old_text.endswith(suffix): return old_text.removesuffix(suffix) or "SMOKE_VALUE", None return f"{old_text}{suffix}" if old_text else f"SMOKE_{canonical.upper()}", None if value_type == "bool_atom": old_text = str(old) if old_text == "1": return "0", None if old_text == "0": return "1", None return None, "bool_atom_not_0_or_1" if value_type == "bool_or_enum_atom": old_text = str(old) if old_text == "1": return "0", None if old_text == "0": return "1", None return None, "enum_values_unknown" return None, "value_type_not_smoke_safe" def form_write_matrix_build_entry(profile: dict[str, Any], requested_target: dict[str, Any], prop: dict[str, Any]) -> dict[str, Any]: property_name = prop.get("canonical_property") or prop.get("property") or prop.get("presentation") semantic_prop = form_semantic_property_for_parameter(requested_target, prop.get("parameter_index")) value_type = form_write_matrix_infer_value_type(prop) effective_target, effective_source = form_effective_write_target(profile, requested_target, property_name, {"property": property_name}) effective_old = form_property_current_value(effective_target, property_name) test_value, test_value_reason = form_write_matrix_test_value({**prop, "value": effective_old, "value_type": value_type}) if value_type == "string" and effective_old in {None, ""} and not effective_source: test_value = None test_value_reason = "empty_local_string_requires_codec_probe" probe_value = test_value if test_value_reason is None else prop.get("value") path_edit, error = form_element_write_edit(effective_target, {"property": property_name, "value": probe_value}, 0) write_path = path_edit.get("path") if isinstance(path_edit, dict) else None status = "candidate" if write_path else "not_writable" can_smoke = bool(write_path and test_value_reason is None) if status == "not_writable": reason = "property_not_writable" elif not can_smoke: reason = test_value_reason or "not_smoke_safe" else: reason = None return { "status": status, "can_smoke": can_smoke, "reason": reason, "selector": form_write_matrix_target_selector(requested_target), "requested_target": form_write_target_public(requested_target), "effective_target": form_write_target_public(effective_target), "effective_source": effective_source, "property": { "property": prop.get("property"), "canonical_property": normalize_form_property_name(property_name), "presentation": prop.get("presentation") or property_name, "semantic_name": semantic_prop.get("name") if semantic_prop else None, "semantic_group": semantic_prop.get("group") if semantic_prop else None, "semantic_source": semantic_prop.get("source") if semantic_prop else None, "semantic_status": semantic_prop.get("status") if semantic_prop else None, "parameter_index": prop.get("parameter_index"), "read_path": prop.get("path"), "write_path": write_path, "old": effective_old, "requested_old": prop.get("value"), "test_value": test_value if can_smoke else None, "value_type": value_type, "verification": prop.get("verification"), }, **({"error": error} if error else {}), } def metadata_form_write_matrix_build(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_MATRIX_BUILD_METHOD payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error resolved_source = write_learning_resolve_file(payload, method=method, timeout_seconds=int(timeout_seconds or 30)) if isinstance(resolved_source, dict): return resolved_source table, file_name = resolved_source decode_payload = dict(payload) for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): decode_payload.pop(owner_selector_key, None) decoded = metadata_form_decode( { **decode_payload, "base_id": base_id, "table": table, "file_name": file_name, "include_storage": True, "include_parameters": True, "max_items": int(payload.get("max_items") or 5000), "timeout_seconds": int(timeout_seconds or 30), } ) if decoded.get("status") != "ok": result = dict(decoded) result["method"] = method return result profile = decoded.get("profile") if isinstance(decoded.get("profile"), dict) else {} codec_probe_by_path: dict[str, dict[str, Any]] = {} try: from parser.payload import decode_payload_lossless, inspect_brace_text_path data, _config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if not read_error and data: decoded_payload = decode_payload_lossless(data) text = decoded_payload.get("text") if isinstance(text, str): probe_paths = set() for target in form_profile_write_targets(profile): for prop in form_write_target_writable_properties(target): if not isinstance(prop, dict): continue property_name = prop.get("canonical_property") or prop.get("property") or prop.get("presentation") value_type = form_write_matrix_infer_value_type(prop) effective_target, effective_source = form_effective_write_target(profile, target, property_name, {"property": property_name}) effective_old = form_property_current_value(effective_target, property_name) if value_type == "string" and effective_old in {None, ""} and not effective_source: path_edit, _error = form_element_write_edit(effective_target, {"property": property_name, "value": prop.get("value")}, 0) if isinstance(path_edit, dict) and path_edit.get("path"): probe_paths.add(str(path_edit.get("path"))) for probe_path in sorted(probe_paths): try: codec_probe_by_path[probe_path] = inspect_brace_text_path(text, probe_path, max_depth=2, max_children=8) except Exception as exc: codec_probe_by_path[probe_path] = {"path": probe_path, "error": str(exc)} except Exception: codec_probe_by_path = {} entries = [] for target in form_profile_write_targets(profile): for prop in form_write_target_writable_properties(target): if not isinstance(prop, dict) or not prop.get("path"): continue entry = form_write_matrix_build_entry(profile, target, prop) write_path = ((entry.get("property") or {}) if isinstance(entry.get("property"), dict) else {}).get("write_path") if entry.get("reason") == "empty_local_string_requires_codec_probe" and write_path in codec_probe_by_path: entry["codec_probe"] = codec_probe_by_path[str(write_path)] probe_node = entry["codec_probe"].get("node") if isinstance(entry["codec_probe"], dict) else {} if isinstance(probe_node, dict) and probe_node.get("type") == "string": entry["can_smoke"] = True entry["reason"] = None prop_info = entry.get("property") if isinstance(entry.get("property"), dict) else {} canonical = normalize_form_property_name(prop_info.get("canonical_property") or prop_info.get("property") or prop_info.get("presentation")) prop_info["test_value"] = f"SMOKE_{canonical.upper()}" entry["property"] = prop_info elif isinstance(probe_node, dict) and probe_node.get("type") == "list": entry["reason"] = "composite_node_requires_semantic_rule" entries.append(entry) can_smoke = [entry for entry in entries if entry.get("can_smoke")] return { "schema": "onec_form_write_matrix.v1", "method": method, "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "table": table, "file_name": file_name}, "form": decoded.get("form"), "entries": entries, "counts": { "entries": len(entries), "candidates": sum(1 for entry in entries if entry.get("status") == "candidate"), "can_smoke": len(can_smoke), "not_smoke_safe": sum(1 for entry in entries if entry.get("status") == "candidate" and not entry.get("can_smoke")), "not_writable": sum(1 for entry in entries if entry.get("status") == "not_writable"), }, } def metadata_form_write_matrix_smoke(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_MATRIX_SMOKE_METHOD payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if not allow_apply: return invalid_argument(method, "allow_sql_saved_state_apply", "Write-matrix smoke is opt-in; pass allow_sql_saved_state_apply=true.") allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "Write-matrix smoke requires rollback opt-in; pass allow_sql_saved_state_rollback=true.") timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error max_candidates, max_candidates_error = parse_int_argument(payload, "max_candidates", method=method, default=100, minimum=1, maximum=5000) if max_candidates_error: return max_candidates_error matrix = metadata_form_write_matrix_build(payload) if matrix.get("status") != "ok": result = dict(matrix) result["method"] = method return result source = matrix.get("source") if isinstance(matrix.get("source"), dict) else {} results = [] candidates = [entry for entry in matrix.get("entries") or [] if isinstance(entry, dict) and entry.get("can_smoke")] for entry in candidates[: int(max_candidates or 100)]: prop = entry.get("property") if isinstance(entry.get("property"), dict) else {} selector = entry.get("selector") if isinstance(entry.get("selector"), dict) else {} write_payload = { "base_id": base_id, "target": { "kind": "form", "table": source.get("table"), "file_name": source.get("file_name"), **selector, }, "mode": "apply_and_rollback", "edits": [{"property": prop.get("canonical_property") or prop.get("property"), "value": prop.get("test_value")}], "allow_sql_saved_state_apply": True, "allow_sql_saved_state_rollback": True, "timeout_seconds": int(timeout_seconds or 30), } write_result = metadata_write(write_payload) results.append( { "status": "verified" if write_result.get("status") == "verified_and_rolled_back" else str(write_result.get("status") or "error"), "entry": entry, "metadata_write": write_payload, "result_status": write_result.get("status"), "error": (((write_result.get("result") or {}).get("proposal") or {}).get("error") if isinstance(write_result.get("result"), dict) else write_result.get("error")), "diagnostics": ((write_result.get("result") or {}).get("diagnostics") if isinstance(write_result.get("result"), dict) else write_result.get("diagnostics")), "rolled_back": ((write_result.get("result") or {}) if isinstance(write_result.get("result"), dict) else {}).get("rolled_back"), "semantic_verification": (((write_result.get("result") or {}).get("apply_result") or {}).get("semantic_verification") if isinstance(write_result.get("result"), dict) else None), } ) report = { "schema": "onec_form_write_matrix_smoke.v1", "method": method, "status": "ok" if all(row.get("status") == "verified" for row in results) else "partial", "base_id": base_id, "source": source, "form": matrix.get("form"), "results": results, "counts": { "matrix_entries": (matrix.get("counts") or {}).get("entries"), "can_smoke": len(candidates), "smoked": len(results), "verified": sum(1 for row in results if row.get("status") == "verified"), "failed": sum(1 for row in results if row.get("status") != "verified"), "not_smoked": max(0, len(candidates) - len(results)), }, } learning_id = str(payload.get("learning_id") or "").strip() if learning_id: if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", learning_id): return invalid_argument(method, "learning_id", "learning_id must be 1-80 chars: letters, digits, dot, underscore, or dash.") root = write_learning_dir() / learning_id root.mkdir(parents=True, exist_ok=True) path = root / f"write-matrix-smoke-{uuid.uuid4().hex}.json" path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") report["path"] = str(path) return report def write_learning_dir() -> Path: return Path(os.environ.get("ONEC_ADAPTER_WRITE_LEARNING_DIR") or "/data/adapter-write-learning") def write_learning_stage_path(learning_id: str, stage: str, snapshot_id: str) -> Path: return write_learning_dir() / learning_id / f"{stage}-{snapshot_id}.json" def write_learning_latest_path(learning_id: str, stage: str) -> Path: return write_learning_dir() / learning_id / f"latest-{stage}.json" def write_learning_capture_targets(profile: dict[str, Any]) -> list[dict[str, Any]]: targets = [] for target in form_profile_write_targets(profile): public = form_write_target_public(target) public["writable_properties"] = form_write_target_writable_properties(target) targets.append(public) return targets def write_learning_resolve_file(payload: dict[str, Any], *, method: str, timeout_seconds: int) -> dict[str, Any] | tuple[str, str]: table = str(payload.get("table") or "ConfigCASSave") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be captured for write learning.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) file_name = str(payload.get("file_name") or "").strip() if file_name: return table, file_name resolve_payload = { **payload, "table": table, "property": payload.get("property") or "Заголовок", "value": payload.get("value") if "value" in payload else "", "timeout_seconds": timeout_seconds, } resolved = metadata_form_write_target_resolve(resolve_payload) if resolved.get("status") != "ok": result = dict(resolved) result["method"] = method return result source = resolved.get("source") if isinstance(resolved.get("source"), dict) else {} file_name = str(source.get("file_name") or "") if not file_name: return invalid_argument(method, "file_name", "Could not resolve saved-state form file_name for write learning.") return table, file_name def saved_state_extension_protocol_bundle( *, base_id: str, table: str, file_name: str, source_data: bytes, timeout_seconds: int, ) -> dict[str, Any] | None: """Capture only evidenced ConfigCASSave protocol facts for later diffing. The working extension manifest (``__configinfo``) is intentionally not generated or edited here. We retain hashes and its decoded SHA-1 references, which lets a before/after Configurator save reveal an actual transform without persisting opaque payload text or guessing its fields. """ if table != "ConfigCASSave": return None extension_guid, separator, _suffix = file_name.partition("__") extension_guid = extension_guid.strip().lower() if not separator or not is_guid_text(extension_guid): return None configinfo_name = f"{extension_guid}__configinfo" configinfo_data, _config, read_error = read_storage_file_bytes( base_id, table, configinfo_name, timeout_seconds=timeout_seconds ) result: dict[str, Any] = { "extension_guid": extension_guid, "source_file_name": file_name, "source_sha1": hashlib.sha1(source_data or b"").hexdigest(), "configinfo_file_name": configinfo_name, "configinfo": {"status": "missing" if read_error else "present"}, } if read_error or configinfo_data is None: return result result["configinfo"].update( {"sha1": hashlib.sha1(configinfo_data).hexdigest(), "bytes": len(configinfo_data)} ) try: from parser.payload import decode_payload_lossless, parse_brace_text text = str(decode_payload_lossless(configinfo_data).get("text") or "") references = sorted( { decoded.hex() for value in re.findall(r"(? 1 and isinstance(root_items[1], dict) and isinstance(root_items[1].get("items"), list) else [] ) for index, node in enumerate(service_items): if not isinstance(node, dict) or node.get("type") not in {"atom", "string"}: continue value = str(node.get("value") or "") compact_value = re.sub(r"\s+", "", value) try: decoded_value = base64.b64decode(compact_value, validate=True) except Exception: continue if len(decoded_value) == 20: continue service_atoms.append( { "path": f"items[1]/items[{index}]", "base64": compact_value, "bytes": len(decoded_value), "sha1": hashlib.sha1(decoded_value).hexdigest(), } ) result["configinfo"].update( { "decode_status": "ok", "sha1_references": references, "file_sha1_references": file_sha1_references, "service_atoms": service_atoms, "references_source_sha1": result["source_sha1"] in references, } ) except Exception as exc: result["configinfo"].update({"decode_status": "unresolved", "diagnostics": {"message": str(exc)}}) return result def rewrite_extension_configinfo_file_sha1_reference( configinfo_data: bytes, *, logical_file_name: str, replacement_file_data: bytes, ) -> dict[str, Any]: """Losslessly replace one proven ``__configinfo`` file-SHA1 reference. This is deliberately a pure codec helper: it does not open SQL, select a target, or apply a mutation. The opaque service atoms are preserved because only the exact Base64 value paired with ``logical_file_name`` is replaced. Callers must still pair the resulting ConfigInfo bytes with the changed saved-state stream in one transaction. """ if not logical_file_name or Path(logical_file_name).name != logical_file_name: return { "status": "invalid_logical_file_name", "diagnostics": {"message": "logical_file_name must be one safe ConfigInfo logical file name."}, } try: from parser.payload import decode_payload_lossless, encode_payload_lossless decoded = decode_payload_lossless(configinfo_data) text = str(decoded.get("text") or "") except Exception as exc: return {"status": "decode_error", "diagnostics": {"message": str(exc)}} needle = f'"{logical_file_name}",' positions: list[int] = [] offset = 0 while True: found = text.find(needle, offset) if found < 0: break positions.append(found) offset = found + len(needle) if len(positions) != 1: return { "status": "reference_not_unique", "logical_file_name": logical_file_name, "occurrences": len(positions), "diagnostics": {"message": "ConfigInfo logical file reference must occur exactly once."}, } value_start = positions[0] + len(needle) value_end = value_start while value_end < len(text) and text[value_end] not in ",\r\n}": value_end += 1 old_value = text[value_start:value_end].strip() try: old_sha1 = base64.b64decode(old_value, validate=True) except Exception: old_sha1 = b"" if len(old_sha1) != 20: return { "status": "invalid_reference_value", "logical_file_name": logical_file_name, "diagnostics": {"message": "The selected ConfigInfo value is not a Base64-encoded 20-byte SHA-1."}, } replacement_sha1 = hashlib.sha1(replacement_file_data).digest() replacement_value = base64.b64encode(replacement_sha1).decode("ascii") rewritten_text = text[:value_start] + replacement_value + text[value_end:] try: rewritten_data = encode_payload_lossless(decoded, text=rewritten_text) roundtrip_text = str(decode_payload_lossless(rewritten_data).get("text") or "") except Exception as exc: return {"status": "encode_error", "diagnostics": {"message": str(exc)}} if roundtrip_text != rewritten_text: return {"status": "roundtrip_mismatch", "diagnostics": {"message": "ConfigInfo lossless codec did not preserve rewritten text."}} return { "status": "ok", "logical_file_name": logical_file_name, "original_file_sha1": old_sha1.hex(), "replacement_file_sha1": replacement_sha1.hex(), "original_reference_base64": old_value, "replacement_reference_base64": replacement_value, "original_configinfo_sha1": hashlib.sha1(configinfo_data).hexdigest(), "replacement_configinfo_sha1": hashlib.sha1(rewritten_data).hexdigest(), "payload": rewritten_data, } def build_extension_saved_state_pair_plan( *, logical_file_name: str, current_file_data: bytes, replacement_file_data: bytes, current_configinfo_data: bytes, ) -> dict[str, Any]: """Build, but do not apply, the verified payload pair for one extension edit. A valid pair changes the selected saved-state stream and its one matching ConfigInfo SHA-1 reference. It deliberately does not generate service atoms, create missing object rows, or choose a SQL transaction target. """ current_file_sha1 = hashlib.sha1(current_file_data).hexdigest() map_rewrite = rewrite_extension_configinfo_file_sha1_reference( current_configinfo_data, logical_file_name=logical_file_name, replacement_file_data=replacement_file_data, ) if map_rewrite.get("status") != "ok": return { "status": "configinfo_rewrite_failed", "logical_file_name": logical_file_name, "current_file_sha1": current_file_sha1, "configinfo": map_rewrite, } mapped_current_sha1 = str(map_rewrite.get("original_file_sha1") or "") if mapped_current_sha1 != current_file_sha1: return { "status": "configinfo_reference_mismatch", "logical_file_name": logical_file_name, "current_file_sha1": current_file_sha1, "mapped_current_file_sha1": mapped_current_sha1, "diagnostics": { "message": "ConfigInfo does not point to the current saved-state stream. Refuse to overwrite a divergent pending overlay." }, } return { "status": "ok", "logical_file_name": logical_file_name, "module": { "original_sha1": current_file_sha1, "replacement_sha1": hashlib.sha1(replacement_file_data).hexdigest(), "payload": replacement_file_data, }, "configinfo": { "original_sha1": str(map_rewrite["original_configinfo_sha1"]), "replacement_sha1": str(map_rewrite["replacement_configinfo_sha1"]), "payload": map_rewrite["payload"], "service_atoms": "preserved_unmodified", }, } def metadata_write_learning_capture(payload: dict[str, Any], stage: str) -> dict[str, Any]: method = f"metadata.write_learning.capture_{stage}" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error resolved_source = write_learning_resolve_file(payload, method=method, timeout_seconds=int(timeout_seconds or 30)) if isinstance(resolved_source, dict): return resolved_source table, file_name = resolved_source decode_payload = dict(payload) for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): decode_payload.pop(owner_selector_key, None) decoded = metadata_form_decode( { **decode_payload, "base_id": base_id, "table": table, "file_name": file_name, "include_storage": True, "include_parameters": True, "max_items": int(payload.get("max_items") or 5000), "timeout_seconds": int(timeout_seconds or 30), } ) if decoded.get("status") != "ok": result = dict(decoded) result["method"] = method return result data, config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if read_error: result = dict(read_error) result["method"] = method return result profile = decoded.get("profile") if isinstance(decoded.get("profile"), dict) else {} protocol_bundle = saved_state_extension_protocol_bundle( base_id=base_id, table=table, file_name=file_name, source_data=data or b"", timeout_seconds=int(timeout_seconds or 30), ) protocol_raw_evidence = None if payload.get("include_protocol_raw") is True and protocol_bundle: configinfo_name = str(protocol_bundle.get("configinfo_file_name") or "") configinfo_data, _protocol_config, protocol_read_error = read_storage_file_bytes( base_id, table, configinfo_name, timeout_seconds=int(timeout_seconds or 30) ) if not protocol_read_error and configinfo_data is not None: # Deliberately stored only in the adapter-local learning artifact, # never returned by the capture API. This is an explicit protocol # experiment record, not a general payload-exfiltration surface. protocol_raw_evidence = { "source_payload_base64": base64.b64encode(data or b"").decode("ascii"), "configinfo_payload_base64": base64.b64encode(configinfo_data).decode("ascii"), } learning_id = str(payload.get("learning_id") or uuid.uuid4().hex).strip() if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", learning_id): return invalid_argument(method, "learning_id", "learning_id must be 1-80 chars: letters, digits, dot, underscore, or dash.") snapshot_id = uuid.uuid4().hex captured_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") artifact = { "schema": "onec_write_learning_capture.v1", "method": method, "status": "ok", "learning_id": learning_id, "snapshot_id": snapshot_id, "stage": stage, "captured_at_utc": captured_at, "base_id": base_id, "source": { "kind": "live_sql", "server": (config or {}).get("server"), "database": (config or {}).get("database"), "table": table, "file_name": file_name, }, "form": decoded.get("form"), "storage": {"sha1": hashlib.sha1(data or b"").hexdigest(), "bytes": len(data or b"")}, **({"extension_saved_state_protocol": protocol_bundle} if protocol_bundle else {}), **({"protocol_raw_evidence": protocol_raw_evidence} if protocol_raw_evidence else {}), "targets": write_learning_capture_targets(profile), "counts": { "targets": len(form_profile_write_targets(profile)), "writable_properties": sum(len(form_write_target_writable_properties(target)) for target in form_profile_write_targets(profile)), }, } root = write_learning_dir() / learning_id root.mkdir(parents=True, exist_ok=True) path = write_learning_stage_path(learning_id, stage, snapshot_id) path.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") write_learning_latest_path(learning_id, stage).write_text(json.dumps({"snapshot_id": snapshot_id, "path": str(path)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return { "schema": "onec_write_learning_capture_result.v1", "method": method, "status": "ok", "learning_id": learning_id, "snapshot_id": snapshot_id, "stage": stage, "path": str(path), "source": artifact["source"], "form": artifact.get("form"), "storage": artifact["storage"], **({"extension_saved_state_protocol": protocol_bundle} if protocol_bundle else {}), "counts": artifact["counts"], } def write_learning_load_capture(payload: dict[str, Any], stage: str) -> dict[str, Any] | tuple[dict[str, Any], Path]: method = str(payload.get("_method") or "metadata.write_learning.diff") snapshot_key = f"{stage}_snapshot_id" path_key = f"{stage}_path" if payload.get(path_key): path = Path(str(payload.get(path_key))).resolve() else: learning_id = str(payload.get("learning_id") or "").strip() if not learning_id: return invalid_argument(method, "learning_id", "Pass learning_id or explicit before_path/after_path.") if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", learning_id): return invalid_argument(method, "learning_id", "Invalid learning_id.") snapshot_id = str(payload.get(snapshot_key) or "").strip() if snapshot_id: path = write_learning_stage_path(learning_id, stage, snapshot_id).resolve() else: latest = write_learning_latest_path(learning_id, stage).resolve() if not latest.is_file(): return invalid_argument(method, snapshot_key, f"No latest {stage} capture found for learning_id.") try: pointer = json.loads(latest.read_text(encoding="utf-8")) except Exception as exc: return invalid_argument(method, snapshot_key, f"Could not read latest {stage} pointer: {exc}") path = Path(str(pointer.get("path") or "")).resolve() root = write_learning_dir().resolve() try: path.relative_to(root) except ValueError: return invalid_argument(method, path_key, "Capture path must be inside the write-learning directory.") if not path.is_file(): return invalid_argument(method, path_key, "Capture file was not found.") try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: return invalid_argument(method, path_key, f"Could not read capture JSON: {exc}") return data, path def write_learning_target_key(target: dict[str, Any]) -> str: return "|".join(str(target.get(key) or "") for key in ("section", "path", "name", "id")) def write_learning_stable_target_key(target: dict[str, Any]) -> str: return "|".join(str(target.get(key) or "") for key in ("section", "name", "id")) def write_learning_target_map(capture: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for target in capture.get("targets") or []: if not isinstance(target, dict): continue key = write_learning_stable_target_key(target) if key and key not in result: result[key] = target return result def write_learning_property_map(capture: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for target in capture.get("targets") or []: if not isinstance(target, dict): continue target_key = write_learning_target_key(target) for prop in target.get("writable_properties") or []: if not isinstance(prop, dict) or not prop.get("path"): continue key = f"{target_key}|{prop.get('path')}" result[key] = {"target": target, "property": prop} return result def write_learning_extension_protocol_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any] | None: before_bundle = before.get("extension_saved_state_protocol") after_bundle = after.get("extension_saved_state_protocol") if not isinstance(before_bundle, dict) or not isinstance(after_bundle, dict): return None before_info = before_bundle.get("configinfo") if isinstance(before_bundle.get("configinfo"), dict) else {} after_info = after_bundle.get("configinfo") if isinstance(after_bundle.get("configinfo"), dict) else {} before_pairs = before_info.get("file_sha1_references") if isinstance(before_info.get("file_sha1_references"), dict) else {} after_pairs = after_info.get("file_sha1_references") if isinstance(after_info.get("file_sha1_references"), dict) else {} before_atoms = { str(item.get("path")): item for item in (before_info.get("service_atoms") or []) if isinstance(item, dict) and item.get("path") } after_atoms = { str(item.get("path")): item for item in (after_info.get("service_atoms") or []) if isinstance(item, dict) and item.get("path") } changed_pairs = [ {"file_name": name, "old_sha1": before_pairs.get(name), "new_sha1": after_pairs.get(name)} for name in sorted(set(before_pairs) | set(after_pairs)) if before_pairs.get(name) != after_pairs.get(name) ] changed_atoms = [ {"path": path, "old": before_atoms.get(path), "new": after_atoms.get(path)} for path in sorted(set(before_atoms) | set(after_atoms)) if before_atoms.get(path) != after_atoms.get(path) ] source_changed = before_bundle.get("source_sha1") != after_bundle.get("source_sha1") configinfo_changed = before_info.get("sha1") != after_info.get("sha1") return { "status": "changed" if source_changed or configinfo_changed or changed_pairs or changed_atoms else "no_changes", "source": { "file_name": after_bundle.get("source_file_name") or before_bundle.get("source_file_name"), "old_sha1": before_bundle.get("source_sha1"), "new_sha1": after_bundle.get("source_sha1"), }, "configinfo": { "file_name": after_bundle.get("configinfo_file_name") or before_bundle.get("configinfo_file_name"), "old_sha1": before_info.get("sha1"), "new_sha1": after_info.get("sha1"), }, "file_sha1_reference_changes": changed_pairs, "service_atom_changes": changed_atoms, } def metadata_write_learning_diff(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.write_learning.diff" before_loaded = write_learning_load_capture({**payload, "_method": method}, "before") if isinstance(before_loaded, dict): return before_loaded after_loaded = write_learning_load_capture({**payload, "_method": method}, "after") if isinstance(after_loaded, dict): return after_loaded before, before_path = before_loaded after, after_path = after_loaded before_targets = write_learning_target_map(before) after_targets = write_learning_target_map(after) before_props = write_learning_property_map(before) after_props = write_learning_property_map(after) changes = [] target_moves = [] for key, after_target in after_targets.items(): before_target = before_targets.get(key) if not before_target: continue before_target_path = str(before_target.get("path") or "") after_target_path = str(after_target.get("path") or "") if before_target_path == after_target_path: continue target_moves.append( { "target": { "section": after_target.get("section"), "name": after_target.get("name"), "id": after_target.get("id"), "marker": after_target.get("marker"), "type_name": after_target.get("type_name"), }, "old_path": before_target_path, "new_path": after_target_path, "presentation": f"{after_target.get('name') or after_target.get('title') or after_target_path}: {before_target_path} -> {after_target_path}", } ) for key, after_row in after_props.items(): before_row = before_props.get(key) if not before_row: continue before_prop = before_row["property"] after_prop = after_row["property"] if str(before_prop.get("value")) == str(after_prop.get("value")): continue changes.append( { "target": after_row["target"], "property": { "property": after_prop.get("property"), "canonical_property": after_prop.get("canonical_property"), "presentation": after_prop.get("presentation"), "path": after_prop.get("path"), "value_type": after_prop.get("value_type"), "verification": after_prop.get("verification"), }, "old": before_prop.get("value"), "new": after_prop.get("value"), "presentation": f"{after_row['target'].get('name') or after_row['target'].get('title') or after_row['target'].get('path')}.{after_prop.get('presentation') or after_prop.get('property')}: {before_prop.get('value')} -> {after_prop.get('value')}", } ) protocol_diff = write_learning_extension_protocol_diff(before, after) status = "changed" if changes or target_moves or (protocol_diff or {}).get("status") == "changed" else "no_changes" return { "schema": "onec_write_learning_diff.v1", "method": method, "status": status, "learning_id": after.get("learning_id") or before.get("learning_id"), "base_id": after.get("base_id") or before.get("base_id"), "before": {"snapshot_id": before.get("snapshot_id"), "path": str(before_path), "storage": before.get("storage")}, "after": {"snapshot_id": after.get("snapshot_id"), "path": str(after_path), "storage": after.get("storage")}, "source": after.get("source") or before.get("source"), "form": after.get("form") or before.get("form"), "changes": changes, "target_moves": target_moves, **({"extension_saved_state_protocol": protocol_diff} if protocol_diff else {}), "counts": {"changes": len(changes), "target_moves": len(target_moves), "before_targets": len(before.get("targets") or []), "after_targets": len(after.get("targets") or [])}, } def metadata_write_learning_infer_rule(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.write_learning.infer_rule" diff = payload.get("diff") if isinstance(payload.get("diff"), dict) else metadata_write_learning_diff(payload) if diff.get("status") not in {"changed", "ok"}: return { "schema": "onec_write_learning_rule.v1", "method": method, "status": diff.get("status") or "error", "diff": diff, "diagnostics": {"message": "No changed writable properties were found."}, } changes = [change for change in diff.get("changes") or [] if isinstance(change, dict)] target_moves = [move for move in diff.get("target_moves") or [] if isinstance(move, dict)] if target_moves and not changes: return { "schema": "onec_write_learning_rule.v1", "method": method, "status": "structural_move_rule_required", "diff": diff, "diagnostics": { "message": "The manual edit moved form targets in the element tree. Property write inference is not enough; add a reorder/move structural writer.", "next_action": "Implement a saved-state form move operation that swaps or reorders sibling nodes while preserving nested child nodes.", }, "rule": {"operation": "form_target_move", "moves": target_moves}, "counts": {"changes": len(changes), "target_moves": len(target_moves)}, } if not changes: return invalid_argument(method, "diff.changes", "No changes available to infer a rule.") if len(changes) > 1 and payload.get("allow_multiple") is not True: return { "schema": "onec_write_learning_rule.v1", "method": method, "status": "ambiguous", "diff": diff, "diagnostics": {"message": "More than one writable property changed. Pass allow_multiple=true or narrow the manual edit."}, "counts": {"changes": len(changes)}, } source = diff.get("source") if isinstance(diff.get("source"), dict) else {} edits = [] target = changes[0].get("target") if isinstance(changes[0].get("target"), dict) else {} for change in changes: prop = change.get("property") if isinstance(change.get("property"), dict) else {} edits.append( { "property": prop.get("canonical_property") or prop.get("presentation") or prop.get("property"), "value": change.get("new"), "expected_old": change.get("old"), } ) write_payload = { "method": "metadata.write", "payload": { "base_id": payload.get("base_id") or diff.get("base_id"), "target": { "kind": "form", "table": source.get("table"), "file_name": source.get("file_name"), "element_path": target.get("path"), }, "mode": payload.get("mode") or "plan", "edits": edits, }, } return { "schema": "onec_write_learning_rule.v1", "method": method, "status": "ok", "learning_id": diff.get("learning_id"), "rule": { "kind": "form_property_write", "source": source, "target": target, "edits": edits, "changes": changes, }, "metadata_write": write_payload, } def validate_metadata_object_full_payload(payload: dict[str, Any]) -> dict[str, Any] | None: selector_error = validate_object_selector_arguments(payload, "metadata.object.full") if selector_error: return selector_error table_or_error = metadata_storage_table(payload, "metadata.object.full") if isinstance(table_or_error, dict): return table_or_error if "sections" in payload: raw_sections = payload.get("sections") if not isinstance(raw_sections, list): return invalid_argument( "metadata.object.full", "sections", "sections must be a JSON array of section names.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) if not raw_sections: return invalid_argument( "metadata.object.full", "sections", "sections must contain at least one section name.", allowed_values=sorted(FULL_METHOD_SECTIONS | {FULL_METHOD_ALL_KEY}), ) requested_sections: list[str] = [] allowed_sections = set(FULL_METHOD_SECTIONS) for section in raw_sections: if not isinstance(section, str): return invalid_argument( "metadata.object.full", "sections", "sections must be a JSON array of section names.", allowed_values=sorted(allowed_sections | {FULL_METHOD_ALL_KEY}), ) section_name = section.strip().lower() if section_name == FULL_METHOD_ALL_KEY: for candidate in FULL_METHOD_SECTION_ORDER: if candidate not in requested_sections: requested_sections.append(candidate) continue if section_name not in allowed_sections: return invalid_argument( "metadata.object.full", "sections", f"Unsupported section `{section}`.", allowed_values=sorted(allowed_sections | {FULL_METHOD_ALL_KEY}), ) if section_name not in requested_sections: requested_sections.append(section_name) payload["_sections"] = requested_sections if "only" in payload: return invalid_argument( "metadata.object.full", "only", "metadata.object.full does not support `only`; use metadata.object.attributes for field subset selection.", allowed_values=["all", "attributes", "tabular_sections", "dimensions", "resources", "register_fields"], ) for name, default in [ ("include_storage", False), ("include_module_text", False), ("include_form_details", True), ("include_form_module_text", False), ("include_template_details", False), ("include_template_preview", True), ("include_parts_summary", False), ("include_parameters", True), ]: _, bool_error = strict_bool_argument(payload, name, method="metadata.object.full", default=default) if bool_error: return bool_error evidence_mode, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.full") if evidence_mode_error: return evidence_mode_error payload["_evidence_mode"] = evidence_mode for name, default, minimum, maximum in [ ("limit", 20, 1, None), ("max_forms", 20, 1, 100), ("max_form_items", 1000, 1, 5000), ("max_parameters", 80, 1, 500), ("max_items", 1000, 1, 5000), ("timeout_seconds", 60, 1, None), ("section_timeout_seconds", 0, 1, None), ("_section_timeout_seconds", 0, 1, None), ("adapter_timeout_seconds", 0, 1, None), ("_adapter_timeout_seconds", 0, 1, None), ]: _, int_error = parse_int_argument(payload, name, method="metadata.object.full", default=default, minimum=minimum, maximum=maximum) if int_error: return int_error return None def metadata_object_full(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.object.full") if isinstance(base_id_or_error, dict): return base_id_or_error normalized_payload = normalize_object_selector_aliases(payload, "metadata.object.full") if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload if not has_object_selector(payload): return invalid_argument("metadata.object.full", "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) validation_error = validate_metadata_object_full_payload(payload) if validation_error: return validation_error return adapter_start_job({"method": "metadata.object.full", "payload": payload}) def adapter_now() -> float: return time.time() def adapter_job_store_path() -> Path: """Legacy JSON path used only for one-time migration to local SQLite.""" return Path(os.environ.get("ONEC_ADAPTER_JOB_STORE") or str(cache_db_path().with_name("adapter-jobs.json"))) def adapter_state_db_path() -> Path: """Local adapter state; never points at a configured 1C database.""" return Path(os.environ.get("ONEC_ADAPTER_STATE_DB") or str(cache_db_path())) def adapter_state_connection() -> sqlite3.Connection: path = adapter_state_db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute( """ CREATE TABLE IF NOT EXISTS adapter_jobs ( job_id TEXT PRIMARY KEY, method TEXT, status TEXT NOT NULL, updated_at REAL NOT NULL, payload_json TEXT NOT NULL ) """ ) conn.execute("CREATE INDEX IF NOT EXISTS idx_adapter_jobs_updated_at ON adapter_jobs(updated_at)") conn.execute( """ CREATE TABLE IF NOT EXISTS adapter_state_meta ( key TEXT PRIMARY KEY, value TEXT, updated_at REAL NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS configuration_activation_requests ( request_id TEXT PRIMARY KEY, base_id TEXT NOT NULL, layer TEXT NOT NULL, fingerprint TEXT NOT NULL, status TEXT NOT NULL, created_at REAL NOT NULL, expires_at REAL NOT NULL, updated_at REAL NOT NULL, payload_json TEXT NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS configuration_activation_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, event TEXT NOT NULL, occurred_at REAL NOT NULL, details_json TEXT NOT NULL ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_configuration_activation_requests_base_status " "ON configuration_activation_requests(base_id, status, created_at)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_configuration_activation_events_request " "ON configuration_activation_events(request_id, event_id)" ) conn.execute( """ INSERT INTO adapter_state_meta(key, value, updated_at) VALUES('adapter_state_schema_version', ?, ?) ON CONFLICT(key) DO UPDATE SET value=CASE WHEN CAST(adapter_state_meta.value AS INTEGER) < CAST(excluded.value AS INTEGER) THEN excluded.value ELSE adapter_state_meta.value END, updated_at=excluded.updated_at """, (str(ADAPTER_STATE_SCHEMA_VERSION), adapter_now()), ) conn.commit() return conn def adapter_load_legacy_jobs() -> dict[str, dict[str, Any]]: path = adapter_job_store_path() if not path.is_file(): return {} try: payload = json.loads(path.read_text(encoding="utf-8-sig")) except Exception: return {} jobs = payload.get("jobs") if isinstance(payload, dict) else None if not isinstance(jobs, dict): return {} return {str(job_id): dict(job) for job_id, job in jobs.items() if isinstance(job, dict)} ADAPTER_STATE_SECRET_KEYS = { "password", "newpassword", "oldpassword", "sqlpassword", "repositorypassword", "token", "servicetoken", "accesstoken", "refreshtoken", "authorization", "proxyauthorization", "clientsecret", } def adapter_state_safe_value(value: Any) -> Any: """Drop clear credentials before writing adapter-owned local state.""" if isinstance(value, dict): return { key: adapter_state_safe_value(item) for key, item in value.items() if normalize(key) not in ADAPTER_STATE_SECRET_KEYS } if isinstance(value, list): return [adapter_state_safe_value(item) for item in value] return value def adapter_load_jobs_from_store() -> None: global ADAPTER_JOB_STORE_LOADED if ADAPTER_JOB_STORE_LOADED: return ADAPTER_JOB_STORE_LOADED = True jobs: dict[str, dict[str, Any]] = {} try: with adapter_state_connection() as conn: rows = conn.execute("SELECT job_id, payload_json FROM adapter_jobs ORDER BY updated_at").fetchall() for row in rows: try: job = json.loads(str(row["payload_json"] or "")) except Exception: continue if isinstance(job, dict): jobs[str(row["job_id"])] = job migration_row = conn.execute("SELECT value FROM adapter_state_meta WHERE key='legacy_jobs_imported'").fetchone() if not migration_row: if not jobs: jobs.update(adapter_load_legacy_jobs()) conn.execute( "INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) VALUES('legacy_jobs_imported', ?, ?)", ("yes", adapter_now()), ) conn.commit() except Exception: return now = adapter_now() with ADAPTER_JOB_LOCK: for job_id, job in jobs.items(): restored = dict(job) if restored.get("status") in {"queued", "running"}: restored.update( adapter_public_error( str(restored.get("method") or "adapter.job"), "adapter_restarted", { "message": "Adapter restarted before this job finished. Start a narrower request or rerun the job.", "previous_instance_id": restored.get("adapter_instance_id"), "current_instance_id": ADAPTER_INSTANCE_ID, }, ) ) restored["status"] = "error" restored["finished_at"] = now restored["updated_at"] = now restored["current_step"] = "adapter_restarted" ADAPTER_JOBS[str(job_id)] = restored if jobs: adapter_save_jobs_to_store() def adapter_save_jobs_to_store() -> None: try: rows = [ ( str(job_id), str(job.get("method") or ""), str(job.get("status") or "unknown"), float(job.get("updated_at") or job.get("created_at") or adapter_now()), json.dumps(adapter_state_safe_value(job), ensure_ascii=False, separators=(",", ":"), default=str), ) for job_id, job in ADAPTER_JOBS.items() if isinstance(job, dict) ] with adapter_state_connection() as conn: conn.execute("DELETE FROM adapter_jobs") conn.executemany( "INSERT INTO adapter_jobs(job_id, method, status, updated_at, payload_json) VALUES(?, ?, ?, ?, ?)", rows, ) conn.commit() except Exception: return def adapter_job_timeout_seconds(payload: dict[str, Any], *, method: str = "") -> float: raw = payload.get("adapter_timeout_seconds") or payload.get("_adapter_timeout_seconds") or payload.get("timeout_seconds") if raw is None or raw == "": raw = os.environ.get("ONEC_ADAPTER_FULL_TIMEOUT_SECONDS" if method == "metadata.object.full" else "ONEC_ADAPTER_JOB_TIMEOUT_SECONDS", "600" if method == "metadata.object.full" else "240") try: return max(1.0, float(raw)) except (TypeError, ValueError): return 240.0 def adapter_section_timeout_seconds(payload: dict[str, Any], remaining: float) -> float: raw = payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds") if raw is None or raw == "": raw = os.environ.get("ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS", "180") try: value = max(1.0, float(raw)) except (TypeError, ValueError): value = 180.0 return max(1.0, min(value, remaining)) def adapter_column_type_timeout_seconds(payload: dict[str, Any], remaining: float) -> float: raw = payload.get("column_type_timeout_seconds") or payload.get("_column_type_timeout_seconds") if raw is None or raw == "": return adapter_section_timeout_seconds(payload, remaining) try: return max(1.0, min(float(raw), remaining)) except (TypeError, ValueError): return adapter_section_timeout_seconds(payload, remaining) def adapter_timeout_payload_value(timeout_seconds: float) -> int: return max(1, int(math.ceil(float(timeout_seconds)))) def adapter_max_columns(payload: dict[str, Any]) -> int | None: raw = payload.get("max_columns") if raw is None or raw == "": return None try: return max(1, int(raw)) except (TypeError, ValueError): return None def adapter_job_heartbeat_seconds() -> float: try: return max(1.0, min(float(os.environ.get("ONEC_ADAPTER_JOB_HEARTBEAT_SECONDS", "2")), 30.0)) except ValueError: return 2.0 def adapter_job_process_isolation_enabled(method: str) -> bool: if not truthy(os.environ.get("ONEC_ADAPTER_JOB_PROCESS_ISOLATION", "true")): return False return not method.startswith(("adapter.job.", "repository.")) def adapter_job_process_memory_limit_bytes() -> int: try: megabytes = max(0, int(os.environ.get("ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB", "0"))) except ValueError: return 0 return megabytes * 1024 * 1024 def adapter_job_process_cpu_limit_seconds() -> int: try: return max(0, int(os.environ.get("ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS", "0"))) except ValueError: return 0 def adapter_apply_job_process_limits() -> dict[str, Any]: """Apply optional POSIX hard limits inside an isolated worker process.""" limits = { "memory_bytes": adapter_job_process_memory_limit_bytes(), "cpu_seconds": adapter_job_process_cpu_limit_seconds(), "applied": [], "unsupported": [], } try: import resource except ImportError: if limits["memory_bytes"] or limits["cpu_seconds"]: limits["unsupported"].append("resource_module") return limits if limits["memory_bytes"]: try: resource.setrlimit(resource.RLIMIT_AS, (limits["memory_bytes"], limits["memory_bytes"])) limits["applied"].append("memory") except (OSError, ValueError): limits["unsupported"].append("memory") if limits["cpu_seconds"]: try: resource.setrlimit(resource.RLIMIT_CPU, (limits["cpu_seconds"], limits["cpu_seconds"])) limits["applied"].append("cpu") except (OSError, ValueError): limits["unsupported"].append("cpu") return limits def adapter_isolated_job_entry( job_id: str, method: str, job_payload: dict[str, Any], timeout_seconds: float, event_queue: Any, ) -> None: """Child-process entry point. All progress is sent back to the parent.""" global ADAPTER_JOB_EVENT_SINK ADAPTER_JOB_EVENT_SINK = event_queue ADAPTER_JOB_CONTEXT.job_id = job_id job_payload = {**job_payload, "_job_id": job_id} now = adapter_now() ADAPTER_JOBS[job_id] = { "schema": "onec_adapter_job.v1", "status": "running", "job_id": job_id, "method": method, "base_id": job_payload.get("base_id"), "created_at": now, "started_at": now, "updated_at": now, } try: event_queue.put({"type": "process_started", "pid": os.getpid(), "limits": adapter_apply_job_process_limits()}) if method == "metadata.object.full": adapter_run_metadata_object_full_job(job_id, job_payload, timeout_seconds) return if method == "metadata.object.attributes": adapter_run_metadata_object_attributes_job(job_id, job_payload, timeout_seconds) return if method == "metadata.object.special.details" and canonical_kind(str(job_payload.get("kind") or "")) == "DocumentJournal": adapter_run_document_journal_special_job(job_id, job_payload, timeout_seconds) return if method == "metadata.cache.rebuild": adapter_run_metadata_cache_rebuild_job(job_id, job_payload, timeout_seconds) return result = call_method_impl(method, job_payload) event_queue.put({"type": "result", "result": result}) except BaseException as exc: event_queue.put( { "type": "error", "error": adapter_public_error( method, "adapter_job_process_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)}, ), } ) finally: ADAPTER_JOB_CONTEXT.job_id = None try: event_queue.put({"type": "process_exited"}) except Exception: pass def adapter_stop_job_process(process: Any) -> None: if not process.is_alive(): process.join(timeout=0.2) return process.terminate() process.join(timeout=2) if process.is_alive() and hasattr(process, "kill"): process.kill() process.join(timeout=2) def adapter_apply_isolated_job_event(job_id: str, event: dict[str, Any]) -> bool: event_type = str(event.get("type") or "") if event_type == "set": adapter_job_set(job_id, **dict(event.get("updates") or {})) return False if event_type == "finish": adapter_job_finish(job_id, str(event.get("status") or "error"), **dict(event.get("updates") or {})) return True if event_type == "result": adapter_job_finish( job_id, "done", result=event.get("result"), progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}, ) return True if event_type == "error": adapter_job_finish(job_id, "error", **dict(event.get("error") or {})) return True if event_type == "process_started": adapter_job_set( job_id, worker={ "mode": "process", "pid": event.get("pid"), "limits": event.get("limits") or {}, }, ) return False def adapter_run_job_in_process( job_id: str, method: str, job_payload: dict[str, Any], timeout_seconds: float, ) -> None: context = multiprocessing.get_context("spawn") event_queue = context.Queue() process = context.Process( target=adapter_isolated_job_entry, args=(job_id, method, job_payload, timeout_seconds, event_queue), name=f"onec-adapter-process-{job_id[:8]}", daemon=True, ) process.start() deadline = adapter_now() + timeout_seconds terminal_event = False try: while True: if adapter_job_cancel_requested(job_id): adapter_stop_job_process(process) if method == CODE_WRITE_METHOD: cleanup = cleanup_job_prepare_receipts( job_id, str(job_payload.get("base_id") or ""), timeout_seconds=30 ) if cleanup: adapter_job_set(job_id, prepare_cleanup=cleanup, current_step="prepare_cleanup") return remaining = deadline - adapter_now() if remaining <= 0: adapter_stop_job_process(process) cleanup = [] if method == CODE_WRITE_METHOD: cleanup = cleanup_job_prepare_receipts( job_id, str(job_payload.get("base_id") or ""), timeout_seconds=30 ) if method in {"metadata.definition.find", "metadata.relationship.find", "metadata.code_index.search"}: # A worker may have already reported matches through # adapter_job_set(). Keep that evidence when its time # budget expires: an empty final result would make the # useful, verified portion of the search disappear. with ADAPTER_JOB_LOCK: saved_partial = dict((ADAPTER_JOBS.get(job_id) or {}).get("partial_result") or {}) partial_schema = ( "onec_metadata_definition_find.v1" if method == "metadata.definition.find" else "onec_metadata_relationship_find.v1" if method == "metadata.relationship.find" else "onec_code_index_search.v1" ) result = { "schema": partial_schema, "method": method, "status": "partial", "base_id": job_payload.get("base_id"), "matches": [], "counts": {"matches": 0, "by_area": {}}, "diagnostics": [], } if saved_partial: result.update(saved_partial) result["schema"] = partial_schema result["method"] = method result["status"] = "partial" result["base_id"] = job_payload.get("base_id") result["matches"] = list(result.get("matches") or []) result["counts"] = dict(result.get("counts") or {}) result["counts"]["matches"] = len(result["matches"]) by_area: dict[str, int] = {} for match in result["matches"]: if not isinstance(match, dict): continue area = str(match.get("area") or "unknown") by_area[area] = by_area.get(area, 0) + 1 if method == "metadata.definition.find": result["counts"]["by_area"] = by_area else: result["counts"].pop("by_area", None) diagnostics = list(result.get("diagnostics") or []) diagnostics.append({ "code": "time_budget_exhausted", "message": f"Object-scoped search exceeded {timeout_seconds:.0f} seconds and its worker process was terminated.", }) result["diagnostics"] = diagnostics adapter_job_finish( job_id, "done", progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}, partial_result=result, result=result, ) return if method in {"metadata.object.form.details", "metadata.form.decode", "form.field.lineage"}: partial_schema = ( "onec_object_form_details.v1" if method == "metadata.object.form.details" else "onec_form_decode.v1" if method == "metadata.form.decode" else "onec_form_field_lineage.v2" ) result = { "schema": partial_schema, "method": method, "status": "partial", "base_id": job_payload.get("base_id"), "forms": [] if method == "metadata.object.form.details" else None, "field": job_payload.get("field") if method == "form.field.lineage" else None, "matches": [] if method == "form.field.lineage" else None, "missing": [{ "entity": "form_payload", "reason": f"SQL form read exceeded {timeout_seconds:.0f} seconds and its isolated worker was terminated.", "required_capability": "bounded_form_payload_read", }], "diagnostics": [{ "code": "time_budget_exhausted", "message": "Retry with a narrower form/element selector or a larger job timeout.", }], } result = {key: value for key, value in result.items() if value is not None} adapter_job_finish( job_id, "done", progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}, partial_result=result, result=result, ) return adapter_job_finish( job_id, "error", **adapter_public_error( method, "job_timeout", {"message": f"Adapter job process exceeded {timeout_seconds:.0f} seconds and was terminated."}, ), **({"prepare_cleanup": cleanup} if cleanup else {}), ) return try: event = event_queue.get(timeout=min(0.25, remaining)) except queue.Empty: if process.is_alive(): continue break except (EOFError, OSError): break if isinstance(event, dict): terminal_event = adapter_apply_isolated_job_event(job_id, event) or terminal_event if terminal_event and not process.is_alive(): break process.join(timeout=0.5) while True: try: event = event_queue.get_nowait() except (queue.Empty, EOFError, OSError): break if isinstance(event, dict): terminal_event = adapter_apply_isolated_job_event(job_id, event) or terminal_event if not terminal_event and not adapter_job_cancel_requested(job_id): adapter_job_finish( job_id, "error", **adapter_public_error( method, "adapter_job_process_exited", {"message": f"Adapter job process exited without a result (exit_code={process.exitcode})."}, ), ) finally: adapter_stop_job_process(process) event_queue.close() def adapter_cleanup_jobs(now: float | None = None) -> None: adapter_load_jobs_from_store() effective_now = adapter_now() if now is None else now changed = False with ADAPTER_JOB_LOCK: expired = [ job_id for job_id, job in ADAPTER_JOBS.items() if effective_now - float(job.get("updated_at") or job.get("created_at") or effective_now) > ADAPTER_JOB_TTL_SECONDS ] for job_id in expired: ADAPTER_JOBS.pop(job_id, None) changed = True if changed: adapter_save_jobs_to_store() def adapter_job_set(job_id: str, **updates: Any) -> None: with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) if job: job.update(updates) job["updated_at"] = adapter_now() if ADAPTER_JOB_EVENT_SINK is not None: try: ADAPTER_JOB_EVENT_SINK.put({"type": "set", "updates": updates}) except Exception: pass else: adapter_save_jobs_to_store() def adapter_job_cancel_requested(job_id: str) -> bool: with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) or {} return truthy(job.get("cancel_requested")) or job.get("status") == "cancelled" def adapter_job_finish(job_id: str, job_status: str, **updates: Any) -> None: with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) if not job or (job.get("status") == "cancelled" and job_status != "cancelled"): return job.update(updates) job["status"] = job_status if job_status in {"done", "cancelled"} and "current_step" not in updates: job["current_step"] = job_status elif job_status == "error" and "current_step" not in updates: job["current_step"] = "error" job["finished_at"] = adapter_now() job["updated_at"] = job["finished_at"] if ADAPTER_JOB_EVENT_SINK is not None: try: ADAPTER_JOB_EVENT_SINK.put({"type": "finish", "status": job_status, "updates": updates}) except Exception: pass else: adapter_save_jobs_to_store() def adapter_card_failure_result_for_long_method(method: str, payload: dict[str, Any], card_result: dict[str, Any], elapsed_seconds: float) -> dict[str, Any] | None: if method == "metadata.object.attributes": partial = adapter_attributes_partial_result(payload) queued_sections = ["semantic", "type_resolution", "build_result"] skipped_status = "not_started_due_to_card_failure" elif method == "metadata.object.full": partial = adapter_full_partial_result(payload) evidence_mode = str(payload.get("_evidence_mode") or payload.get("evidence_mode") or payload.get("undecoded_evidence_mode") or "summary").casefold() requested_sections = list(payload.get("_sections") or []) if not requested_sections: requested_sections = list(FULL_METHOD_DEFAULT_SECTIONS) if truthy(payload.get("include_parts_summary")) or truthy(payload.get("include_storage")) or evidence_mode in {"full", "raw"}: requested_sections.append("parts_summary") queued_sections = [section for section in requested_sections if section != "card"] skipped_status = "not_started_due_to_not_found" else: return None diagnostics = card_result.get("diagnostics") if isinstance(card_result.get("diagnostics"), dict) else {} reason = diagnostics.get("message") or str(card_result.get("diagnostics") or "Object card was not resolved.") partial["status"] = str(card_result.get("status") or "not_found") partial["object"] = card_result.get("object") if card_result.get("matches") is not None: partial["matches"] = card_result.get("matches") partial.setdefault("sections", {})["card"] = "failed" card_failure = { "section": "card", "method": "metadata.object.get", "status": card_result.get("status") or "error", "diagnostics": card_result.get("diagnostics") or {"message": reason}, } partial.setdefault("failed_sections", []).append(card_failure) partial.setdefault("diagnostics", []).append(card_failure) for section in queued_sections: partial.setdefault("sections", {})[section] = skipped_status item = { "section": section, "method": "metadata.object.attributes" if section in {"semantic", "type_resolution", "build_result"} else method, "status": skipped_status, "diagnostics": {"message": reason}, } partial.setdefault("failed_sections", []).append(item) partial.setdefault("diagnostics", []).append(item) partial.setdefault("section_timings", {})[section] = {"method": item["method"], "status": skipped_status} partial.setdefault("section_timings", {})["card"] = { "method": "metadata.object.get", "status": "failed", "timeout_seconds": adapter_timeout_payload_value(payload.get("timeout_seconds") or 60), "elapsed_seconds": round(max(0.0, elapsed_seconds), 3), } partial["elapsed_seconds"] = round(max(0.0, elapsed_seconds), 3) partial["last_section_update_at"] = adapter_now() return partial def adapter_long_method_card_preflight(method: str, payload: dict[str, Any]) -> dict[str, Any] | None: if method not in {"metadata.object.attributes", "metadata.object.full"}: return None started_at = adapter_now() timeout_seconds = adapter_timeout_payload_value(payload.get("timeout_seconds") or 60) card_result = call_method_impl( "metadata.object.get", {**payload, "include_semantic": False, "timeout_seconds": timeout_seconds}, ) if isinstance(card_result, dict) and card_result.get("status") in {"not_found", "source_missing"}: return adapter_card_failure_result_for_long_method(method, payload, card_result, adapter_now() - started_at) return None def adapter_job_heartbeat(job_id: str, stop_event: threading.Event) -> None: while not stop_event.wait(adapter_job_heartbeat_seconds()): with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) if not job or job.get("status") not in {"queued", "running"}: return job["updated_at"] = adapter_now() progress = dict(job.get("progress") or {}) progress["heartbeat_at"] = job["updated_at"] if job.get("started_at"): progress["elapsed_seconds"] = round(max(0.0, job["updated_at"] - float(job.get("started_at") or job["updated_at"])), 3) partial = job.get("partial_result") if isinstance(partial, dict): partial["elapsed_seconds"] = progress["elapsed_seconds"] job["partial_result"] = partial job["progress"] = progress adapter_save_jobs_to_store() def adapter_public_error(method: str, error: str, diagnostics: Any | None = None) -> dict[str, Any]: safe_diagnostics = diagnostics if diagnostics is not None else {"message": error} if not truthy(os.environ.get("ONEC_ADAPTER_DEBUG_DIAGNOSTICS")): safe_diagnostics = strip_private_error_diagnostics(safe_diagnostics) return { "schema": "onec_adapter_method_error.v1", "status": "error", "method": method, "error": error, "diagnostics": safe_diagnostics, } def strip_private_error_diagnostics(value: Any) -> Any: if isinstance(value, list): return [strip_private_error_diagnostics(item) for item in value] if not isinstance(value, dict): return value return { key: strip_private_error_diagnostics(item) for key, item in value.items() if key not in {"traceback", "stack", "stacktrace", "exception_repr"} } def adapter_public_object_query(payload: dict[str, Any]) -> dict[str, Any]: normalized_payload = normalize_object_ref_payload(payload, "metadata.object.full") if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": normalized_payload = payload query: dict[str, Any] = { "guid": normalized_payload.get("guid"), "kind": normalized_payload.get("kind"), "name": normalized_payload.get("name"), } if payload.get("ref") not in {None, ""}: query["ref"] = payload.get("ref") if normalized_payload.get("ordinal") not in {None, ""}: query["ordinal"] = normalized_payload.get("ordinal") query["include_storage"] = truthy(normalized_payload.get("include_storage")) if normalized_payload.get("timeout_seconds") not in {None, ""}: query["timeout_seconds"] = normalized_payload.get("timeout_seconds") section_timeout = normalized_payload.get("section_timeout_seconds") or normalized_payload.get("_section_timeout_seconds") if section_timeout not in {None, ""}: query["section_timeout_seconds"] = section_timeout return query def adapter_full_partial_result(payload: dict[str, Any]) -> dict[str, Any]: evidence_mode = str(payload.get("_evidence_mode") or payload.get("evidence_mode") or payload.get("undecoded_evidence_mode") or "summary").casefold() requested_sections = list(payload.get("_sections") or []) if not requested_sections: requested_sections = list(FULL_METHOD_DEFAULT_SECTIONS) if truthy(payload.get("include_parts_summary")) or truthy(payload.get("include_storage")) or evidence_mode in {"full", "raw"}: requested_sections.append("parts_summary") # Ensure stable section order in response and explicit selection visibility. sections = { "card": "pending", "semantic": "pending", "forms": "pending", "templates": "pending", "commands": "pending", "modules": "pending", "parts_summary": "not_requested", } for section in sections: if section not in requested_sections: sections[section] = "not_requested" return { "schema": "onec_metadata_object_full.v1", "status": "partial", "base_id": payload.get("base_id"), "source": {"kind": "live_metadata"}, "query": { **adapter_public_object_query(payload), "evidence_mode": evidence_mode, **({"sections": requested_sections} if payload.get("_sections") is not None else {}), }, "sections": sections, "failed_sections": [], "section_timings": {}, "diagnostics": [], "dimensions": [], "resources": [], "attributes": [], "tabular_sections": [], "counts": {}, } def adapter_merge_full_counts(partial: dict[str, Any]) -> None: semantic_sections = ((partial.get("semantic") or {}).get("sections") or []) if isinstance(partial.get("semantic"), dict) else [] existing_counts = partial.get("counts") if isinstance(partial.get("counts"), dict) else {} partial["counts"] = { "forms": len(partial.get("forms") or []), "templates": len(partial.get("templates") or []), "commands": len(partial.get("commands") or []), "modules": len(partial.get("modules") or []), "dimensions": int(existing_counts.get("dimensions") or len(partial.get("dimensions") or [])), "resources": int(existing_counts.get("resources") or len(partial.get("resources") or [])), "attributes": int( existing_counts.get("attributes") or len(partial.get("attributes") or []) or sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "Attribute") ), "tabular_sections": int( existing_counts.get("tabular_sections") or len(partial.get("tabular_sections") or []) or sum(int((section or {}).get("declared_record_count") or 0) for section in semantic_sections if (section or {}).get("category") == "TabularSection") ), **({"resolved_reference_types": existing_counts.get("resolved_reference_types")} if "resolved_reference_types" in existing_counts else {}), **({"unresolved_reference_types": existing_counts.get("unresolved_reference_types")} if "unresolved_reference_types" in existing_counts else {}), } def adapter_section_failed(partial: dict[str, Any], section: str, method: str, result: Any) -> None: status = result.get("status") if isinstance(result, dict) else "error" diagnostics = result.get("diagnostics") if isinstance(result, dict) else {"message": str(result)} partial.setdefault("sections", {})[section] = "failed" if any(item.get("section") == section for item in partial.get("failed_sections") or []): return item = {"section": section, "method": method, "status": status, "diagnostics": diagnostics} partial.setdefault("failed_sections", []).append(item) partial.setdefault("diagnostics", []).append(item) def adapter_section_not_started( partial: dict[str, Any], section: str, method: str, reason: str, *, status: str = "not_started_due_to_job_timeout" ) -> None: partial.setdefault("sections", {})[section] = status if any(item.get("section") == section for item in partial.get("failed_sections") or []): return item = { "section": section, "method": method, "status": status, "diagnostics": {"message": reason}, } partial.setdefault("failed_sections", []).append(item) partial.setdefault("diagnostics", []).append(item) partial.setdefault("section_timings", {})[section] = { "method": method, "status": status, } def adapter_section_ok_or_empty(partial: dict[str, Any], section: str, value: Any) -> None: partial.setdefault("sections", {})[section] = "ok" if value else "empty" def adapter_update_full_job( job_id: str, partial: dict[str, Any], current_step: str, completed: int, total: int, running_steps: list[str] | None = None, *, started_at: float | None = None, queued_steps: list[str] | None = None, done_steps: list[str] | None = None, failed_steps: list[str] | None = None, ) -> None: adapter_merge_full_counts(partial) now = adapter_now() if started_at is not None: partial["elapsed_seconds"] = round(max(0.0, now - started_at), 3) partial["last_section_update_at"] = now progress: dict[str, Any] = { "current_step": current_step, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, } if started_at is not None: progress["elapsed_seconds"] = partial["elapsed_seconds"] progress["last_section_update_at"] = partial["last_section_update_at"] if running_steps is not None: progress["running_steps"] = running_steps if queued_steps is not None: progress["queued_steps"] = queued_steps if done_steps is not None: progress["done_steps"] = done_steps if failed_steps is not None: progress["failed_steps"] = failed_steps adapter_job_set(job_id, partial_result=partial, current_step=current_step, progress=progress) def adapter_section_running(partial: dict[str, Any], section: str) -> None: partial.setdefault("sections", {})[section] = "running" def adapter_section_timing_start(partial: dict[str, Any], section: str, method: str, timeout_seconds: float) -> float: started_at = adapter_now() partial.setdefault("section_timings", {})[section] = { "method": method, "status": "running", "started_at": started_at, "timeout_seconds": adapter_timeout_payload_value(timeout_seconds), } return started_at def adapter_section_timing_finish(partial: dict[str, Any], section: str, status: str, section_started_at: float) -> None: finished_at = adapter_now() timing = dict((partial.setdefault("section_timings", {}) or {}).get(section) or {}) timing.update( { "status": status, "finished_at": finished_at, "elapsed_seconds": round(max(0.0, finished_at - section_started_at), 3), } ) partial.setdefault("section_timings", {})[section] = timing def adapter_full_form_commands(partial: dict[str, Any]) -> list[dict[str, Any]]: commands = [] seen: set[tuple[str, str]] = set() for form in partial.get("forms") or []: form_name = form.get("name") for item in form.get("commands") or []: name = str(item.get("name") or "") key = (str(form_name or ""), name) if key in seen: continue seen.add(key) commands.append( { "scope": "form", "form": form_name, "name": name, "title": item.get("title"), "id": item.get("id"), } ) return commands def adapter_full_commands_result(payload: dict[str, Any], partial: dict[str, Any], timeout_seconds: float) -> dict[str, Any]: object_result = call_method_impl( "metadata.object.commands", {**payload, "include_form_commands": False, "timeout_seconds": adapter_timeout_payload_value(timeout_seconds)}, ) object_commands = [] if isinstance(object_result, dict) and object_result.get("status") == "ok": object_commands = object_result.get("object_commands") or object_result.get("commands") or [] command_modules = [] for command in object_commands: read_selector = command.get("read_selector") if isinstance(command, dict) and isinstance(command.get("read_selector"), dict) else None if not read_selector: continue command_name = str(command.get("name") or command.get("synonym") or "") owner_name = str(((object_result.get("object") or {}).get("name") if isinstance(object_result, dict) else "") or (partial.get("object") or {}).get("name") or "") qualified_name = ".".join(part for part in [owner_name, "Команда", command_name, "Модуль команды"] if part) command_modules.append( { "kind": "command_module", "name": "Модуль команды", "command": command_name, **({"qualified_name": qualified_name, "display_name": qualified_name} if qualified_name else {}), "read_selector": dict(read_selector), } ) form_commands = adapter_full_form_commands(partial) commands = [*object_commands, *form_commands] return { "schema": "onec_object_commands.v1", "status": "ok", "base_id": payload.get("base_id"), "source": {"kind": "live_metadata"}, "commands": commands, "object_commands": object_commands, "form_commands": form_commands, "modules": command_modules, "counts": { "commands": len(commands), "object_commands": len(object_commands), "form_commands": len(form_commands), "command_modules": len(command_modules), }, "capabilities": { "object_commands": True, "form_commands": True, "form_commands_source": "metadata.object.full.forms", }, } def adapter_document_journal_partial_result(payload: dict[str, Any]) -> dict[str, Any]: return { "schema": "onec_object_special_details.v1", "status": "partial", "base_id": payload.get("base_id"), "source": {"kind": "live_metadata"}, "query": { "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), **({"ordinal": payload.get("ordinal")} if payload.get("ordinal") not in {None, ""} else {}), "include_column_types": truthy(payload.get("include_column_types")), **({"timeout_seconds": payload.get("timeout_seconds")} if payload.get("timeout_seconds") not in {None, ""} else {}), **({"section_timeout_seconds": payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds")} if (payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds")) not in {None, ""} else {}), }, "sections": { "card": "pending", "document_types": "pending", "columns": "pending", "column_types": "pending" if truthy(payload.get("include_column_types")) else "not_requested", }, "details": {}, "counts": {"document_types": 0, "columns": 0, "typed_columns": 0}, "current_column": None, "failed_columns": [], "column_timings": {}, "failed_sections": [], "section_timings": {}, "diagnostics": [], } def adapter_document_journal_counts(partial: dict[str, Any]) -> None: details = partial.get("details") or {} document_types = details.get("document_types") if isinstance(details.get("document_types"), list) else [] columns = details.get("columns") if isinstance(details.get("columns"), list) else [] partial["counts"] = { "document_types": len(document_types), "columns": len(columns), "typed_columns": sum(1 for column in columns if isinstance(column, dict) and column.get("type")), } def adapter_document_journal_column_types_complete(partial: dict[str, Any]) -> bool: counts = partial.get("counts") or {} columns = int(counts.get("columns") or 0) typed_columns = int(counts.get("typed_columns") or 0) return bool(columns and typed_columns >= columns and not partial.get("failed_columns")) def adapter_update_special_job( job_id: str, partial: dict[str, Any], current_step: str, completed: int, total: int, *, started_at: float, queued_steps: list[str], running_steps: list[str], done_steps: list[str], failed_steps: list[str], ) -> None: adapter_document_journal_counts(partial) now = adapter_now() partial["elapsed_seconds"] = round(max(0.0, now - started_at), 3) partial["last_section_update_at"] = now adapter_job_set( job_id, partial_result=partial, current_step=current_step, progress={ "current_step": current_step, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], "queued_steps": queued_steps, "running_steps": running_steps, "done_steps": done_steps, "failed_steps": failed_steps, }, ) def adapter_run_document_journal_special_job(job_id: str, payload: dict[str, Any], timeout_seconds: float) -> None: base_id = str(payload.get("base_id") or "") partial = adapter_document_journal_partial_result(payload) started_at = adapter_now() context: dict[str, Any] = {} table = str(payload.get("table") or "Config") steps: list[tuple[str, str, Any]] = [] def load_card() -> dict[str, Any]: guid, kind, object_card, error = resolve_object_guid( payload, base_id, timeout_seconds=int(payload.get("timeout_seconds") or 60), method="metadata.object.special.details", table=table, ) if error: return error data, _, read_error = read_storage_file_bytes(base_id, table, guid, timeout_seconds=int(payload.get("timeout_seconds") or 60)) if read_error: return public_error_result(read_error, include_storage=False, method="metadata.object.special.details") tree = parse_config_tree_from_bytes(data) identity = config_identity_from_bytes(data) or (object_card or {}).get("identity") or {} context.update({"guid": guid, "kind": kind, "object_card": object_card, "tree": tree, "identity": identity, "strings": tree_ordered_strings(tree)}) partial["object"] = object_card or {"guid": guid, "kind": kind, "identity": identity} partial.setdefault("details", {})["description"] = next((value for value in context["strings"] if " " in value), None) return {"status": "ok"} def load_document_types() -> dict[str, Any]: records, _ = live_dbnames_records(base_id, timeout_seconds=int(payload.get("timeout_seconds") or 60)) context["dbnames_records"] = records document_types = document_journal_document_types( base_id, context.get("tree"), dbnames_records=records, table=table, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) context["document_types"] = document_types partial.setdefault("details", {})["document_types"] = document_types if document_types else {"status": "not_decoded_yet"} return {"status": "ok" if document_types else "partial"} def load_columns(include_types: bool = False, section_deadline: float | None = None) -> dict[str, Any]: if include_types: config, _ = sql_config_for_base(base_id) journal_field_guids = document_journal_all_column_field_guids(context.get("tree")) field_types: dict[str, dict[str, Any]] = metadata_field_type_cache_lookup( config, journal_field_guids, ) if field_types: columns = document_journal_columns_from_field_types( base_id, context.get("tree"), field_types, timeout_seconds=int(payload.get("timeout_seconds") or 60), max_columns=adapter_max_columns(payload), ) if columns: partial.setdefault("details", {})["columns"] = columns adapter_document_journal_counts(partial) partial.setdefault("column_timings", {})["field_type_cache"] = { "status": "ok", "typed_columns": partial.get("counts", {}).get("typed_columns", 0), } adapter_update_special_job( job_id, partial, "column_types:field_type_cache", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) if adapter_document_journal_column_types_complete(partial): partial["current_column"] = None return {"status": "ok"} documents = context.get("document_types") or [] max_columns = adapter_max_columns(payload) partial["failed_columns"] = [] for index, document in enumerate(documents): if section_deadline and adapter_now() >= section_deadline: return adapter_public_error( "metadata.object.special.details.column_types", "section_timeout", {"message": "Column type decoding timed out", "processed_documents": index, "total_documents": len(documents)}, ) guid = str(document.get("guid") or "").lower() started_column_at = adapter_now() partial["current_column"] = { "document": document.get("name") or document.get("synonym") or guid, "document_guid": guid, "document_index": index + 1, "documents_total": len(documents), "phase": "read_metadata", } adapter_update_special_job( job_id, partial, "column_types", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) try: data, _, read_error = read_storage_file_bytes(base_id, table, guid, timeout_seconds=int(payload.get("timeout_seconds") or 60)) if read_error: partial.setdefault("failed_columns", []).append({"document": document.get("name"), "guid": guid, "diagnostics": read_error.get("diagnostics")}) continue partial["current_column"]["phase"] = "decode_attributes" adapter_update_special_job( job_id, partial, "column_types:decode_attributes", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) decoded = decode_config_object_full(data, kind="Document", dbnames_records=context.get("dbnames_records") or [], max_depth=3) semantic = decoded.get("semantic") if decoded.get("status") == "ok" else None sections = (semantic or {}).get("sections") or [] target_attributes: list[dict[str, Any]] = [] for semantic_section in sections: if semantic_section.get("category") != "Attribute": continue for attribute in semantic_section.get("records") or []: identity = attribute.get("identity") if isinstance(attribute.get("identity"), dict) else {} attribute_guid = str(identity.get("guid") or "").lower() if attribute_guid and attribute_guid in journal_field_guids and attribute.get("type"): target_attributes.append(attribute) type_guids: set[str] = set() for attribute in target_attributes: attribute_type = attribute.get("type") or {} if isinstance(attribute_type, dict) and attribute_type.get("kind") == "reference" and attribute_type.get("type_guid"): type_guids.add(str(attribute_type.get("type_guid")).lower()) partial["current_column"]["phase"] = "resolve_types" adapter_update_special_job( job_id, partial, "column_types:resolve_types", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) resolved_types = resolve_type_guids( base_id, type_guids, timeout_seconds=int(payload.get("timeout_seconds") or 60), table=table, ) for attribute in target_attributes: identity = attribute.get("identity") if isinstance(attribute.get("identity"), dict) else {} attribute_guid = str(identity.get("guid") or "").lower() public_type = sanitize_public_result(public_type_info(attribute.get("type"), resolved_types, include_storage=False)) field_types[attribute_guid] = public_type metadata_field_type_cache_upsert(config, attribute_guid, public_type, owner=document, field_name=identity.get("name")) partial["current_column"]["phase"] = "match_columns" columns = document_journal_columns_from_field_types( base_id, context.get("tree"), field_types, table=table, timeout_seconds=int(payload.get("timeout_seconds") or 60), max_columns=max_columns, ) if columns: partial.setdefault("details", {})["columns"] = columns adapter_document_journal_counts(partial) partial.setdefault("column_timings", {})[guid] = { "document": document.get("name"), "status": "ok", "started_at": started_column_at, "finished_at": adapter_now(), "elapsed_seconds": round(max(0.0, adapter_now() - started_column_at), 3), "typed_columns": partial.get("counts", {}).get("typed_columns", 0), } total_columns = (partial.get("counts") or {}).get("columns", 0) typed_columns = (partial.get("counts") or {}).get("typed_columns", 0) if adapter_document_journal_column_types_complete(partial): partial["current_column"] = None adapter_update_special_job( job_id, partial, "column_types", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) return {"status": "ok"} except Exception as exc: partial.setdefault("failed_columns", []).append({"document": document.get("name"), "guid": guid, "diagnostics": {"message": str(exc)}}) partial.setdefault("column_timings", {})[guid] = { "document": document.get("name"), "status": "failed", "started_at": started_column_at, "finished_at": adapter_now(), "elapsed_seconds": round(max(0.0, adapter_now() - started_column_at), 3), "diagnostics": {"message": str(exc)}, } adapter_update_special_job( job_id, partial, "column_types", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps, ) partial["current_column"] = None typed_columns = (partial.get("counts") or {}).get("typed_columns", 0) total_columns = (partial.get("counts") or {}).get("columns", 0) return {"status": "ok" if adapter_document_journal_column_types_complete(partial) else "partial"} columns = document_journal_columns( base_id, context.get("tree"), document_types=context.get("document_types") or [], dbnames_records=context.get("dbnames_records"), table=table, include_column_types=False, timeout_seconds=int(payload.get("timeout_seconds") or 60), max_columns=adapter_max_columns(payload), ) if columns: partial.setdefault("details", {})["columns"] = columns elif "columns" not in partial.setdefault("details", {}): partial["details"]["columns"] = {"status": "not_decoded_yet"} return {"status": "ok" if columns else "partial"} steps.append(("card", "metadata.object.special.details.card", load_card)) steps.append(("document_types", "metadata.object.special.details.document_types", load_document_types)) steps.append(("columns", "metadata.object.special.details.columns", lambda: load_columns(False))) if truthy(payload.get("include_column_types")): steps.append(("column_types", "metadata.object.special.details.column_types", lambda deadline=None: load_columns(True, section_deadline=deadline))) total = len(steps) completed = 0 queued_steps = [section for section, _, _ in steps] running_steps: list[str] = [] done_steps: list[str] = [] failed_steps: list[str] = [] adapter_update_special_job(job_id, partial, "starting", completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps) for section, section_method, section_callable in steps: if adapter_job_cancel_requested(job_id): partial["status"] = "cancelled" adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial) return elapsed = adapter_now() - started_at if elapsed >= timeout_seconds: for queued_section, queued_method, _ in steps: if queued_section in queued_steps: adapter_section_not_started(partial, queued_section, queued_method, f"Adapter job timeout after {timeout_seconds:.0f} seconds before section start") failed_steps.append(queued_section) partial["status"] = "partial" adapter_job_finish(job_id, "error", error="job_timeout", partial_result=partial, result=partial, diagnostics={"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds"}) return if section in queued_steps: queued_steps.remove(section) running_steps = [section] adapter_section_running(partial, section) remaining = max(1.0, timeout_seconds - elapsed) section_timeout = adapter_column_type_timeout_seconds(payload, remaining) if section == "column_types" else adapter_section_timeout_seconds(payload, remaining) section_started_at = adapter_section_timing_start(partial, section, section_method, section_timeout) adapter_update_special_job(job_id, partial, section, completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps) try: executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix=f"onec-special-{section}-{job_id[:8]}") if section == "column_types": future = executor.submit(section_callable, adapter_now() + section_timeout) else: future = executor.submit(section_callable) try: try: result = future.result(timeout=section_timeout) except concurrent.futures.TimeoutError: future.cancel() if section == "column_types" and adapter_document_journal_column_types_complete(partial): partial["current_column"] = None result = {"status": "ok"} else: result = adapter_public_error( section_method, "section_timeout", {"message": f"Section `{section}` timed out after {section_timeout:.0f} seconds", "section": section, "section_timeout_seconds": section_timeout}, ) finally: executor.shutdown(wait=False, cancel_futures=True) except Exception as exc: result = adapter_public_error(section_method, "adapter_section_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=6)}) running_steps = [] completed += 1 if section == "column_types" and adapter_document_journal_column_types_complete(partial): result = {"status": "ok"} if not isinstance(result, dict) or result.get("status") not in {"ok", "partial"}: adapter_section_failed(partial, section, section_method, result) failed_steps.append(section) adapter_section_timing_finish(partial, section, "failed", section_started_at) else: partial["sections"][section] = "ok" if result.get("status") == "ok" else "partial" done_steps.append(section) adapter_section_timing_finish(partial, section, str(partial["sections"][section]), section_started_at) adapter_update_special_job(job_id, partial, section, completed, total, started_at=started_at, queued_steps=queued_steps, running_steps=running_steps, done_steps=done_steps, failed_steps=failed_steps) partial["status"] = "partial" if partial.get("failed_sections") else "ok" adapter_document_journal_counts(partial) finished_at = adapter_now() partial["elapsed_seconds"] = round(max(0.0, finished_at - started_at), 3) partial["last_section_update_at"] = finished_at adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": total, "total_steps": total, "percent": 100, "queued_steps": [], "running_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], }, ) def adapter_full_forms_method(payload: dict[str, Any]) -> str: if truthy(payload.get("include_form_details")) or truthy(payload.get("include_form_module_text")): return "metadata.object.form.details" return "metadata.object.forms" def adapter_run_metadata_object_full_job(job_id: str, payload: dict[str, Any], timeout_seconds: float) -> None: partial = adapter_full_partial_result(payload) started_at = adapter_now() evidence_mode = str(payload.get("_evidence_mode") or payload.get("evidence_mode") or payload.get("undecoded_evidence_mode") or "summary").casefold() section_payload_overrides = { "card": {"include_semantic": False}, "semantic": {"only": "all"}, "forms": { "max_items": int(payload.get("max_form_items") or payload.get("max_items") or 1000), "max_forms": int(payload.get("max_forms") or 20), "include_module_text": truthy(payload.get("include_form_module_text")), "include_parameters": bool(payload.get("include_parameters", True)), "max_parameters": int(payload.get("max_parameters") or 80), }, "parts_summary": { "include_text": evidence_mode in {"full", "raw"}, "include_tree": evidence_mode == "raw", "evidence_mode": evidence_mode, }, } section_methods = { "card": "metadata.object.get", "semantic": "metadata.object.attributes", "modules": "metadata.object.modules", "templates": "metadata.object.template.details" if truthy(payload.get("include_template_details")) else "metadata.object.templates", "forms": adapter_full_forms_method(payload), "commands": "metadata.object.commands", "parts_summary": "metadata.object.parts", } selected_sections = list(payload.get("_sections") or []) if not selected_sections: selected_sections = list(FULL_METHOD_DEFAULT_SECTIONS) if truthy(payload.get("include_parts_summary")) or truthy(payload.get("include_storage")) or evidence_mode in {"full", "raw"}: selected_sections.append("parts_summary") steps: list[tuple[str, str, dict[str, Any]]] = [] for section in selected_sections: section_method = section_methods[section] section_payload = dict(payload) section_payload.update(section_payload_overrides.get(section) or {}) steps.append((section, section_method, section_payload)) if section == "parts_summary": partial["sections"]["parts_summary"] = "pending" total = len(steps) completed = 0 queued_steps = [section for section, _, _ in steps] running_steps: list[str] = [] done_steps: list[str] = [] failed_steps: list[str] = [] adapter_update_full_job( job_id, partial, "starting", completed, total, running_steps, started_at=started_at, queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) for section, section_method, section_payload in steps: if adapter_job_cancel_requested(job_id): partial["status"] = "cancelled" adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial) return elapsed = adapter_now() - started_at if elapsed >= timeout_seconds: adapter_section_failed(partial, section, section_method, {"status": "timeout", "diagnostics": {"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds"}}) failed_steps.append(section) for queued_section, queued_method, _ in steps: if queued_section in queued_steps: adapter_section_not_started(partial, queued_section, queued_method, f"Adapter job timeout after {timeout_seconds:.0f} seconds before section start") failed_steps.append(queued_section) queued_steps = [] partial["status"] = "partial" adapter_job_finish( job_id, "error", error="job_timeout", partial_result=partial, result=partial, progress={ "current_step": "timeout", "running_steps": running_steps, "queued_steps": queued_steps, "done_steps": done_steps, "failed_steps": failed_steps, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, "elapsed_seconds": round(max(0.0, adapter_now() - started_at), 3), "last_section_update_at": adapter_now(), }, diagnostics={"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds", "running_steps": running_steps}, ) return if section in queued_steps: queued_steps.remove(section) running_steps = [section] adapter_section_running(partial, section) adapter_update_full_job( job_id, partial, f"{section}:{section_method}", completed, total, running_steps, started_at=started_at, queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) remaining = max(1.0, timeout_seconds - elapsed) section_timeout = adapter_section_timeout_seconds(section_payload, remaining) section_started_at = adapter_section_timing_start(partial, section, section_method, section_timeout) adapter_update_full_job( job_id, partial, f"{section}:{section_method}", completed, total, running_steps, started_at=started_at, queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) try: executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix=f"onec-section-{section}-{job_id[:8]}") if section == "commands": future = executor.submit(adapter_full_commands_result, section_payload, partial, section_timeout) else: future = executor.submit(call_method_impl, section_method, {**section_payload, "timeout_seconds": adapter_timeout_payload_value(section_timeout)}) try: try: result = future.result(timeout=section_timeout) except concurrent.futures.TimeoutError: future.cancel() result = adapter_public_error( section_method, "section_timeout", { "message": f"Section `{section}` timed out after {section_timeout:.0f} seconds", "section": section, "section_timeout_seconds": section_timeout, }, ) finally: executor.shutdown(wait=False, cancel_futures=True) except Exception as exc: result = adapter_public_error(section_method, "adapter_section_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=6)}) running_steps = [] completed += 1 if isinstance(result, dict) and result.get("status") == "not_found": partial["object"] = result.get("object") partial["matches"] = result.get("matches") adapter_section_failed(partial, section, section_method, result) failed_steps.append(section) adapter_section_timing_finish(partial, section, "failed", section_started_at) for queued_section, queued_method, _ in steps: if queued_section in queued_steps: adapter_section_not_started( partial, queued_section, queued_method, result.get("diagnostics", {}).get("message") if isinstance(result.get("diagnostics"), dict) else (str(result.get("diagnostics")) if result.get("diagnostics") is not None else "Object was not found."), status="not_started_due_to_not_found", ) failed_steps.append(queued_section) queued_steps = [] partial["status"] = "partial" partial["elapsed_seconds"] = round(max(0.0, adapter_now() - started_at), 3) partial["last_section_update_at"] = adapter_now() adapter_update_full_job( job_id, partial, "done", completed, total, started_at=started_at, queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], }, ) return if not isinstance(result, dict) or result.get("status") not in {"ok", "partial"}: adapter_section_failed(partial, section, section_method, result) failed_steps.append(section) adapter_section_timing_finish(partial, section, "failed", section_started_at) elif section == "card": partial["object"] = result.get("object") partial["matches"] = result.get("matches") adapter_section_ok_or_empty(partial, section, partial.get("object")) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if partial.get("object") else "empty", section_started_at) elif section == "semantic": partial["object"] = result.get("object") if result.get("semantic") is not None: partial["semantic"] = result.get("semantic") else: partial.pop("semantic", None) partial["dimensions"] = result.get("dimensions") or [] partial["resources"] = result.get("resources") or [] partial["attributes"] = result.get("attributes") or [] partial["tabular_sections"] = result.get("tabular_sections") or [] partial.setdefault("counts", {}).update(result.get("counts") or {}) semantic_payload = partial.get("semantic") or partial.get("dimensions") or partial.get("resources") or partial.get("attributes") or partial.get("tabular_sections") adapter_section_ok_or_empty(partial, section, semantic_payload) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if semantic_payload else "empty", section_started_at) elif section == "forms": partial["forms"] = result.get("forms") or [] adapter_section_ok_or_empty(partial, section, partial["forms"]) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if partial["forms"] else "empty", section_started_at) elif section == "templates": partial["templates"] = result.get("templates") or [] adapter_section_ok_or_empty(partial, section, partial["templates"]) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if partial["templates"] else "empty", section_started_at) elif section == "commands": partial["commands"] = result.get("commands") or [] if "modules" in selected_sections: existing_modules = [item for item in partial.get("modules") or [] if isinstance(item, dict)] seen_module_refs = { str((item.get("read_selector") or {}).get("module_ref") or "") for item in existing_modules if isinstance(item.get("read_selector"), dict) } for module in result.get("modules") or []: module_ref = str((module.get("read_selector") or {}).get("module_ref") or "") if isinstance(module, dict) else "" if module_ref and module_ref in seen_module_refs: continue existing_modules.append(module) if module_ref: seen_module_refs.add(module_ref) partial["modules"] = existing_modules if existing_modules: partial["sections"]["modules"] = "ok" adapter_section_ok_or_empty(partial, section, partial["commands"]) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if partial["commands"] else "empty", section_started_at) elif section == "modules": partial["modules"] = result.get("modules") or [] adapter_section_ok_or_empty(partial, section, partial["modules"]) done_steps.append(section) adapter_section_timing_finish(partial, section, "ok" if partial["modules"] else "empty", section_started_at) elif section == "parts_summary": partial["parts_summary"] = {"counts": result.get("counts")} if evidence_mode in {"full", "raw"}: partial["parts_summary"]["parts"] = result.get("parts") or [] partial["sections"][section] = "ok" done_steps.append(section) adapter_section_timing_finish(partial, section, "ok", section_started_at) adapter_update_full_job( job_id, partial, section, completed, total, running_steps, started_at=started_at, queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) partial["status"] = "partial" if partial.get("failed_sections") else "ok" adapter_merge_full_counts(partial) finished_at = adapter_now() partial["elapsed_seconds"] = round(max(0.0, finished_at - started_at), 3) partial["last_section_update_at"] = finished_at adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": total, "total_steps": total, "percent": 100, "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], }, ) def adapter_attributes_partial_result(payload: dict[str, Any]) -> dict[str, Any]: return { "schema": "onec_metadata_object_attributes.v1", "status": "partial", "base_id": payload.get("base_id"), "source": {"kind": "live_metadata"}, "query": { "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), **({"ordinal": payload.get("ordinal")} if payload.get("ordinal") not in {None, ""} else {}), "only": payload.get("only") or payload.get("scope") or "all", "include_storage": truthy(payload.get("include_storage")), "use_cache": truthy(payload.get("use_cache")), **({"timeout_seconds": payload.get("timeout_seconds")} if payload.get("timeout_seconds") not in {None, ""} else {}), **({"section_timeout_seconds": payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds")} if (payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds")) not in {None, ""} else {}), }, "sections": { "card": "pending", "semantic": "pending", "type_resolution": "pending", "build_result": "pending", }, "object": None, "dimensions": [], "resources": [], "attributes": [], "tabular_sections": [], "counts": {"dimensions": 0, "resources": 0, "attributes": 0, "tabular_sections": 0, "resolved_reference_types": 0, "unresolved_reference_types": 0}, "failed_sections": [], "section_timings": {}, "diagnostics": [], } def adapter_mark_attributes_cancelled( partial: dict[str, Any], *, started_at: float, current_step: str = "cancelled", ) -> dict[str, Any]: now = adapter_now() partial["status"] = "cancelled" partial["elapsed_seconds"] = round(max(0.0, now - started_at), 3) partial["last_section_update_at"] = now for section, status in list((partial.get("sections") or {}).items()): if status in {"running", "pending"}: partial["sections"][section] = "cancelled" timing = partial.setdefault("section_timings", {}).setdefault(section, {"method": "metadata.object.attributes"}) timing["status"] = "cancelled" timing.setdefault("finished_at", now) if "started_at" in timing: timing["elapsed_seconds"] = round(max(0.0, now - float(timing.get("started_at") or now)), 3) partial.setdefault("diagnostics", []).append({"status": "cancelled", "message": "Запрос отменен пользователем."}) return { "current_step": current_step, "completed_steps": len([value for value in (partial.get("sections") or {}).values() if value == "ok"]), "total_steps": len(partial.get("sections") or {}), "percent": 100, "running_steps": [], "queued_steps": [], "done_steps": [key for key, value in (partial.get("sections") or {}).items() if value == "ok"], "failed_steps": [key for key, value in (partial.get("sections") or {}).items() if value in {"failed", "cancelled"}], "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], } def validate_metadata_object_attributes_payload(payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: selector_error = validate_object_selector_arguments(payload, "metadata.object.attributes") if selector_error: return None, selector_error if "extension_guid" in payload: extension_guid = payload.get("extension_guid") if not isinstance(extension_guid, str) or not is_guid_text(extension_guid.strip()): return None, invalid_argument( "metadata.object.attributes", "extension_guid", "extension_guid must be a GUID string.", ) table_or_error = metadata_storage_table(payload, "metadata.object.attributes") if isinstance(table_or_error, dict): return None, table_or_error table = table_or_error include_storage, include_storage_error = strict_bool_argument(payload, "include_storage", method="metadata.object.attributes", default=False) if include_storage_error: return None, include_storage_error refresh_cache, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method="metadata.object.attributes", default=False) if refresh_cache_error: return None, refresh_cache_error use_cache, use_cache_error = strict_bool_argument(payload, "use_cache", method="metadata.object.attributes", default=False) if use_cache_error: return None, use_cache_error resolve_generated_types, resolve_generated_types_error = strict_bool_argument( payload, "resolve_generated_types", method="metadata.object.attributes", default=False, ) if resolve_generated_types_error: return None, resolve_generated_types_error member = payload.get("member") if member is not None and (not isinstance(member, str) or not member.strip()): return None, invalid_argument("metadata.object.attributes", "member", "member must be a non-empty string.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.attributes", default=240, minimum=1) if timeout_error: return None, timeout_error _, adapter_timeout_error = parse_int_argument(payload, "adapter_timeout_seconds", method="metadata.object.attributes", default=0, minimum=1) if adapter_timeout_error: return None, adapter_timeout_error _, legacy_adapter_timeout_error = parse_int_argument(payload, "_adapter_timeout_seconds", method="metadata.object.attributes", default=0, minimum=1) if legacy_adapter_timeout_error: return None, legacy_adapter_timeout_error _, section_timeout_error = parse_int_argument(payload, "section_timeout_seconds", method="metadata.object.attributes", default=180, minimum=1) if section_timeout_error: return None, section_timeout_error _, legacy_section_timeout_error = parse_int_argument(payload, "_section_timeout_seconds", method="metadata.object.attributes", default=180, minimum=1) if legacy_section_timeout_error: return None, legacy_section_timeout_error if "offset" in payload: return None, invalid_argument( "metadata.object.attributes", "offset", "offset is not supported by metadata.object.attributes. Use only/limit to control returned attribute slices.", ) limit, limit_error = parse_int_argument(payload, "limit", method="metadata.object.attributes", default=20, minimum=1) if limit_error: return None, limit_error view, view_error = parse_view_argument(payload, "metadata.object.attributes") if view_error: return None, view_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.attributes") if ordinal_error: return None, ordinal_error raw_only = payload.get("only", payload.get("scope", "all")) raw_only_arg = payload.get("only") raw_scope_arg = payload.get("scope") if ("only" in payload and (raw_only_arg is None or raw_only_arg == "")) or ("scope" in payload and (raw_scope_arg is None or raw_scope_arg == "")): return None, invalid_argument( "metadata.object.attributes", "only" if "only" in payload else "scope", "only must be one of the allowed values.", allowed_values=["all", "attributes", "tabular_sections", "dimensions", "resources", "register_fields"], ) if not isinstance(raw_only, str): return None, invalid_argument( "metadata.object.attributes", "only", "only must be a string.", allowed_values=["all", "attributes", "tabular_sections", "dimensions", "resources", "register_fields"], ) only = raw_only.strip().casefold() if only not in ATTRIBUTE_ONLY_ALIASES: return None, invalid_argument( "metadata.object.attributes", "only", "only must be one of the allowed values.", allowed_values=["all", "attributes", "tabular_sections", "dimensions", "resources", "register_fields"], ) return { "include_storage": bool(include_storage), "refresh_cache": bool(refresh_cache), "use_cache": bool(use_cache), "resolve_generated_types": bool(resolve_generated_types), "member": str(member).strip() if isinstance(member, str) else None, "only": ATTRIBUTE_ONLY_ALIASES[only], "limit": limit, "view": view, "table": table, }, None def validate_metadata_relationship_verify_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.relationship.verify" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, method) if selector_error: return selector_error member = payload.get("member") if not isinstance(member, str) or not member.strip(): return invalid_argument(method, "member", "member must be an exact non-empty attribute or tabular-section column name.") target_ref = payload.get("target_ref") if target_ref is not None and (not isinstance(target_ref, str) or not target_ref.strip()): return invalid_argument(method, "target_ref", "target_ref must be a non-empty public 1C ref when provided.") if isinstance(target_ref, str): target_kind, target_name = parse_object_query(None, target_ref.strip()) if not target_kind or not target_name: return invalid_argument(method, "target_ref", "target_ref must be a public 1C object ref such as Document.РасходнаяНакладная.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) return timeout_error def validate_metadata_relationship_find_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.relationship.find" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, method) if selector_error: return selector_error target_ref = payload.get("target_ref") if not isinstance(target_ref, str) or not target_ref.strip(): return invalid_argument(method, "target_ref", "target_ref must be a non-empty public 1C object ref.") target_kind, target_name = parse_object_query(None, target_ref.strip()) if not target_kind or not target_name: return invalid_argument(method, "target_ref", "target_ref must be a public 1C object ref such as Document.РасходнаяНакладная.") execution_mode = payload.get("execution_mode") if execution_mode is not None and (not isinstance(execution_mode, str) or execution_mode.strip().casefold() != "job"): return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) direction = str(payload.get("direction") or "outgoing").strip().casefold() if direction not in {"outgoing", "incoming", "either"}: return invalid_argument(method, "direction", "direction must be outgoing, incoming, or either.", allowed_values=["outgoing", "incoming", "either"]) _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) return timeout_error def adapter_update_attributes_job( job_id: str, partial: dict[str, Any], current_step: str, completed: int, total: int, *, started_at: float, running_steps: list[str] | None = None, queued_steps: list[str] | None = None, done_steps: list[str] | None = None, failed_steps: list[str] | None = None, ) -> None: now = adapter_now() partial["elapsed_seconds"] = round(max(0.0, now - started_at), 3) partial["last_section_update_at"] = now progress: dict[str, Any] = { "current_step": current_step, "completed_steps": completed, "total_steps": total, "percent": int((completed / total) * 100) if total else 0, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], } if running_steps is not None: progress["running_steps"] = running_steps if queued_steps is not None: progress["queued_steps"] = queued_steps if done_steps is not None: progress["done_steps"] = done_steps if failed_steps is not None: progress["failed_steps"] = failed_steps adapter_job_set(job_id, partial_result=partial, current_step=current_step, progress=progress) def adapter_run_metadata_object_attributes_job(job_id: str, payload: dict[str, Any], timeout_seconds: float) -> None: partial = adapter_attributes_partial_result(payload) started_at = adapter_now() steps = ["card", "semantic", "type_resolution", "build_result"] queued_steps = list(steps) done_steps: list[str] = [] failed_steps: list[str] = [] adapter_update_attributes_job( job_id, partial, "starting", 0, len(steps), started_at=started_at, running_steps=[], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) try: if "card" in queued_steps: queued_steps.remove("card") partial["sections"]["card"] = "running" card_timeout = adapter_timeout_payload_value(timeout_seconds) card_started_at = adapter_section_timing_start(partial, "card", "metadata.object.get", card_timeout) adapter_update_attributes_job( job_id, partial, "card:metadata.object.get", 0, len(steps), started_at=started_at, running_steps=["card"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) card_result = call_method_impl("metadata.object.get", {**payload, "include_semantic": False, "timeout_seconds": card_timeout}) if isinstance(card_result, dict) and card_result.get("status") == "ok": partial["object"] = card_result.get("object") partial["sections"]["card"] = "ok" if partial.get("object") else "empty" adapter_section_timing_finish(partial, "card", "ok" if partial.get("object") else "empty", card_started_at) done_steps.append("card") else: adapter_section_failed(partial, "card", "metadata.object.get", card_result) adapter_section_timing_finish(partial, "card", "failed", card_started_at) failed_steps.append("card") reason = None if isinstance(card_result, dict): diagnostics = card_result.get("diagnostics") if isinstance(diagnostics, dict): reason = diagnostics.get("message") elif diagnostics is not None: reason = str(diagnostics) if card_result.get("object") is not None: partial["object"] = card_result.get("object") if card_result.get("matches") is not None: partial["matches"] = card_result.get("matches") reason = reason or "Object card was not resolved." for queued_section in list(queued_steps): partial.setdefault("sections", {})[queued_section] = "not_started_due_to_card_failure" partial.setdefault("failed_sections", []).append( { "section": queued_section, "method": "metadata.object.attributes", "status": "not_started_due_to_card_failure", "diagnostics": {"message": reason}, } ) failed_steps.append(queued_section) queued_steps = [] partial["status"] = str(card_result.get("status") or "partial") if isinstance(card_result, dict) else "partial" partial["elapsed_seconds"] = round(max(0.0, adapter_now() - started_at), 3) partial["last_section_update_at"] = adapter_now() adapter_update_attributes_job( job_id, partial, "done", 1, len(steps), started_at=started_at, running_steps=[], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": 1, "total_steps": len(steps), "percent": int((1 / len(steps)) * 100), "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial["elapsed_seconds"], "last_section_update_at": partial["last_section_update_at"], }, ) return adapter_update_attributes_job( job_id, partial, "card", 1, len(steps), started_at=started_at, running_steps=[], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) if adapter_job_cancel_requested(job_id): progress = adapter_mark_attributes_cancelled(partial, started_at=started_at) adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial, progress=progress) return if "semantic" in queued_steps: queued_steps.remove("semantic") partial["sections"]["semantic"] = "running" elapsed = adapter_now() - started_at remaining = max(1.0, timeout_seconds - elapsed) if (payload.get("section_timeout_seconds") or payload.get("_section_timeout_seconds")) in {None, ""}: section_timeout = min(120.0, remaining) else: section_timeout = adapter_section_timeout_seconds(payload, remaining) section_started_at = adapter_section_timing_start(partial, "semantic", "metadata.object.attributes", section_timeout) adapter_update_attributes_job( job_id, partial, "semantic:metadata.object.attributes", 1, len(steps), started_at=started_at, running_steps=["semantic"], queued_steps=queued_steps, done_steps=done_steps, failed_steps=failed_steps, ) executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix=f"onec-attrs-semantic-{job_id[:8]}") future = executor.submit(metadata_object_attributes, {**payload, "timeout_seconds": adapter_timeout_payload_value(section_timeout)}) try: try: result = future.result(timeout=section_timeout) except concurrent.futures.TimeoutError: future.cancel() result = adapter_public_error( "metadata.object.attributes", "section_timeout", { "message": f"Section `semantic` timed out after {section_timeout:.0f} seconds", "section": "semantic", "section_timeout_seconds": section_timeout, }, ) finally: executor.shutdown(wait=False, cancel_futures=True) if adapter_job_cancel_requested(job_id): progress = adapter_mark_attributes_cancelled(partial, started_at=started_at) adapter_job_finish(job_id, "cancelled", partial_result=partial, result=partial, progress=progress) return if not isinstance(result, dict) or result.get("status") not in {"ok", "partial"}: partial["status"] = "partial" adapter_section_failed(partial, "semantic", "metadata.object.attributes", result) adapter_section_timing_finish(partial, "semantic", "failed", section_started_at) failed_steps.append("semantic") for queued_section in list(queued_steps): partial.setdefault("sections", {})[queued_section] = "not_started_due_to_section_failure" adapter_update_attributes_job( job_id, partial, "done", len(steps), len(steps), started_at=started_at, running_steps=[], queued_steps=[], done_steps=done_steps, failed_steps=failed_steps, ) adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": len(steps), "total_steps": len(steps), "percent": 100, "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial.get("elapsed_seconds"), "last_section_update_at": partial.get("last_section_update_at"), }, ) return partial.update(result) partial.setdefault("sections", {})["semantic"] = "ok" partial.setdefault("sections", {})["type_resolution"] = "ok" partial.setdefault("sections", {})["build_result"] = "ok" adapter_section_timing_finish(partial, "semantic", "ok", section_started_at) done_steps.extend(["semantic", "type_resolution", "build_result"]) partial["status"] = result.get("status") or "ok" adapter_update_attributes_job( job_id, partial, "done", len(steps), len(steps), started_at=started_at, running_steps=[], queued_steps=[], done_steps=done_steps, failed_steps=failed_steps, ) adapter_job_finish( job_id, "done", result=partial, partial_result=partial, progress={ "current_step": "done", "completed_steps": len(steps), "total_steps": len(steps), "percent": 100, "running_steps": [], "queued_steps": [], "done_steps": done_steps, "failed_steps": failed_steps, "elapsed_seconds": partial.get("elapsed_seconds"), "last_section_update_at": partial.get("last_section_update_at"), }, ) except Exception as exc: adapter_job_finish(job_id, "error", **adapter_public_error("metadata.object.attributes", "adapter_job_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)})) def validate_metadata_object_get_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.get") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.get") if selector_error: return selector_error guid_error = validate_explicit_guid_argument(payload, "metadata.object.get") if guid_error: return guid_error if "mode" in payload and (payload.get("mode") is None or payload.get("mode") == ""): return invalid_argument("metadata.object.get", "mode", "mode must be one of: card, semantic.", allowed_values=["card", "semantic"]) if "mode" in payload and not isinstance(payload.get("mode"), str): return invalid_argument("metadata.object.get", "mode", "mode must be a JSON string.", allowed_values=["card", "semantic"]) ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.get") if ordinal_error: return ordinal_error _, limit_error = parse_int_argument(payload, "limit", method="metadata.object.get", default=20, minimum=1, maximum=5000) if limit_error: return limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.get", default=60, minimum=1) if timeout_error: return timeout_error _, view_error = parse_view_argument(payload, "metadata.object.get") if view_error: return view_error mode = str(payload.get("mode") or "card").strip().casefold() if mode not in {"card", "semantic"}: return invalid_argument("metadata.object.get", "mode", "mode must be one of: card, semantic.", allowed_values=["card", "semantic"]) _, include_storage_error = strict_bool_argument(payload, "include_storage", method="metadata.object.get", default=False) if include_storage_error: return include_storage_error _, include_semantic_error = strict_bool_argument(payload, "include_semantic", method="metadata.object.get", default=False) if include_semantic_error: return include_semantic_error table_or_error = metadata_storage_table(payload, "metadata.object.get") if isinstance(table_or_error, dict): return table_or_error return None def validate_modules_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "modules.search") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "modules.search") if selector_error: return selector_error normalized_payload = normalize_object_selector_aliases(payload, "modules.search") if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload query_value = payload.get("query") if query_value is not None and not isinstance(query_value, str): return invalid_argument("modules.search", "query", "query must be a JSON string.") query = str(query_value or "").strip() _, include_storage_error = strict_include_storage(payload, "modules.search") if include_storage_error: return include_storage_error _, resolve_owners_error = strict_bool_argument(payload, "resolve_owners", method="modules.search", default=False) if resolve_owners_error: return resolve_owners_error _, full_scan_error = strict_bool_argument(payload, "full_scan", method="modules.search", default=False) if full_scan_error: return full_scan_error if not query: return invalid_argument("modules.search", "query", "Передайте непустой query.") string_error = validate_optional_string_arguments( payload, "modules.search", ["table", "prefix", "scope", "extension", "extension_guid", "routine_name", "state"], ) if string_error: return string_error saved_extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(saved_extension_guid): return invalid_argument("modules.search", "extension_guid", "extension_guid must be a GUID string.") state = str(payload.get("state") or "working").strip().lower() if state not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument("modules.search", "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) scope = str(payload.get("scope") or "auto").strip().casefold() if scope not in {"auto", "modules", "configcas", "config", "all"}: return invalid_argument("modules.search", "scope", "Unsupported scope. Allowed values: auto, modules, configcas, config, all.", allowed_values=["auto", "modules", "configcas", "config", "all"]) table = str(payload.get("table") or "auto") if table != "auto" and table not in STORAGE_TABLES: return invalid_argument("modules.search", "table", "Unsupported storage table.", allowed_values=["auto", *sorted(STORAGE_TABLES)]) table_for_read = table if table in STORAGE_TABLES else "Config" for argument, default, minimum, maximum in ( ("scan_limit", 300, 1, 5000), ("owner_scan_limit", 40, 1, 200), ("read_max_chars", 4000, 1, 100000), ("timeout_seconds", 60, 1, None), ): _, int_error = parse_int_argument(payload, argument, method="modules.search", default=default, minimum=minimum, maximum=maximum) if int_error: return int_error _, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method="modules.search", default=20, minimum=1, maximum=100) if limit_error: return limit_error requested_module_ordinal = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") if requested_module_ordinal not in {None, ""}: _, ordinal_error = parse_ordinal(requested_module_ordinal, "modules.search", argument="module_ordinal") if ordinal_error: return ordinal_error object_ordinal_selector = first_non_empty_arg(payload, "ordinal", "index", "object_index") has_object_selector = bool(payload.get("guid") or payload.get("name") or (object_ordinal_selector is not None)) if has_object_selector: modules_result = metadata_object_modules({**payload, "include_storage": False, "table": table_for_read}) if modules_result.get("status") != "ok": result = dict(modules_result) result["method"] = "modules.search" return result available_modules = int((modules_result.get("counts") or {}).get("available_modules") or (modules_result.get("counts") or {}).get("modules") or len(modules_result.get("modules") or [])) if int(requested_module_ordinal or 1) > available_modules: return { "schema": "onec_modules_search.v1", "status": "not_found", "error": "module_not_found", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "object": modules_result.get("object"), "query": { "query": query, "limit": payload.get("limit") or 20, "scope": "object_modules", "kind": payload.get("kind"), "name": payload.get("name"), "guid": payload.get("guid"), "module_ordinal": requested_module_ordinal, "include_storage": payload.get("include_storage") or False, }, "matches": [], "counts": {"matches": 0, "available_modules": available_modules}, "diagnostics": {"message": f"Module ordinal {requested_module_ordinal} was not found for the selected object."}, } return None def validate_metadata_definition_find_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.definition.find" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error query_value = first_non_empty_arg(payload, "query", "definition", "identifier", "field", "requisite", "name_filter") if query_value is None: return invalid_argument(method, "query", "query must be a non-empty JSON string.") if not isinstance(query_value, str): return invalid_argument(method, "query", "query must be a JSON string.") if not query_value.strip(): return invalid_argument(method, "query", "query must be a non-empty JSON string.") _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error areas, areas_error = parse_definition_find_areas(payload) if areas_error: return areas_error _, exact_only_error = strict_bool_argument(payload, "exact_only", method=method, default=False) if exact_only_error: return exact_only_error _, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method=method, default=False) if refresh_cache_error: return refresh_cache_error _, use_cache_error = strict_bool_argument(payload, "use_cache", method=method, default=False) if use_cache_error: return use_cache_error for argument, default, minimum, maximum in ( ("timeout_seconds", 90, 1, None), ("max_items", 5000, 1, 5000), ("max_matches", 50, 1, 500), ("limit", 20, 1, 5000), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error ordinal_error = validate_explicit_ordinal_arguments(payload, method) if ordinal_error: return ordinal_error _, view_error = parse_view_argument(payload, method) if view_error: return view_error string_error = validate_optional_string_arguments( payload, method, ["ref", "object_type", "object_name", "object_guid", "kind", "name", "guid", "view", "form", "schema", "template"], ) if string_error: return string_error normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload if not normalized_payload.get("guid") and not normalized_payload.get("name") and not (set(areas) & {"metadata", "extensions"}): return invalid_argument(method, "name", OBJECT_SELECTOR_GLOBAL_REQUIRED_MESSAGE) return None def validate_metadata_resolve_overrides_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.resolve_overrides" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error method_name = payload.get("method_name") if method_name is None: return invalid_argument(method, "method_name", "method_name is required.") if not isinstance(method_name, str): return invalid_argument(method, "method_name", "method_name must be a JSON string.") if not str(method_name).strip(): return invalid_argument(method, "method_name", "method_name is required.") selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_non_empty_string_arguments(payload, method, ["extension"]) if string_error: return string_error string_error = validate_optional_string_arguments(payload, method, ["state"]) if string_error: return string_error if "state" in payload and str(payload.get("state") or "").strip().lower() not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument(method, "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload object_selector, _ = _code_query_object_selector(normalized_payload) if not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) return None def validate_code_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "code.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error query_value = payload.get("query") or payload.get("pattern") if query_value is not None and not isinstance(query_value, str): return invalid_argument(method, "query", "query must be a JSON string.") query = str(query_value or "").strip() if not query: return invalid_argument(method, "query", "Передайте непустой query.") saved_extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(saved_extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") _, include_line_numbers_error = strict_bool_argument(payload, "include_line_numbers", method=method, default=False) if include_line_numbers_error: return include_line_numbers_error _, include_context_error = strict_bool_argument(payload, "include_context", method=method, default=True) if include_context_error: return include_context_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error _, resolve_owners_error = strict_bool_argument(payload, "resolve_owners", method=method, default=False) if resolve_owners_error: return resolve_owners_error limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=25, minimum=1, maximum=500) if limit_error: return limit_error offset, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0) if offset_error: return offset_error scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=300, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error module_ordinal = payload.get("module_ordinal") if module_ordinal is not None: _, module_ordinal_error = parse_ordinal(module_ordinal, method, argument="module_ordinal") if module_ordinal_error: return module_ordinal_error selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_string_arguments(payload, method, ["scope", "table", "prefix", "extension", "routine_name", "state"]) if string_error: return string_error if "state" in payload and str(payload.get("state") or "").strip().lower() not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument(method, "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) string_error = validate_optional_non_empty_string_arguments(payload, method, ["query"]) if string_error: return string_error return None def validate_code_read_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "code.read" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, include_line_numbers_error = strict_bool_argument(payload, "include_line_numbers", method=method, default=False) if include_line_numbers_error: return include_line_numbers_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error _, include_text_error = strict_bool_argument(payload, "include_text", method=method, default=True) if include_text_error: return include_text_error _, prefer_code_index_error = strict_bool_argument(payload, "prefer_code_index", method=method, default=True) if prefer_code_index_error: return prefer_code_index_error selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_non_empty_string_arguments(payload, method, ["routine_name", "module_id", "module_ref", "state"]) if string_error: return string_error if "state" in payload and str(payload.get("state") or "").strip().lower() not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument(method, "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) module_ref_or_id = first_non_empty_arg(payload, "module_ref", "module_id", "module_ordinal", "module_index", "module_number") normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload object_selector, _ = _code_query_object_selector(normalized_payload) if module_ref_or_id is None and not any([object_selector.get("kind"), object_selector.get("guid"), object_selector.get("name")]): return invalid_argument( method, "selector", OBJECT_SELECTOR_OR_MODULE_REQUIRED_MESSAGE, ) _, max_chars_error = parse_int_argument(payload, "max_chars", method=method, default=100000, minimum=1) if max_chars_error: return max_chars_error return validate_modules_read_arguments(payload) def validate_code_write_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CODE_WRITE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error target = payload.get("target") if target is not None and not isinstance(target, dict): return invalid_argument(method, "target", "target must be a JSON object when provided.") string_error = validate_optional_string_arguments( payload, method, [ "ref", "kind", "name", "guid", "canonical_path", "path", "object_type", "object_name", "object_guid", "form", "form_name", "routine_name", "routine_text", "routine_operation", "operation", "text", "module_text", "full_text", "code", "old", "new", "extension", "preferred_extension", "module_ref", "module_id", "concrete_reference", "file_name", "layer_id", "mode", "execution_mode", "summary", ], ) if string_error: return string_error mode = str(payload.get("execution_mode") or payload.get("mode") or "apply").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) _, include_storage_error = strict_bool_argument(payload, "include_storage", method=method, default=False) if include_storage_error: return include_storage_error has_fragment = payload.get("old") is not None or payload.get("new") is not None if has_fragment and (payload.get("old") is None or payload.get("new") is None): return invalid_argument(method, "old/new", "Pass both old and new for fragment replacement.") has_edit = any(payload.get(key) is not None for key in ("routine_text", "text", "module_text", "full_text", "code", "old", "new")) if not has_edit: return invalid_argument(method, "edit", "Pass module text, routine_text, or old/new fragment replacement.") has_path_with_routine = bool( payload.get("canonical_path") or payload.get("path") or (isinstance(target, dict) and (target.get("canonical_path") or target.get("path"))) ) if payload.get("routine_text") is not None and not ( payload.get("routine_name") or (isinstance(target, dict) and target.get("routine_name")) or has_path_with_routine ): return invalid_argument(method, "routine_name", "Pass routine_name when replacing a routine.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) return timeout_error def validate_code_symbol_resolve_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "code.symbol.resolve" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error expression_value = first_non_empty_arg(payload, "expression", "symbol", "path") if expression_value is None: return invalid_argument(method, "expression", "expression is required.") if not isinstance(expression_value, str): return invalid_argument(method, "expression", "expression must be a JSON string.") if not expression_value.strip(): return invalid_argument(method, "expression", "expression must be a non-empty BSL expression.") selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_non_empty_string_arguments(payload, method, ["expression", "symbol", "path", "routine_name", "module_id", "module_ref"]) if string_error: return string_error module_ref_or_id = first_non_empty_arg(payload, "module_ref", "module_id", "module_ordinal", "module_index", "module_number") normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload object_selector, _ = _code_query_object_selector(normalized_payload) if module_ref_or_id is None and not any([object_selector.get("kind"), object_selector.get("guid"), object_selector.get("name")]): return invalid_argument(method, "selector", OBJECT_SELECTOR_OR_MODULE_REQUIRED_MESSAGE) _, max_chars_error = parse_int_argument(payload, "max_chars", method=method, default=200000, minimum=1) if max_chars_error: return max_chars_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return validate_modules_read_arguments(payload) def validate_extension_objects_find_payload(payload: dict[str, Any], method: str = "extension.objects.find") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_string_arguments(payload, method, ["query", "name_filter", "extension", "kind", "object_type", "name", "object_name", "guid", "object_guid", "table", "state"]) if string_error: return string_error if "state" in payload and str(payload.get("state") or "").strip().lower() not in EXTENSION_OBJECTS_FIND_STATES: return invalid_argument(method, "state", "Unsupported saved-state mode.", allowed_values=sorted(EXTENSION_OBJECTS_FIND_STATES)) _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error for argument, default in (("use_cache", True), ("refresh_cache", False), ("full_scan", False)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error _, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=50, minimum=1, maximum=500) if limit_error: return limit_error for argument, default, minimum, maximum in ( ("scan_limit", 5000, 1, 20000), ("cache_ttl_seconds", 300, 0, 86400), ("timeout_seconds", 90, 1, None), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error return None def validate_extension_cache_rebuild_payload(payload: dict[str, Any], method: str = "extension.cache.rebuild") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["extension", "kind", "object_type"]) if string_error: return string_error _, include_matches_error = strict_bool_argument(payload, "include_matches", method=method, default=False) if include_matches_error: return include_matches_error _, max_items_error = parse_int_argument(payload, "max_items", method=method, default=5000, minimum=1, maximum=50000) if max_items_error: return max_items_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=180, minimum=1) if timeout_error: return timeout_error return None def validate_extension_cache_status_payload(payload: dict[str, Any], method: str = "extension.cache.status") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["extension", "kind", "object_type"]) if string_error: return string_error _, include_entries_error = strict_bool_argument(payload, "include_entries", method=method, default=False) if include_entries_error: return include_entries_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error return None def validate_extension_cache_validate_payload(payload: dict[str, Any], method: str = "extension.cache.validate") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["extension", "kind", "object_type"]) if string_error: return string_error _, include_entries_error = strict_bool_argument(payload, "include_entries", method=method, default=False) if include_entries_error: return include_entries_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=50000) if limit_error: return limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1) if timeout_error: return timeout_error return None def validate_templates_read_payload(payload: dict[str, Any], method: str = "templates.read") -> dict[str, Any] | None: payload = normalize_template_route_ref_payload(payload) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_string_arguments(payload, method, ["template", "name_filter", "table", "extension", "owner_ref", "ref", "object_type", "object_name", "object_guid", "kind", "name", "guid", "file_name", "part_id", "route_ref", "view"]) if string_error: return string_error if payload.get("route_ref") and not parse_storage_route_ref(payload.get("route_ref")): return invalid_argument(method, "route_ref", "route_ref must have format
: where table is a supported storage table.") if payload.get("sections") is not None and not isinstance(payload.get("sections"), (str, list)): return invalid_argument(method, "sections", "sections must be a comma-separated string or a JSON array of strings.") if payload.get("moxel_record_heads") is not None and not isinstance(payload.get("moxel_record_heads"), (str, list)): return invalid_argument(method, "moxel_record_heads", "moxel_record_heads must be a comma-separated string or a JSON array of integers.") if payload.get("moxel_candidate_heads") is not None and not isinstance(payload.get("moxel_candidate_heads"), (str, list)): return invalid_argument(method, "moxel_candidate_heads", "moxel_candidate_heads must be a comma-separated string or a JSON array of integers.") if payload.get("moxel_candidate_reasons") is not None and not isinstance(payload.get("moxel_candidate_reasons"), (str, list)): return invalid_argument(method, "moxel_candidate_reasons", "moxel_candidate_reasons must be a comma-separated string or a JSON array of strings.") view = str(payload.get("view") or "").strip().lower() if view and view not in {"summary", "structure", "full"}: return invalid_argument(method, "view", "view must be one of: summary, structure, full.") _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error _, include_content_error = strict_bool_argument(payload, "include_content", method=method, default=False) if include_content_error: return include_content_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error for argument, default, minimum, maximum in ( ("max_areas", 20, 0, 5000), ("max_cells", 20, 0, 5000), ("max_parameters", 50, 0, 5000), ("max_coverage", 20, 0, 5000), ("max_widths", 50, 0, 5000), ("max_merged", 20, 0, 5000), ("max_intersections", 20, 0, 5000), ("max_strings", 20, 0, 1000), ("max_moxel_records", 20, 0, 1000), ("moxel_record_start", 0, 0, 100000), ("moxel_record_end", 0, 0, 100000), ("moxel_record_context", 0, 0, 100), ("moxel_candidate_rank", 1, 1, 1000), ("moxel_candidate_window_index", 1, 1, 1000), ("moxel_candidate_start", 0, 0, 100000), ("moxel_candidate_end", 0, 0, 100000), ("moxel_candidate_min_score", 0, 0, 1000), ("max_content_bytes", TEMPLATE_CONTENT_DEFAULT_MAX_BYTES, 1, TEMPLATE_CONTENT_MAX_BYTES), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error table_or_error = metadata_storage_table(payload, method) if isinstance(table_or_error, dict): return table_or_error normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload selector_value = first_non_empty_arg(normalized_payload, "ref", "guid", "name", "object_guid", "object_name", "file_name", "part_id", "route_ref", "ordinal", "index", "object_index") if selector_value in {None, ""}: return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) return None def validate_templates_areas_find_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "templates.areas.find" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload payload = normalize_template_route_ref_payload(payload) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, ["ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "query", "template", "name_filter", "extension", "table", "file_name", "part_id", "route_ref", "area_query", "area", "area_name", "area_match"], ) if string_error: return string_error area_match = str(payload.get("area_match") or "").strip().lower() if area_match and area_match not in {"contains", "exact"}: return invalid_argument(method, "area_match", "area_match must be one of: contains, exact.") if payload.get("route_ref") and not parse_storage_route_ref(payload.get("route_ref")): return invalid_argument(method, "route_ref", "route_ref must have format
: where table is a supported storage table.") if not str(first_non_empty_arg(payload, "query", "template", "name_filter", "name", "object_name", "file_name", "part_id", "route_ref") or "").strip(): return invalid_argument(method, "query", "Pass query/template/name or file_name.") for argument, default in (("refresh_cache", False), ("include_empty", True), ("include_coverage", True)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error for argument, default, minimum, maximum in ( ("limit", 5, 1, 50), ("max_areas", 500, 0, 5000), ("timeout_seconds", 90, 1, 600), ("cache_ttl_seconds", 300, 0, 86400), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error _, occurrence_error = parse_int_alias_argument(payload, "area_occurrence", "occurrence", method=method, default=0, minimum=0, maximum=100000) if occurrence_error: return occurrence_error if ("area_occurrence" in payload or "occurrence" in payload) and int(payload.get("area_occurrence") if "area_occurrence" in payload else payload.get("occurrence")) < 1: return invalid_argument(method, "area_occurrence", "area_occurrence/occurrence is 1-based and must be >= 1.") return None def validate_templates_bindings_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "templates.bindings" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error string_error = validate_optional_non_empty_string_arguments(payload, method, ["template"]) if string_error: return string_error normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload object_selector, _ = _code_query_object_selector(normalized_payload) if not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) return None def validate_template_field_lineage_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "template.field.lineage" base_error = validate_templates_bindings_payload(payload) if base_error: return base_error string_error = validate_optional_non_empty_string_arguments(payload, method, ["template", "field", "area"]) if string_error: return string_error for key in ("template", "field"): if not str(payload.get(key) or "").strip(): return invalid_argument(method, key, f"{key} is required.") _, resolve_scd_error = strict_bool_argument(payload, "resolve_scd", method=method, default=False) if resolve_scd_error: return resolve_scd_error return None def validate_diagnostics_call_chain_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "diagnostics.call_chain" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error entry_method = payload.get("entry_method") if entry_method is None or entry_method == "": entry_method = payload.get("method_name") if entry_method is not None and not isinstance(entry_method, str): return invalid_argument(method, "entry_method", "entry_method must be a JSON string.") if not str(entry_method or "").strip(): return invalid_argument(method, "entry_method", "entry_method is required.") module_ref = payload.get("module_ref") if module_ref is not None and not isinstance(module_ref, str): return invalid_argument(method, "module_ref", "module_ref must be a JSON string.") has_module_ref = bool(str(module_ref or "").strip()) for argument, default, minimum, maximum in ( ("depth", 1, 0, 6), ("max_nodes", 40, 1, 200), ("timeout_seconds", 30, 1, 120), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error if not has_module_ref: selector_error = validate_object_selector_arguments(payload, method, include_view=False) if selector_error: return selector_error normalized_payload = normalize_object_selector_aliases(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload object_selector, _ = _code_query_object_selector(normalized_payload) if not has_module_ref and not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) return None def validate_metadata_object_modules_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.modules") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.modules") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.modules", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.modules") if isinstance(table_or_error, dict): return table_or_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.modules") if ordinal_error: return ordinal_error _, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.modules") if lookup_limit_error: return lookup_limit_error _, view_error = parse_view_argument(payload, "metadata.object.modules") if view_error: return view_error _, requested_module_error = optional_string_filter(payload, ["module", "name_filter"], method="metadata.object.modules") if requested_module_error: return requested_module_error _, include_storage_error = strict_include_storage(payload, "metadata.object.modules") if include_storage_error: return include_storage_error return None def validate_metadata_object_components_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.components") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.components") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.components", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.components") if isinstance(table_or_error, dict): return table_or_error _, include_storage_error = strict_include_storage(payload, "metadata.object.components") if include_storage_error: return include_storage_error return None def validate_metadata_object_forms_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.forms") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.forms") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.forms", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.forms") if isinstance(table_or_error, dict): return table_or_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.forms") if ordinal_error: return ordinal_error _, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.forms") if lookup_limit_error: return lookup_limit_error _, view_error = parse_view_argument(payload, "metadata.object.forms") if view_error: return view_error for argument in ("include_text", "include_tree"): _, bool_error = strict_bool_argument(payload, argument, method="metadata.object.forms", default=False) if bool_error: return bool_error _, requested_form_error = optional_string_filter(payload, ["form", "name_filter"], method="metadata.object.forms") if requested_form_error: return requested_form_error _, include_storage_error = strict_include_storage(payload, "metadata.object.forms") if include_storage_error: return include_storage_error return None def validate_metadata_object_templates_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.templates") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.templates") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.templates", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.templates") if isinstance(table_or_error, dict): return table_or_error for argument in ("include_text", "include_tree"): _, bool_error = strict_bool_argument(payload, argument, method="metadata.object.templates", default=False) if bool_error: return bool_error _, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.templates") if evidence_mode_error: return evidence_mode_error _, requested_template_error = optional_string_filter(payload, ["template", "name_filter"], method="metadata.object.templates") if requested_template_error: return requested_template_error _, include_storage_error = strict_include_storage(payload, "metadata.object.templates") if include_storage_error: return include_storage_error return None def validate_metadata_object_related_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.related") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.related") if selector_error: return selector_error guid_error = validate_explicit_guid_argument(payload, "metadata.object.related") if guid_error: return guid_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.related", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.related") if isinstance(table_or_error, dict): return table_or_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.related") if ordinal_error: return ordinal_error _, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.related") if lookup_limit_error: return lookup_limit_error _, view_error = parse_view_argument(payload, "metadata.object.related") if view_error: return view_error _, include_storage_error = strict_include_storage(payload, "metadata.object.related") if include_storage_error: return include_storage_error _, include_text_error = strict_bool_argument(payload, "include_text", method="metadata.object.related", default=False) if include_text_error: return include_text_error _, guids_error = parse_int_argument(payload, "guids_per_record", method="metadata.object.related", default=5, minimum=1, maximum=50) if guids_error: return guids_error return None def validate_metadata_object_parts_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.parts") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.parts") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.parts", default=60, minimum=1) if timeout_error: return timeout_error table_or_error = metadata_storage_table(payload, "metadata.object.parts") if isinstance(table_or_error, dict): return table_or_error _, include_storage_error = strict_include_storage(payload, "metadata.object.parts") if include_storage_error: return include_storage_error for argument in ("include_text", "include_tree"): _, bool_error = strict_bool_argument(payload, argument, method="metadata.object.parts", default=False) if bool_error: return bool_error _, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.parts") if evidence_mode_error: return evidence_mode_error _, part_limit_error = parse_int_argument(payload, "part_limit", method="metadata.object.parts", default=200, minimum=1, maximum=5000) if part_limit_error: return part_limit_error guid_error = validate_explicit_guid_argument(payload, "metadata.object.parts") if guid_error: return guid_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.parts") if ordinal_error: return ordinal_error _, lookup_limit_error = parse_object_lookup_limit(payload, "metadata.object.parts") if lookup_limit_error: return lookup_limit_error _, view_error = parse_view_argument(payload, "metadata.object.parts") if view_error: return view_error return None def validate_metadata_object_form_details_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.form.details") if isinstance(base_id_or_error, dict): return base_id_or_error execution_mode = payload.get("execution_mode") if execution_mode not in {None, "", "job"}: return invalid_argument("metadata.object.form.details", "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) selector_error = validate_object_selector_arguments(payload, "metadata.object.form.details") if selector_error: return selector_error _, include_storage_error = strict_include_storage(payload, "metadata.object.form.details") if include_storage_error: return include_storage_error table_or_error = metadata_storage_table(payload, "metadata.object.form.details") if isinstance(table_or_error, dict): return table_or_error _, include_module_text_error = strict_bool_argument(payload, "include_module_text", method="metadata.object.form.details", default=False) if include_module_text_error: return include_module_text_error _, include_parameters_error = strict_bool_argument(payload, "include_parameters", method="metadata.object.form.details", default=True) if include_parameters_error: return include_parameters_error for argument, default, maximum in ( ("max_forms", 20, 100), ("max_items", 1000, 5000), ("max_attributes", 1000, 5000), ("max_commands", 1000, 5000), ("max_parameters", 80, 500), ): _, int_error = parse_int_argument(payload, argument, method="metadata.object.form.details", default=default, minimum=1, maximum=maximum) if int_error: return int_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.form.details", default=60, minimum=1) if timeout_error: return timeout_error element_error = validate_optional_string_arguments(payload, "metadata.object.form.details", ["element", "element_name", "element_path", "path", "element_id", "id"]) if element_error: return element_error return validate_metadata_object_forms_payload(payload) def validate_metadata_object_template_details_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.template.details") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.template.details") if selector_error: return selector_error _, include_storage_error = strict_include_storage(payload, "metadata.object.template.details") if include_storage_error: return include_storage_error table_or_error = metadata_storage_table(payload, "metadata.object.template.details") if isinstance(table_or_error, dict): return table_or_error _, include_preview_error = strict_bool_argument(payload, "include_preview", method="metadata.object.template.details", default=True) if include_preview_error: return include_preview_error _, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.object.template.details") if evidence_mode_error: return evidence_mode_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.template.details", default=60, minimum=1) if timeout_error: return timeout_error _, requested_template_error = optional_string_filter(payload, ["template", "name_filter"], method="metadata.object.template.details") if requested_template_error: return requested_template_error return validate_metadata_object_templates_payload(payload) def validate_metadata_form_decode_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.form.decode") if isinstance(base_id_or_error, dict): return base_id_or_error execution_mode = payload.get("execution_mode") if execution_mode not in {None, "", "job"}: return invalid_argument("metadata.form.decode", "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) selector_error = validate_object_selector_arguments(payload, "metadata.form.decode") if selector_error: return selector_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.form.decode", default=60, minimum=1) if timeout_error: return timeout_error _, include_storage_error = strict_include_storage(payload, "metadata.form.decode") if include_storage_error: return include_storage_error for argument in ("include_module_text", "include_module"): _, bool_error = strict_bool_argument(payload, argument, method="metadata.form.decode", default=False) if bool_error: return bool_error _, evidence_mode_error = parse_evidence_mode_argument(payload, "metadata.form.decode") if evidence_mode_error: return evidence_mode_error _, include_parameters_error = strict_bool_argument(payload, "include_parameters", method="metadata.form.decode", default=True) if include_parameters_error: return include_parameters_error _, max_items_error = parse_int_argument(payload, "max_items", method="metadata.form.decode", default=500, minimum=1, maximum=5000) if max_items_error: return max_items_error _, max_parameters_error = parse_int_argument(payload, "max_parameters", method="metadata.form.decode", default=80, minimum=1, maximum=500) if max_parameters_error: return max_parameters_error for argument in ("table", "file_name", "form_guid", "guid"): if argument not in payload: continue value = payload.get(argument) if value is None or value == "": return invalid_argument("metadata.form.decode", argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("metadata.form.decode", argument, f"{argument} must be a JSON string.") _, requested_form_error = optional_string_filter(payload, ["form", "name_filter"], method="metadata.form.decode") if requested_form_error: return requested_form_error element_error = validate_optional_string_arguments(payload, "metadata.form.decode", ["element", "element_name", "element_path", "path", "element_id", "id"]) if element_error: return element_error table = str(payload.get("table") or "Config") if table not in STORAGE_TABLES: return invalid_argument("metadata.form.decode", "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) return None def validate_metadata_form_owner_index_build_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_OWNER_INDEX_BUILD_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["extension", "kind", "object_type", "name", "object_name", "form", "form_name", "table", "file_name"]) if string_error: return string_error table = str(payload.get("table") or "") if table and table not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) for argument, default, minimum, maximum in ( ("limit", 10, 1, 100), ("scan_limit", 5000, 1, 20000), ("timeout_seconds", 90, 1, None), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error _, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method=method, default=False) if refresh_cache_error: return refresh_cache_error return None def validate_metadata_saved_state_forms_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = SAVED_STATE_FORMS_SEARCH_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error string_error = validate_optional_string_arguments( payload, method, [ "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "form", "form_name", "form_guid", "name_filter", "element", "element_name", "command", "attribute", "query", "text", "prefix", "extension", "extension_guid", ], ) if string_error: return string_error if "tables" in payload and not (isinstance(payload.get("tables"), list) and all(isinstance(item, str) for item in payload.get("tables") or [])): return invalid_argument(method, "tables", "tables must be an array of strings.") return None def validate_metadata_saved_state_prepare_payload(payload: dict[str, Any], *, method: str = "metadata.saved_state.prepare") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "layer", "target_table", "source_table", "table", "file_name", "module_ref", "module_id", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension", "mode", "execution_mode", ], ) if string_error: return string_error layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) target_table = str(payload.get("target_table") or payload.get("table") or "").strip() if target_table and target_table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "target_table", "target_table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) if layer and target_table and target_table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with target_table.") if layer == "base_saved_state" and str(payload.get("extension") or "").strip(): return invalid_argument(method, "layer", "Extension object preparation requires extension_saved_state.", allowed_values=["extension_saved_state"]) if "file_names" in payload and payload.get("file_names") is not None: file_names = payload.get("file_names") if not isinstance(file_names, list) or not all(isinstance(item, str) for item in file_names): return invalid_argument(method, "file_names", "file_names must be an array of strings.") execution_mode = str(payload.get("execution_mode") or "").strip().casefold() if execution_mode and not (method == "metadata.saved_state.ensure" and execution_mode == "job"): return invalid_argument(method, "execution_mode", "execution_mode may only be job for metadata.saved_state.ensure.", allowed_values=["job"]) mode = str(payload.get("mode") or "plan").strip().casefold() if mode not in {"plan", "apply", "apply_and_verify"}: return invalid_argument(method, "mode", "Unsupported mode.", allowed_values=["plan", "apply", "apply_and_verify"]) if mode in {"apply", "apply_and_verify"}: _, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_prepare", method=method, default=False) if allow_error: return allow_error _, part_limit_error = parse_int_argument(payload, "part_limit", method=method, default=5000, minimum=1, maximum=20000) if part_limit_error: return part_limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, include_storage_error = strict_include_storage(payload, method) return include_storage_error def validate_metadata_saved_state_status_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = SAVED_STATE_STATUS_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["layer", "table", "target_table", "prefix"]) if string_error: return string_error layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) table = str(payload.get("table") or payload.get("target_table") or "") if table and table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) if layer and table and table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with table.") prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") for argument, default, minimum, maximum in ( ("timeout_seconds", 30, 1, None), ("limit", 500, 1, 5000), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error for argument, default in (("include_files", True), ("include_unchanged", True)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error return None def validate_configuration_activation_payload( payload: dict[str, Any], *, method: str, ) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["layer"]) if string_error: return string_error layer = str(payload.get("layer") or "all").strip() if layer not in {"all", "base_saved_state", "extension_saved_state"}: return invalid_argument( method, "layer", "layer must be all, base_saved_state, or extension_saved_state.", allowed_values=["all", "base_saved_state", "extension_saved_state"], ) for argument, default, minimum, maximum in ( ("timeout_seconds", 30, 1, None), ("limit", 5000, 1, 5000), ): _, int_error = parse_int_argument( payload, argument, method=method, default=default, minimum=minimum, maximum=maximum, ) if int_error: return int_error _, include_files_error = strict_bool_argument( payload, "include_files", method=method, default=False, ) if include_files_error: return include_files_error _, include_storage_error = strict_include_storage(payload, method) return include_storage_error def validate_configuration_activation_request_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_REQUEST_METHOD validation_error = validate_configuration_activation_payload(payload, method=method) if validation_error: return validation_error _, ttl_error = parse_int_argument( payload, "ttl_seconds", method=method, default=1800, minimum=60, maximum=86400, ) return ttl_error def validate_configuration_activation_request_status_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_REQUEST_STATUS_METHOD string_error = validate_optional_string_arguments(payload, method, ["base_id", "request_id"]) if string_error: return string_error if not str(payload.get("request_id") or "").strip(): return invalid_argument(method, "request_id", "request_id is required.") return None def validate_configuration_activation_request_cancel_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_REQUEST_CANCEL_METHOD string_error = validate_optional_string_arguments(payload, method, ["base_id", "request_id"]) if string_error: return string_error if not str(payload.get("request_id") or "").strip(): return invalid_argument(method, "request_id", "request_id is required.") _, confirmation_error = strict_bool_argument( payload, "confirm_cancel", method=method, default=False, ) return confirmation_error def validate_configuration_activation_audit_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_AUDIT_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["status"]) if string_error: return string_error _, limit_error = parse_int_argument( payload, "limit", method=method, default=100, minimum=1, maximum=1000, ) return limit_error def validate_configuration_activation_capabilities_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_CAPABILITIES_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["layer"]) if string_error: return string_error layer = str(payload.get("layer") or "all").strip() if layer not in {"all", "base_saved_state", "extension_saved_state"}: return invalid_argument( method, "layer", "layer must be all, base_saved_state, or extension_saved_state.", allowed_values=["all", "base_saved_state", "extension_saved_state"], ) return None def validate_configuration_activation_bridge_probe_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["layer"]) if string_error: return string_error layer = str(payload.get("layer") or "all").strip() if layer not in {"all", "base_saved_state", "extension_saved_state"}: return invalid_argument( method, "layer", "layer must be all, base_saved_state, or extension_saved_state.", allowed_values=["all", "base_saved_state", "extension_saved_state"], ) _, timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=10, minimum=1, maximum=60, ) return timeout_error def validate_configuration_activation_execute_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_EXECUTE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["request_id", "mode"]) if string_error: return string_error if not str(payload.get("request_id") or "").strip(): return invalid_argument(method, "request_id", "request_id is required.") mode = str(payload.get("mode") or "debug").strip().casefold() if mode != "debug": return invalid_argument( method, "mode", "Only debug activation acceptance is currently available.", allowed_values=["debug"], ) _, confirmation_error = strict_bool_argument( payload, "confirm_activation", method=method, default=False, ) if confirmation_error: return confirmation_error _, bridge_debug_error = strict_bool_argument( payload, "bridge_debug", method=method, default=False, ) if bridge_debug_error: return bridge_debug_error _, timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=30, minimum=1, ) return timeout_error def validate_configuration_activation_verify_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = CONFIGURATION_ACTIVATION_VERIFY_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["request_id"]) if string_error: return string_error if not str(payload.get("request_id") or "").strip(): return invalid_argument(method, "request_id", "request_id is required.") _, timeout_error = parse_int_argument( payload, "timeout_seconds", method=method, default=30, minimum=1, ) return timeout_error def validate_metadata_saved_state_diff_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = SAVED_STATE_DIFF_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "table", "target_table", "source_table", "file_name", "module_ref", "module_id", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension", ], ) if string_error: return string_error for argument, default, minimum, maximum in ( ("timeout_seconds", 30, 1, None), ("max_changes", 200, 1, 5000), ("max_text_diff_lines", 200, 0, 5000), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error for argument, default in ( ("include_text_diff", True), ("include_tree_diff", True), ("include_evidence", False), ("include_payload_diff", False), ("include_storage", False), ): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() module_table = module_file_name = None if module_ref: module_table, module_file_name, _stream_index = parse_module_id(module_ref) if not module_table or not module_file_name: return invalid_argument(method, "module_ref", "Use module_ref in the form
:[#stream:].") table = str(payload.get("table") or payload.get("target_table") or module_table or "").strip() if table and table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) file_name = str(payload.get("file_name") or module_file_name or "").strip() if file_name and Path(file_name).name != file_name: return invalid_argument(method, "file_name", "file_name must be a safe FileName value.") requested_module_ordinal = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") if requested_module_ordinal not in {None, ""}: _, ordinal_error = parse_ordinal(requested_module_ordinal, method, argument="module_ordinal") if ordinal_error: return ordinal_error if not module_ref and not file_name and not has_object_selector(payload): return invalid_argument( method, "selector", "Pass a 1C object selector plus module_ordinal, or a generated module_ref/saved-state file_name.", ) return None def validate_metadata_saved_state_changes_list_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = SAVED_STATE_CHANGES_LIST_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["layer", "table", "target_table", "prefix"]) if string_error: return string_error layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) table = str(payload.get("table") or payload.get("target_table") or "").strip() if table and table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) if layer and table and table != SAVED_STATE_TABLE_BY_LAYER[layer]: return invalid_argument(method, "layer", "layer conflicts with table.") prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=500, minimum=1, maximum=5000) if limit_error: return limit_error _, include_unchanged_error = strict_bool_argument(payload, "include_unchanged", method=method, default=False) if include_unchanged_error: return include_unchanged_error _, include_context_error = strict_bool_argument(payload, "include_context", method=method, default=False) if include_context_error: return include_context_error _, group_by_context_error = strict_bool_argument(payload, "group_by_context", method=method, default=False) if group_by_context_error: return group_by_context_error _, include_storage_error = strict_bool_argument(payload, "include_storage", method=method, default=False) if include_storage_error: return include_storage_error _, context_limit_error = parse_int_argument(payload, "context_limit", method=method, default=50, minimum=0, maximum=500) return context_limit_error def validate_metadata_saved_state_modules_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = SAVED_STATE_MODULES_SEARCH_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error _, preview_chars_error = parse_int_argument(payload, "preview_chars", method=method, default=500, minimum=0, maximum=5000) if preview_chars_error: return preview_chars_error if "stream_index" in payload: _, stream_index_error = parse_int_argument(payload, "stream_index", method=method, default=0, minimum=0) if stream_index_error: return stream_index_error string_error = validate_optional_string_arguments( payload, method, ["query", "text", "prefix", "owner_guid", "ref", "object_type", "object_name", "object_guid", "kind", "name", "guid", "file_name", "extension", "extension_guid", "layer"], ) if string_error: return string_error if "tables" in payload and not (isinstance(payload.get("tables"), list) and all(isinstance(item, str) for item in payload.get("tables") or [])): return invalid_argument(method, "tables", "tables must be an array of strings.") layer = str(payload.get("layer") or "").strip() if layer and layer not in {"base_saved_state", "extension_saved_state"}: return invalid_argument( method, "layer", "Unsupported saved-state layer.", allowed_values=["base_saved_state", "extension_saved_state"], ) if layer and "tables" in payload: return invalid_argument(method, "tables", "Pass layer or tables, not both.") return None def validate_metadata_form_write_target_resolve_payload(payload: dict[str, Any], method: str = FORM_WRITE_TARGET_RESOLVE_METHOD) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, max_items_error = parse_int_argument(payload, "max_items", method=method, default=5000, minimum=1, maximum=5000) if max_items_error: return max_items_error _, search_limit_error = parse_int_argument(payload, "search_limit", method=method, default=10, minimum=1, maximum=1000) if search_limit_error: return search_limit_error _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error string_error = validate_optional_string_arguments( payload, method, ["ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "element_name", "command", "attribute", "element_path", "path", "element_id", "id", "property", "query", "prefix", "extension", "extension_guid"], ) if string_error: return string_error return None def validate_metadata_form_element_write_apply_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_ELEMENT_WRITE_APPLY_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) _, include_payload_error = strict_bool_argument(payload, "include_payload", method=method, default=False) if include_payload_error: return include_payload_error for argument in ("allow_sql_saved_state_prepare", "allow_existing_saved_state_target", "auto_prepare_saved_state"): if argument in payload: _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: _, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if mode == "apply_and_rollback": _, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return None def validate_metadata_object_property_write_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = OBJECT_PROPERTY_WRITE_METHOD payload, member_error = normalize_object_property_member_payload(payload) if member_error: return member_error base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, method) if selector_error: return selector_error if not has_object_selector(payload): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) string_error = validate_optional_string_arguments( payload, method, [ "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "member_ref", "child_ref", "member_kind", "member_name", "canonical_path", "extension", "layer", "property", "language", "expected_old", "expected_sha1", "execution_mode", "mode", "summary", ], ) if string_error: return string_error if "property" not in payload or not str(payload.get("property") or "").strip(): return invalid_argument(method, "property", "property is required.", allowed_values=["synonym", "comment"]) if not normalize_object_identity_property(payload.get("property")): raw_property = str(payload.get("property") or "").strip().casefold() message = ( "Object rename is intentionally disabled because reference-safe metadata rename rules are not yet proven." if raw_property in {"name", "имя", "rename", "переименование"} else "Only synonym and comment are supported." ) return invalid_argument(method, "property", message, allowed_values=["synonym", "comment"]) if "value" not in payload or not isinstance(payload.get("value"), str): return invalid_argument(method, "value", "value is required and must be a JSON string; an empty string is allowed.") layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) for argument, default in ( ("allow_saved_state_write", False), ("auto_prepare_saved_state", False), ("allow_sql_saved_state_prepare", False), ("allow_sql_saved_state_apply", False), ("allow_sql_saved_state_rollback", False), ): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, include_storage_error = strict_include_storage(payload, method) return include_storage_error def validate_metadata_object_member_add_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = OBJECT_MEMBER_ADD_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "template_member_ref", "new_member_name", "new_member_synonym", "new_member_comment", "extension", "layer", "expected_sha1", "execution_mode", "mode", "summary", ], ) if string_error: return string_error template_ref = str(payload.get("template_member_ref") or "").strip() if not template_ref: return invalid_argument(method, "template_member_ref", "template_member_ref is required.") normalized, selector_error = normalize_object_property_member_payload({**payload, "member_ref": template_ref}) if selector_error: return selector_error if canonical_nested_member_kind(normalized.get("member_kind")) != "Attribute": return invalid_argument(method, "template_member_ref", "template_member_ref must select an existing Attribute.") new_name = str(payload.get("new_member_name") or "").strip() if not new_name: return invalid_argument(method, "new_member_name", "new_member_name is required.") mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) for argument in ( "allow_saved_state_write", "auto_prepare_saved_state", "allow_sql_saved_state_prepare", "allow_sql_saved_state_apply", "allow_sql_saved_state_rollback", ): _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error _, include_storage_error = strict_include_storage(payload, method) return include_storage_error def validate_metadata_form_target_move_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_TARGET_MOVE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "from", "from_element", "from_path", "from_id", "to", "to_element", "to_path", "to_id", "with", "with_element", "with_path", "with_id", "after_element", "element", "element_path", "path", "expected_sha1", "summary", "execution_mode", "mode", "extension", "extension_guid", ], ) if string_error: return string_error table = str(payload.get("table") or "ConfigCASSave") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be used for target move.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) for argument in ("allow_saved_state_write", "include_payload"): _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: _, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if mode == "apply_and_rollback": _, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) return timeout_error def validate_metadata_form_command_button_write_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_COMMAND_BUTTON_WRITE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "extension", "extension_guid", "kind", "object_type", "name", "object_name", "guid", "object_guid", "form", "form_name", "name_filter", "command", "command_name", "command_title", "command_action", "handler", "handler_name", "handler_routine_operation", "handler_routine_text", "routine_operation", "routine_text", "button", "button_name", "button_title", "button_parent", "button_parent_name", "parent", "table", "file_name", "form_guid", "execution_mode", "mode", ], ) if string_error: return string_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error for argument in ("allow_saved_state_write", "include_storage", "include_handler", "allow_sql_saved_state_apply", "allow_sql_saved_state_rollback", "allow_sql_saved_state_prepare", "allow_existing_saved_state_target", "auto_prepare_saved_state"): _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error routine_operation = str(payload.get("handler_routine_operation") or payload.get("routine_operation") or "upsert") if routine_operation not in {"replace", "append", "upsert"}: return invalid_argument(method, "handler_routine_operation", "Unsupported handler routine operation.", allowed_values=["replace", "append", "upsert"]) mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) return None def validate_metadata_form_command_button_verify_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_COMMAND_BUTTON_VERIFY_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "extension", "extension_guid", "kind", "object_type", "name", "object_name", "guid", "object_guid", "form", "form_name", "name_filter", "command", "command_name", "command_title", "command_action", "handler", "handler_name", "button", "button_name", "table", "file_name", "form_guid", "state", "source_state", ], ) if string_error: return string_error _, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=5000, minimum=1, maximum=20000) if scan_limit_error: return scan_limit_error if "table" in payload and payload.get("table") not in {None, ""} and str(payload.get("table")) not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) return None def validate_metadata_module_write_apply_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = MODULE_WRITE_APPLY_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "module_ref", "module_id", "table", "file_name", "expected_sha1", "expected_text_sha1", "expected_contains", "old", "new", "text", "routine_name", "routine_text", "routine_operation", "operation", "expected_old_sha1", "expected_old_contains", "summary", "execution_mode", "mode", ], ) if string_error: return string_error if "replace" in payload and payload.get("replace") is not None and not isinstance(payload.get("replace"), dict): return invalid_argument(method, "replace", "replace must be a JSON object when provided.") if "routine" in payload and payload.get("routine") is not None and not isinstance(payload.get("routine"), dict): return invalid_argument(method, "routine", "routine must be a JSON object when provided.") routine_operation = str(payload.get("routine_operation") or payload.get("operation") or "replace").strip().casefold() if ("routine_text" in payload or "routine_name" in payload) and routine_operation not in {"replace", "append", "upsert"}: return invalid_argument(method, "routine_operation", "Unsupported routine operation.", allowed_values=["replace", "append", "upsert"]) table = str(payload.get("table") or "") if table and table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be used for module write.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() if module_ref: module_table, module_file_name, module_stream_index = parse_module_id(module_ref) if not module_table or not module_file_name: return invalid_argument(method, "module_ref", "Use module_ref in the form
:#stream:.") if module_table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "module_ref", "Only ConfigSave/ConfigCASSave module refs may be written.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) if module_stream_index is None and "stream_index" not in payload: return invalid_argument(method, "stream_index", "Pass stream_index or use module_ref/module_id with #stream:.") elif not payload.get("file_name"): return invalid_argument(method, "module_ref", "Pass module_ref/module_id or table + file_name + stream_index.") if "stream_index" in payload: _, stream_index_error = parse_int_argument(payload, "stream_index", method=method, default=0, minimum=0) if stream_index_error: return stream_index_error if "count" in payload: _, count_error = parse_int_argument(payload, "count", method=method, default=1, minimum=1) if count_error: return count_error for argument in ("allow_saved_state_write", "include_payload", "include_text"): _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: _, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if mode == "apply_and_rollback": _, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) return timeout_error def validate_metadata_write_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = METADATA_WRITE_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error target = payload.get("target") if target is not None and not isinstance(target, dict): return invalid_argument(method, "target", "target must be a JSON object when provided.") target_dict = target if isinstance(target, dict) else {} route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area") if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in { "form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание" }: route_kind = target_dict.get("kind") if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}: route_kind = payload.get("kind") target_kind = str(route_kind or "form").strip().casefold() if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"}: return invalid_argument(method, "target.kind", "Only form, module, object, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"]) mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) _, include_payload_error = strict_bool_argument(payload, "include_payload", method=method, default=False) if include_payload_error: return include_payload_error if mode in {"apply", "apply_and_verify", "apply_and_rollback"}: _, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error if mode == "apply_and_rollback": _, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return None def validate_metadata_write_plan_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = METADATA_WRITE_PLAN_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error target = payload.get("target") if target is not None and not isinstance(target, dict): return invalid_argument(method, "target", "target must be a JSON object when provided.") intent = payload.get("intent") if intent is not None and not isinstance(intent, dict): return invalid_argument(method, "intent", "intent must be a JSON object when provided.") string_error = validate_optional_string_arguments( payload, method, [ "canonical_path", "path", "target_kind", "kind", "area", "operation", "routine_operation", "property", "preferred_layer", "preferred_extension", "extension", "module_ref", "file_name", "form_guid", ], ) if string_error: return string_error _, resolve_origin_error = strict_bool_argument(payload, "resolve_origin", method=method, default=True) if resolve_origin_error: return resolve_origin_error _, origin_max_matches_error = parse_int_argument(payload, "origin_max_matches", method=method, default=20, minimum=1, maximum=100) if origin_max_matches_error: return origin_max_matches_error return None def validate_metadata_write_preflight_payload(payload: dict[str, Any]) -> dict[str, Any] | None: return validate_metadata_write_plan_payload(payload) def validate_metadata_write_learning_payload(payload: dict[str, Any], method: str) -> dict[str, Any] | None: if method in {FORM_WRITE_MATRIX_BUILD_METHOD, FORM_WRITE_MATRIX_SMOKE_METHOD}: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error table = str(payload.get("table") or "ConfigCASSave") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be used for write matrix.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error if method == FORM_WRITE_MATRIX_SMOKE_METHOD: _, max_candidates_error = parse_int_argument(payload, "max_candidates", method=method, default=100, minimum=1, maximum=5000) if max_candidates_error: return max_candidates_error _, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_apply_error: return allow_apply_error _, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_rollback_error: return allow_rollback_error return validate_optional_string_arguments( payload, method, ["learning_id", "ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value", "extension", "extension_guid"], ) if method in {"metadata.write_learning.capture_before", "metadata.write_learning.capture_after"}: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error table = str(payload.get("table") or "ConfigCASSave") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be captured for write learning.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return validate_optional_string_arguments( payload, method, ["learning_id", "ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value", "extension", "extension_guid"], ) if method == "metadata.write_learning.diff": return validate_optional_string_arguments(payload, method, ["learning_id", "before_snapshot_id", "after_snapshot_id", "before_path", "after_path"]) if method == "metadata.write_learning.infer_rule": _, allow_multiple_error = strict_bool_argument(payload, "allow_multiple", method=method, default=False) if allow_multiple_error: return allow_multiple_error if "diff" in payload and payload.get("diff") is not None and not isinstance(payload.get("diff"), dict): return invalid_argument(method, "diff", "diff must be a JSON object when provided.") return validate_optional_string_arguments(payload, method, ["learning_id", "before_snapshot_id", "after_snapshot_id", "before_path", "after_path", "mode", "base_id"]) return None def validate_metadata_snapshot_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.snapshot") if isinstance(base_id_or_error, dict): return base_id_or_error _, include_modules_error = strict_bool_argument(payload, "include_modules", method="metadata.snapshot", default=False) if include_modules_error: return include_modules_error if "limit" in payload: return invalid_argument("metadata.snapshot", "limit", "metadata.snapshot does not support limit; use metadata.objects.list for paged object lists.") return None def validate_metadata_kinds_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.kinds") if isinstance(base_id_or_error, dict): return base_id_or_error if "limit" in payload: return invalid_argument("metadata.kinds", "limit", "metadata.kinds does not support limit; use metadata.objects.list for paged object lists.") if "include_storage" in payload: return invalid_argument("metadata.kinds", "include_storage", "metadata.kinds does not expose storage details.") return None def validate_help_methods_payload(payload: dict[str, Any]) -> dict[str, Any] | None: if "method" in payload and payload.get("method") is not None and not isinstance(payload.get("method"), str): return invalid_argument("help.methods", "method", "method must be a JSON string.") return None def validate_metadata_capabilities_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.capabilities") if isinstance(base_id_or_error, dict): return base_id_or_error kind_error = validate_optional_string_arguments(payload, "metadata.capabilities", ["kind"]) if kind_error: return kind_error _, include_missing_error = strict_bool_argument(payload, "include_missing", method="metadata.capabilities", default=False) if include_missing_error: return include_missing_error if payload.get("kind"): wanted_kind, requested_public = parse_kind_request(payload.get("kind")) if not any(kind_matches_request(kind, wanted_kind, requested_public) for kind in KIND_CAPABILITIES): return { "schema": "onec_metadata_capabilities.v1", "status": "not_found", "error": "not_found", "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "query": {"kind": payload.get("kind"), "include_missing": payload.get("include_missing") or False}, "capabilities": [], "counts": {"kinds": 0}, "diagnostics": {"message": "Вид метаданных не найден или не поддерживается адаптером."}, } return None def validate_metadata_adapter_audit_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.adapter.audit") if isinstance(base_id_or_error, dict): return base_id_or_error if "include_details" in payload: return invalid_argument( "metadata.adapter.audit", "include_details", "metadata.adapter.audit does not support include_details; use include_unmapped=true for additional audit sections.", ) for argument in ("include_missing", "include_unmapped"): _, bool_error = strict_bool_argument(payload, argument, method="metadata.adapter.audit", default=False) if bool_error: return bool_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.adapter.audit", default=60, minimum=1) if timeout_error: return timeout_error return None def validate_extensions_list_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "extensions.list") if isinstance(base_id_or_error, dict): return base_id_or_error _, include_storage_error = strict_include_storage(payload, "extensions.list") if include_storage_error: return include_storage_error if "limit" in payload: _, limit_error = parse_int_argument(payload, "limit", method="extensions.list", default=1, minimum=1) if limit_error: return limit_error if "offset" in payload: _, offset_error = parse_int_argument(payload, "offset", method="extensions.list", default=0, minimum=0) if offset_error: return offset_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="extensions.list", default=30, minimum=1) if timeout_error: return timeout_error return None def validate_schema_tables_list_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "schema.tables.list") if isinstance(base_id_or_error, dict): return base_id_or_error _, include_columns_error = strict_bool_argument(payload, "include_columns", method="schema.tables.list", default=False) if include_columns_error: return include_columns_error _, limit_error = parse_int_argument(payload, "limit", method="schema.tables.list", default=500, minimum=1, maximum=5000) if limit_error: return limit_error like_error = validate_optional_non_empty_string_arguments(payload, "schema.tables.list", ["like"]) if like_error: return like_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="schema.tables.list", default=30, minimum=1) if timeout_error: return timeout_error return require_diagnostic_mode(payload, "schema.tables.list") def validate_storage_files_list_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "storage.files.list") if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, "storage.files.list") if isinstance(table_or_error, dict): return table_or_error _, limit_error = parse_int_argument(payload, "limit", method="storage.files.list", default=200, minimum=1, maximum=5000) if limit_error: return limit_error prefix_error = validate_optional_non_empty_string_arguments( payload, "storage.files.list", ["prefix", "exact_file_name"], ) if prefix_error: return prefix_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="storage.files.list", default=30, minimum=1) if timeout_error: return timeout_error return require_diagnostic_mode(payload, "storage.files.list") def validate_storage_file_get_payload(payload: dict[str, Any], method: str = "storage.file.get") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, method) if isinstance(table_or_error, dict): return table_or_error if method == "storage.file.get": _, include_payload_error = strict_bool_argument(payload, "include_payload", method=method, default=False) if include_payload_error: return include_payload_error if "file_name" in payload and not isinstance(payload.get("file_name"), str): return invalid_argument(method, "file_name", "file_name must be a JSON string.") file_name = str(payload.get("file_name") or "") if not file_name or Path(file_name).name != file_name: return invalid_argument(method, "file_name", "Pass a single safe FileName value from the live SQL storage table.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return require_diagnostic_mode(payload, method) def validate_storage_saved_state_apply_proposal_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "storage.saved_state.apply_proposal" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) if allow_error: return allow_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error proposal = payload.get("proposal") if proposal is not None and not isinstance(proposal, dict): return invalid_argument(method, "proposal", "proposal must be a JSON object.") return None def validate_storage_saved_state_rollback_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "storage.saved_state.rollback" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_error: return allow_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return validate_optional_string_arguments(payload, method, ["backup_id", "backup_path"]) def validate_storage_saved_state_backups_list_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "storage.saved_state.backups.list" if "base_id" in payload: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["table", "file_name"]) if string_error: return string_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=500) if limit_error: return limit_error return None def validate_storage_saved_state_backups_prune_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "storage.saved_state.backups.prune" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["table", "file_name"]) if string_error: return string_error table = str(payload.get("table") or "").strip() if table and table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument( method, "table", "Only saved-state storage tables can be selected.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES), ) for argument, default, minimum, maximum in ( ("older_than_days", 30, 1, 3650), ("keep_latest", 20, 0, 10000), ("limit", 500, 1, 10000), ): _, integer_error = parse_int_argument( payload, argument, method=method, default=default, minimum=minimum, maximum=maximum, ) if integer_error: return integer_error for argument, default in ( ("dry_run", True), ("confirm_delete", False), ("diagnostic", False), ): _, bool_error = strict_bool_argument( payload, argument, method=method, default=default, ) if bool_error: return bool_error return None def validate_metadata_dbnames_summary_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.dbnames.summary") if isinstance(base_id_or_error, dict): return base_id_or_error prefix_error = validate_optional_string_arguments(payload, "metadata.dbnames.summary", ["prefix"]) if prefix_error: return prefix_error _, limit_error = parse_int_argument(payload, "limit", method="metadata.dbnames.summary", default=50, minimum=1, maximum=5000) if limit_error: return limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.dbnames.summary", default=30, minimum=1) if timeout_error: return timeout_error return require_diagnostic_mode(payload, "metadata.dbnames.summary") def validate_codec_decode_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "codec.decode") if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = storage_table(payload, "codec.decode") if isinstance(table_or_error, dict): return table_or_error for argument, default in (("include_text", True), ("include_tree", False)): _, bool_error = strict_bool_argument(payload, argument, method="codec.decode", default=default) if bool_error: return bool_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="codec.decode", default=30, minimum=1) if timeout_error: return timeout_error if "file_name" in payload and not isinstance(payload.get("file_name"), str): return invalid_argument("codec.decode", "file_name", "file_name must be a JSON string.") file_name = str(payload.get("file_name") or "") if not file_name or Path(file_name).name != file_name: return invalid_argument("codec.decode", "file_name", "Pass a single safe FileName value from the live SQL storage table.") return require_diagnostic_mode(payload, "codec.decode") def validate_payload_diff_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "payload.diff" if "base_id" in payload: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("before", "after"): if not isinstance(payload.get(argument), dict): return invalid_argument(method, argument, f"{argument} must be a JSON object source.") source = payload.get(argument) or {} for source_key in ("payload_base64", "payload_hex", "text", "base_id", "table", "file_name", "encoding"): if source_key in source and source.get(source_key) is not None and not isinstance(source.get(source_key), str): return invalid_argument(method, f"{argument}.{source_key}", f"{argument}.{source_key} must be a JSON string.") for argument, default in (("include_text_diff", True), ("include_tree_diff", True), ("include_evidence", True)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error for argument, default, minimum, maximum in ( ("timeout_seconds", 30, 1, None), ("max_changes", 200, 1, 5000), ("max_text_diff_lines", 200, 0, 5000), ): _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error return require_diagnostic_mode(payload, method) def validate_codec_encode_payload(payload: dict[str, Any]) -> dict[str, Any] | None: _, include_payload_error = strict_bool_argument(payload, "include_payload", method="codec.encode", default=False) if include_payload_error: return include_payload_error if "text" in payload and not isinstance(payload.get("text"), str): return invalid_argument("codec.encode", "text", "text must be a JSON string.") if "source" in payload and payload.get("source") is not None and not isinstance(payload.get("source"), dict): return invalid_argument("codec.encode", "source", "source must be a JSON object.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="codec.encode", default=30, minimum=1) if timeout_error: return timeout_error return require_diagnostic_mode(payload, "codec.encode") def validate_query_validate_payload(payload: dict[str, Any], method: str = "query.validate") -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error if "query" not in payload: return invalid_argument(method, "query", "query is required and must be a non-empty JSON string.") if not isinstance(payload.get("query"), str): return invalid_argument(method, "query", "query must be a JSON string.") if not str(payload.get("query") or "").strip(): return invalid_argument(method, "query", "query is required and must be a non-empty JSON string.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return None def validate_query_run_payload(payload: dict[str, Any]) -> dict[str, Any] | None: query_error = validate_query_validate_payload(payload, "query.run") if query_error: return query_error _, limit_error = parse_int_argument(payload, "limit", method="query.run", default=100, minimum=1, maximum=1000) if limit_error: return limit_error return require_diagnostic_mode(payload, "query.run") def validate_object_ordinal_exists_for_job(method: str, payload: dict[str, Any]) -> dict[str, Any] | None: ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if ordinal_value in {None, ""}: return None base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error table_or_error = metadata_storage_table(payload, method) if isinstance(table_or_error, dict): return table_or_error table = str(payload.get("table") or "Config") kind = canonical_kind(str(payload.get("kind") or "")) if payload.get("kind") else None ordinal, ordinal_error = parse_ordinal(ordinal_value, method) if ordinal_error: return ordinal_error if not kind: return { "schema": "onec_adapter_request_error.v1", "method": method, "status": "error", "error": "kind_required", "diagnostics": {"message": "kind is required when selecting an object by ordinal."}, } page = list_objects(kind, base_id=base_id_or_error, limit=1, offset=int(ordinal or 1) - 1, include_storage=False, table=table) if page.get("status") != "ok" or not page.get("objects"): return { "schema": page.get("schema") or "onec_adapter_request_error.v1", "method": method, "status": "not_found", "error": "not_found", "base_id": base_id_or_error, "counts": page.get("counts"), "diagnostics": {"message": f"Object ordinal {ordinal} was not found for kind {kind}."}, } return None def validate_metadata_cache_status_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.cache.status") if isinstance(base_id_or_error, dict): return base_id_or_error _, include_samples_error = strict_bool_argument(payload, "include_samples", method="metadata.cache.status", default=False) if include_samples_error: return include_samples_error return None def validate_metadata_cache_lookup_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.cache.lookup" payload = normalize_object_selector_aliases(payload, method) if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("guid", "kind", "name"): if argument not in payload: continue value = payload.get(argument) if value is None or value == "": return invalid_argument("metadata.cache.lookup", argument, f"{argument} must be a non-empty JSON string when provided.") if not isinstance(value, str): return invalid_argument("metadata.cache.lookup", argument, f"{argument} must be a JSON string.") return None def validate_metadata_module_owner_cache_prune_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.module_owner_cache.prune" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments( payload, method, [ "owner_guid", "module_ref", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", ], ) if string_error: return string_error owner_guid = str(payload.get("owner_guid") or "").strip() if owner_guid and not is_guid_text(owner_guid): return invalid_argument(method, "owner_guid", "owner_guid must be a valid GUID JSON string.") module_refs = payload.get("module_refs") if module_refs is not None and ( not isinstance(module_refs, list) or any(not isinstance(item, str) or not item.strip() for item in module_refs) ): return invalid_argument(method, "module_refs", "module_refs must be an array of non-empty strings.") if not owner_guid and not str(payload.get("module_ref") or "").strip() and not module_refs and not has_object_selector(payload): return invalid_argument( method, "selector", "Pass a 1C owner selector (ref or kind/name), owner_guid, module_ref, or module_refs.", ) for argument, default in (("dry_run", False), ("include_storage", False)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) return timeout_error def validate_metadata_module_owner_cache_backfill_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.module_owner_cache.backfill" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["kind", "table"]) if string_error: return string_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=200) if limit_error: return limit_error _, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0) if offset_error: return offset_error _, kind_index_error = parse_int_argument(payload, "kind_index", method=method, default=0, minimum=0) if kind_index_error: return kind_index_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) if timeout_error: return timeout_error _, refresh_error = strict_bool_argument(payload, "refresh_cache", method=method, default=False) if refresh_error: return refresh_error _, deep_error = strict_bool_argument(payload, "deep", method=method, default=False) return deep_error def validate_semantic_cache_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.search" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["query", "kind", "object_kind"]) if string_error: return string_error if payload.get("query_embedding") is not None and not isinstance(payload.get("query_embedding"), list): return invalid_argument(method, "query_embedding", "query_embedding must be a JSON array of numbers.") if not str(payload.get("query") or "").strip() and payload.get("query_embedding") is None: return invalid_argument(method, "query", "Pass query text or query_embedding.") _, include_vectors_error = strict_bool_argument(payload, "include_vectors", method=method, default=False) if include_vectors_error: return include_vectors_error _, validate_candidates_error = strict_bool_argument(payload, "validate_candidates", method=method, default=False) if validate_candidates_error: return validate_candidates_error _, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=10000) if scan_limit_error: return scan_limit_error _, validation_limit_error = parse_int_argument(payload, "validation_limit", method=method, default=20, minimum=1, maximum=200) if validation_limit_error: return validation_limit_error _, validation_timeout_error = parse_int_argument(payload, "validation_timeout_seconds", method=method, default=30, minimum=1, maximum=300) if validation_timeout_error: return validation_timeout_error return None def validate_semantic_cache_pending_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.pending" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["kind", "object_kind", "vector_status"]) if string_error: return string_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error status_filter = str(payload.get("vector_status") or "pending_embedding").strip() if status_filter not in {"pending_embedding", "embedded", "error", "all"}: return invalid_argument(method, "vector_status", "vector_status must be one of: pending_embedding, embedded, error, all.") return None def validate_semantic_cache_status_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.status" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["kind", "object_kind"]) if string_error: return string_error _, include_entries_error = strict_bool_argument(payload, "include_entries", method=method, default=False) if include_entries_error: return include_entries_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) if limit_error: return limit_error return None def validate_semantic_cache_validate_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.validate" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error value = payload.get("document_id") if value is None or value == "": return invalid_argument(method, "document_id", "document_id is required.") if not isinstance(value, str): return invalid_argument(method, "document_id", "document_id must be a JSON string.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) if timeout_error: return timeout_error return None def validate_semantic_cache_validate_batch_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.validate_batch" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["kind", "object_kind", "vector_status"]) if string_error: return string_error ids_value = payload.get("document_ids") if ids_value is not None: if not isinstance(ids_value, list): return invalid_argument(method, "document_ids", "document_ids must be a JSON array of strings.") if not ids_value: return invalid_argument(method, "document_ids", "document_ids must not be empty when provided.") for item in ids_value: if not isinstance(item, str): return invalid_argument(method, "document_ids", "document_ids must contain only strings.") status_filter = str(payload.get("vector_status") or "all").strip() if status_filter not in {"pending_embedding", "embedded", "error", "all"}: return invalid_argument(method, "vector_status", "vector_status must be one of: pending_embedding, embedded, error, all.") _, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) if limit_error: return limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) if timeout_error: return timeout_error return None def validate_semantic_cache_refresh_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.refresh" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error value = payload.get("document_id") if value is None or value == "": return invalid_argument(method, "document_id", "document_id is required.") if not isinstance(value, str): return invalid_argument(method, "document_id", "document_id must be a JSON string.") _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) if timeout_error: return timeout_error return None def validate_semantic_cache_rebuild_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.rebuild" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["extension", "kind", "object_kind", "object_type"]) if string_error: return string_error object_kind = canonical_kind(str(payload.get("kind") or payload.get("object_kind") or payload.get("object_type") or "Template")) if object_kind != "Template": return invalid_argument(method, "kind", "semantic.cache.rebuild currently supports kind=Template only.") _, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=5000) if limit_error: return limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) if timeout_error: return timeout_error for argument, default in (("refresh_routes", False), ("include_entries", False)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error return None def validate_semantic_cache_embedding_upsert_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.embedding.upsert" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("document_id", "content_sha1", "embedding_model"): value = payload.get(argument) if value is None or value == "": return invalid_argument(method, argument, f"{argument} is required.") if not isinstance(value, str): return invalid_argument(method, argument, f"{argument} must be a JSON string.") if payload.get("embedding") is None: return invalid_argument(method, "embedding", "embedding is required.") if not isinstance(payload.get("embedding"), list): return invalid_argument(method, "embedding", "embedding must be a JSON array of numbers.") if numeric_vector(payload.get("embedding")) is None: return invalid_argument(method, "embedding", "embedding must be a non-empty JSON array of numbers.") return None def validate_metadata_object_commands_payload(payload: dict[str, Any]) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, "metadata.object.commands") if isinstance(base_id_or_error, dict): return base_id_or_error selector_error = validate_object_selector_arguments(payload, "metadata.object.commands") if selector_error: return selector_error _, requested_command_error = optional_string_filter(payload, ["command", "name_filter"], method="metadata.object.commands") if requested_command_error: return requested_command_error _, include_storage_error = strict_include_storage(payload, "metadata.object.commands") if include_storage_error: return include_storage_error table_or_error = metadata_storage_table(payload, "metadata.object.commands") if isinstance(table_or_error, dict): return table_or_error for argument, default in (("include_form_commands", True), ("refresh_cache", False)): _, bool_error = strict_bool_argument(payload, argument, method="metadata.object.commands", default=default) if bool_error: return bool_error for argument, default, maximum in ( ("max_forms", 20, 100), ("max_items", 200, 5000), ("max_form_items", 200, 5000), ("max_attributes", 100, 5000), ("max_commands", 200, 5000), ("limit", 20, 5000), ): _, int_error = parse_int_argument(payload, argument, method="metadata.object.commands", default=default, minimum=1, maximum=maximum) if int_error: return int_error ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.commands") if ordinal_error: return ordinal_error _, view_error = parse_view_argument(payload, "metadata.object.commands") if view_error: return view_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.commands", default=60, minimum=1) if timeout_error: return timeout_error return None def validate_metadata_code_index_payload(payload: dict[str, Any], method: str) -> dict[str, Any] | None: base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_args = [ "query", "pattern", "mode", "table", "prefix", "module_ref", "module_id", "ref", "object_type", "object_name", "object_guid", "kind", "name", "guid", "extension_guid", "embedding_model", "chunk_id", "text_sha1", "execution_mode", ] string_error = validate_optional_string_arguments(payload, method, string_args) if string_error: return string_error if method == "metadata.code_index.search" and payload.get("execution_mode") not in {None, "", "job"}: return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") if payload.get("query_embedding") is not None and not isinstance(payload.get("query_embedding"), list): return invalid_argument(method, "query_embedding", "query_embedding must be a JSON array of numbers.") if payload.get("embedding") is not None and not isinstance(payload.get("embedding"), list): return invalid_argument(method, "embedding", "embedding must be a JSON array of numbers.") for argument, default, minimum, maximum in ( ("limit", 20, 1, 500), ("max_matches", 20, 1, 500), ("scan_limit", 1000, 1, 50000), ("max_items", 500, 1, 50000), ("verify_limit", 200, 1, 5000), ("overfetch_factor", 5, 1, 20), ("timeout_seconds", 60, 1, 600), ): if argument in payload: _, int_error = parse_int_argument(payload, argument, method=method, default=default, minimum=minimum, maximum=maximum) if int_error: return int_error for argument in ("verify", "strict", "refresh_stale", "include_vectors", "include_active"): if argument in payload: _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) if bool_error: return bool_error return None def validate_metadata_write_history_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "metadata.write.history" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=200) if limit_error: return limit_error _, include_summary_error = strict_bool_argument(payload, "include_summary", method=method, default=False) if include_summary_error: return include_summary_error string_error = validate_optional_string_arguments(payload, method, ["operation_id", "operation_method", "write_method", "status", "routed_method", "backup_id"]) if string_error: return string_error return None def validate_metadata_write_rollback_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = METADATA_WRITE_ROLLBACK_METHOD base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(payload, method, ["operation_id", "backup_id"]) if string_error: return string_error _, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_error: return allow_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error return None def validate_adapter_job_payload(method: str, job_payload: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(job_payload, dict): return invalid_argument("adapter.job.start", "payload", "payload must be a JSON object.") source_policy_error = validate_sql_only_runtime_payload(method, job_payload) if source_policy_error: return source_policy_error if "base_id" in job_payload and job_payload.get("base_id") is not None and not isinstance(job_payload.get("base_id"), str): return invalid_argument(method, "base_id", "base_id must be a JSON string.") if method in OBJECT_SELECTOR_ALIAS_METHODS: template_view_method = method in {"templates.read", "templates.analyze", "templates.map"} selector_argument_error = validate_object_selector_arguments(job_payload, method, include_view=not template_view_method) if selector_argument_error: return selector_argument_error normalized_payload = normalize_selector_payload_for_method(job_payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload if normalized_payload is not job_payload: job_payload.clear() job_payload.update(normalized_payload) selector_error = validate_optional_string_arguments(job_payload, method, ["view"]) if selector_error: return selector_error if method in {"metadata.object.attributes", "metadata.object.full"} and not has_object_selector(job_payload): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) ordinal_exists_error = validate_object_ordinal_exists_for_job(method, job_payload) if ordinal_exists_error: return ordinal_exists_error if method == "help.methods": return validate_help_methods_payload(job_payload) if method in {"repository.layer.connection.plan", "repository.layer.connection.set"}: base_id_or_error = require_base_id(job_payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error string_error = validate_optional_string_arguments(job_payload, method, ["layer_id", "extension", "extension_guid", "connection_state", "repository_user"]) if string_error: return string_error if method == "repository.layer.connection.set": _, confirmation_error = strict_bool_argument(job_payload, "confirm_repository_connection_change", method=method, default=False) return confirmation_error return None if method == "repository.layers.audit": base_id_or_error = require_base_id(job_payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error _, timeout_error = parse_int_argument(job_payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=600) return timeout_error if method in repository_control.METHODS: base_required = method not in {repository_control.METHOD_LOCK_REQUEST_STATUS, repository_control.METHOD_LOCK_REQUEST_CANCEL, repository_control.METHOD_VERIFY, repository_control.METHOD_CLOSE, repository_control.METHOD_UNLOCK, repository_control.METHOD_COMMIT_PLAN, repository_control.METHOD_COMMIT} if base_required: base_id_or_error = require_base_id(job_payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error return None if method == "metadata.kinds": return validate_metadata_kinds_payload(job_payload) if method == "metadata.capabilities": return validate_metadata_capabilities_payload(job_payload) if method == "metadata.adapter.audit": return validate_metadata_adapter_audit_payload(job_payload) if method == "metadata.object.full": return validate_metadata_object_full_payload(job_payload) if method == "metadata.object.attributes": _, validation_error = validate_metadata_object_attributes_payload(job_payload) return validation_error if method == "metadata.relationship.verify": return validate_metadata_relationship_verify_payload(job_payload) if method == "metadata.relationship.find": return validate_metadata_relationship_find_payload(job_payload) if method == "metadata.write.history": return validate_metadata_write_history_payload(job_payload) if method == METADATA_WRITE_ROLLBACK_METHOD: return validate_metadata_write_rollback_payload(job_payload) if method == SAVED_STATE_STATUS_METHOD: return validate_metadata_saved_state_status_payload(job_payload) if method in {CONFIGURATION_ACTIVATION_STATUS_METHOD, CONFIGURATION_ACTIVATION_PLAN_METHOD}: return validate_configuration_activation_payload(job_payload, method=method) if method == CONFIGURATION_ACTIVATION_REQUEST_METHOD: return validate_configuration_activation_request_payload(job_payload) if method == CONFIGURATION_ACTIVATION_REQUEST_STATUS_METHOD: return validate_configuration_activation_request_status_payload(job_payload) if method == CONFIGURATION_ACTIVATION_REQUEST_CANCEL_METHOD: return validate_configuration_activation_request_cancel_payload(job_payload) if method == CONFIGURATION_ACTIVATION_AUDIT_METHOD: return validate_configuration_activation_audit_payload(job_payload) if method == CONFIGURATION_ACTIVATION_CAPABILITIES_METHOD: return validate_configuration_activation_capabilities_payload(job_payload) if method == CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD: return validate_configuration_activation_bridge_probe_payload(job_payload) if method == CONFIGURATION_ACTIVATION_EXECUTE_METHOD: return validate_configuration_activation_execute_payload(job_payload) if method == CONFIGURATION_ACTIVATION_VERIFY_METHOD: return validate_configuration_activation_verify_payload(job_payload) if method == SAVED_STATE_DIFF_METHOD: return validate_metadata_saved_state_diff_payload(job_payload) if method == SAVED_STATE_CHANGES_LIST_METHOD: return validate_metadata_saved_state_changes_list_payload(job_payload) if method == "metadata.object.special.details": return validate_metadata_object_special_details_payload(job_payload) if method == "metadata.cache.rebuild": return validate_metadata_cache_rebuild_payload(job_payload) if method == "metadata.objects.list": if job_payload.get("extension") not in {None, ""}: return metadata_objects_list_extension_not_supported(job_payload) if "query" in job_payload: return invalid_argument("metadata.objects.list", "query", "metadata.objects.list does not use query; pass name_filter/name_contains or kind/name_filter.") string_error = validate_optional_string_arguments(job_payload, "metadata.objects.list", ["name_filter", "name_contains", "extension"]) if string_error: return string_error for argument in ("include_storage", "include_missing", "only_missing", "exact_counts", "refresh_cache"): _, bool_error = strict_bool_argument(job_payload, argument, method="metadata.objects.list", default=False) if bool_error: return bool_error _, limit_error = parse_int_argument(job_payload, "limit", method="metadata.objects.list", default=200, minimum=1) if limit_error: return limit_error _, offset_error = parse_int_argument(job_payload, "offset", method="metadata.objects.list", default=0, minimum=0) if offset_error: return offset_error return None if method == "metadata.snapshot": return validate_metadata_snapshot_payload(job_payload) if method == "metadata.cache.status": return validate_metadata_cache_status_payload(job_payload) if method == "metadata.cache.lookup": return validate_metadata_cache_lookup_payload(job_payload) if method == "semantic.cache.search": return validate_semantic_cache_search_payload(job_payload) if method == "semantic.cache.status": return validate_semantic_cache_status_payload(job_payload) if method == "semantic.cache.validate": return validate_semantic_cache_validate_payload(job_payload) if method == "semantic.cache.validate_batch": return validate_semantic_cache_validate_batch_payload(job_payload) if method == "semantic.cache.refresh": return validate_semantic_cache_refresh_payload(job_payload) if method == "semantic.cache.rebuild": return validate_semantic_cache_rebuild_payload(job_payload) if method == "semantic.cache.pending": return validate_semantic_cache_pending_payload(job_payload) if method == "semantic.cache.embedding.upsert": return validate_semantic_cache_embedding_upsert_payload(job_payload) if method in CODE_INDEX_METHODS: return validate_metadata_code_index_payload(job_payload, method) if method == "metadata.object.get": return validate_metadata_object_get_payload(job_payload) if method == "metadata.object.decode": return validate_metadata_object_decode_payload(job_payload) if method == "metadata.object.parts": return validate_metadata_object_parts_payload(job_payload) if method == "metadata.object.components": return validate_metadata_object_components_payload(job_payload) if method == "metadata.object.modules": return validate_metadata_object_modules_payload(job_payload) if method == "metadata.object.related": return validate_metadata_object_related_payload(job_payload) if method == "metadata.object.forms": return validate_metadata_object_forms_payload(job_payload) if method == "metadata.object.form.details": return validate_metadata_object_form_details_payload(job_payload) if method == "metadata.object.templates": return validate_metadata_object_templates_payload(job_payload) if method == "metadata.object.template.details": return validate_metadata_object_template_details_payload(job_payload) if method in {"templates.read", "templates.analyze", "templates.map"}: return validate_templates_read_payload(job_payload, method) if method == "templates.areas.find": return validate_templates_areas_find_payload(job_payload) if method == "metadata.object.commands": return validate_metadata_object_commands_payload(job_payload) if method == "metadata.definition.find": return validate_metadata_definition_find_payload(job_payload) if method == "metadata.route.resolve": return validate_extension_objects_find_payload(job_payload, method) if method == "metadata.form.decode": return validate_metadata_form_decode_payload(job_payload) if method == FORM_OWNER_INDEX_BUILD_METHOD: return validate_metadata_form_owner_index_build_payload(job_payload) if method == "metadata.form.write_target.resolve": return validate_metadata_form_write_target_resolve_payload(job_payload) if method == FORM_WRITE_TARGET_VERIFY_METHOD: return validate_metadata_form_write_target_resolve_payload(job_payload, method=FORM_WRITE_TARGET_VERIFY_METHOD) if method in {FORM_WRITE_MATRIX_BUILD_METHOD, FORM_WRITE_MATRIX_SMOKE_METHOD}: return validate_metadata_write_learning_payload(job_payload, method) if method == "metadata.saved_state.forms.search": return validate_metadata_saved_state_forms_search_payload(job_payload) if method in {"metadata.saved_state.prepare", "metadata.saved_state.ensure"}: return validate_metadata_saved_state_prepare_payload(job_payload, method=method) if method == SAVED_STATE_MODULES_SEARCH_METHOD: return validate_metadata_saved_state_modules_search_payload(job_payload) if method == "metadata.form.element.write_apply": return validate_metadata_form_element_write_apply_payload(job_payload) if method == OBJECT_PROPERTY_WRITE_METHOD: return validate_metadata_object_property_write_payload(job_payload) if method == OBJECT_MEMBER_ADD_METHOD: return validate_metadata_object_member_add_payload(job_payload) if method == FORM_TARGET_MOVE_METHOD: return validate_metadata_form_target_move_payload(job_payload) if method == FORM_COMMAND_BUTTON_WRITE_METHOD: return validate_metadata_form_command_button_write_payload(job_payload) if method == FORM_COMMAND_BUTTON_VERIFY_METHOD: return validate_metadata_form_command_button_verify_payload(job_payload) if method == MODULE_WRITE_APPLY_METHOD: return validate_metadata_module_write_apply_payload(job_payload) if method == METADATA_WRITE_TARGET_RESOLVE_METHOD: return None if method == METADATA_WRITE_PLAN_METHOD: return validate_metadata_write_plan_payload(job_payload) if method == METADATA_WRITE_PREFLIGHT_METHOD: return validate_metadata_write_preflight_payload(job_payload) if method == METADATA_WRITE_METHOD: return validate_metadata_write_payload(job_payload) if method in WRITE_LEARNING_METHODS: return validate_metadata_write_learning_payload(job_payload, method) if method == "metadata.cache.invalidate": base_id_or_error = require_base_id(job_payload, "metadata.cache.invalidate") if isinstance(base_id_or_error, dict): return base_id_or_error _, dry_run_error = strict_bool_argument(job_payload, "dry_run", method="metadata.cache.invalidate", default=False) return dry_run_error if method == "metadata.module_owner_cache.backfill": return validate_metadata_module_owner_cache_backfill_payload(job_payload) if method == "metadata.module_owner_cache.prune": return validate_metadata_module_owner_cache_prune_payload(job_payload) if method == "metadata.resolve_overrides": return validate_metadata_resolve_overrides_payload(job_payload) if method == "code.search": return validate_code_search_payload(job_payload) if method == "code.read": return validate_code_read_payload(job_payload) if method == CODE_WRITE_METHOD: return validate_code_write_payload(job_payload) if method == "templates.bindings": return validate_templates_bindings_payload(job_payload) if method == "template.field.lineage": return validate_template_field_lineage_payload(job_payload) if method == "diagnostics.call_chain": return validate_diagnostics_call_chain_payload(job_payload) if method == "extensions.list": return validate_extensions_list_payload(job_payload) if method == "extension.cache.status": return validate_extension_cache_status_payload(job_payload, method) if method == "extension.cache.rebuild": return validate_extension_cache_rebuild_payload(job_payload, method) if method == "extension.cache.validate": return validate_extension_cache_validate_payload(job_payload, method) if method == "extension.objects.find": return validate_extension_objects_find_payload(job_payload, method) if method == "schema.tables.list": return validate_schema_tables_list_payload(job_payload) if method == "storage.files.list": return validate_storage_files_list_payload(job_payload) if method == "storage.file.get": return validate_storage_file_get_payload(job_payload) if method == "storage.saved_state.apply_proposal": return validate_storage_saved_state_apply_proposal_payload(job_payload) if method == "storage.saved_state.rollback": return validate_storage_saved_state_rollback_payload(job_payload) if method == "storage.saved_state.backups.list": return validate_storage_saved_state_backups_list_payload(job_payload) if method == "storage.saved_state.backups.prune": return validate_storage_saved_state_backups_prune_payload(job_payload) if method == "metadata.dbnames.summary": return validate_metadata_dbnames_summary_payload(job_payload) if method == "payload.diff": return validate_payload_diff_payload(job_payload) if method == "codec.decode": return validate_codec_decode_payload(job_payload) if method == "codec.encode": return validate_codec_encode_payload(job_payload) if method == "query.validate": return validate_query_validate_payload(job_payload) if method == "query.run": return validate_query_run_payload(job_payload) if method == "modules.read": base_id_or_error = require_base_id(job_payload, "modules.read") if isinstance(base_id_or_error, dict): return base_id_or_error return validate_modules_read_arguments(job_payload) if method == "modules.search": return validate_modules_search_payload(job_payload) if method == "changes.propose": return validate_changes_propose_payload(job_payload) return None def adapter_start_job(payload: dict[str, Any]) -> dict[str, Any]: raw_method = payload.get("method") if raw_method is not None and not isinstance(raw_method, str): return invalid_argument("adapter.job.start", "method", "method must be a JSON string.") method = str(raw_method or "").strip() if "payload" in payload and not isinstance(payload.get("payload"), dict): return invalid_argument("adapter.job.start", "payload", "payload must be a JSON object.") job_payload = payload.get("payload") or {} if not method: return invalid_argument("adapter.job.start", "method", "payload.method is required.") if method.startswith("adapter.job."): return adapter_public_error("adapter.job.start", "unsupported_method", {"message": "adapter job methods cannot be nested"}) known_methods = set(adapter_method_registry()) if method not in known_methods: return invalid_argument("adapter.job.start", "method", "Unsupported adapter method.", allowed_values=sorted(known_methods)) requested_timeout, timeout_error = parse_int_argument(payload, "timeout_seconds", method="adapter.job.start", default=0, minimum=1) if timeout_error: return timeout_error preflight_error = validate_adapter_job_payload(method, job_payload) if preflight_error: return preflight_error early_result = adapter_long_method_card_preflight(method, job_payload) if early_result is not None: return early_result adapter_cleanup_jobs() job_id = uuid.uuid4().hex now = adapter_now() timeout_seconds = float(requested_timeout) if requested_timeout else adapter_job_timeout_seconds(job_payload, method=method) with ADAPTER_JOB_LOCK: ADAPTER_JOBS[job_id] = { "schema": "onec_adapter_job.v1", "status": "queued", "job_id": job_id, "method": method, "base_id": job_payload.get("base_id"), "adapter_instance_id": ADAPTER_INSTANCE_ID, "created_at": now, "updated_at": now, "timeout_seconds": timeout_seconds, "progress": {"current_step": "queued", "completed_steps": 0, "total_steps": None, "percent": 0}, } adapter_save_jobs_to_store() def worker() -> None: stop_heartbeat = threading.Event() heartbeat_thread = threading.Thread(target=adapter_job_heartbeat, args=(job_id, stop_heartbeat), name=f"onec-adapter-heartbeat-{job_id[:8]}", daemon=True) heartbeat_thread.start() adapter_job_set(job_id, status="running", started_at=adapter_now(), progress={"current_step": "running", "completed_steps": 0, "total_steps": None, "percent": 0}) try: ADAPTER_JOB_CONTEXT.job_id = job_id if adapter_job_process_isolation_enabled(method): adapter_run_job_in_process(job_id, method, job_payload, timeout_seconds) return if method == "metadata.object.full": adapter_run_metadata_object_full_job(job_id, job_payload, timeout_seconds) return if method == "metadata.object.attributes": adapter_run_metadata_object_attributes_job(job_id, job_payload, timeout_seconds) return if method == "metadata.object.special.details" and canonical_kind(str(job_payload.get("kind") or "")) == "DocumentJournal": adapter_run_document_journal_special_job(job_id, job_payload, timeout_seconds) return if method == "metadata.cache.rebuild": adapter_run_metadata_cache_rebuild_job(job_id, job_payload, timeout_seconds) return result = call_method_impl(method, job_payload) if adapter_job_cancel_requested(job_id): adapter_job_finish(job_id, "cancelled", result={"status": "cancelled", "method": method}) return adapter_job_finish(job_id, "done", result=result, progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}) except Exception as exc: adapter_job_finish(job_id, "error", **adapter_public_error(method, "adapter_job_exception", {"message": str(exc), "traceback": traceback.format_exc(limit=8)})) finally: ADAPTER_JOB_CONTEXT.job_id = None stop_heartbeat.set() heartbeat_thread.join(timeout=0.2) def watchdog() -> None: time.sleep(timeout_seconds) with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) if not job or job.get("status") not in {"queued", "running"}: return current_step = ((job.get("progress") or {}).get("current_step") or job.get("current_step") or "running") running_steps = (job.get("progress") or {}).get("running_steps") or [current_step] queued_steps = (job.get("progress") or {}).get("queued_steps") or [] partial = job.get("partial_result") if isinstance(partial, dict): partial["status"] = "partial" timings = partial.get("section_timings") or {} for section in running_steps: section_name = str(section or "").strip() if not section_name or section_name == "running_sections": continue partial.setdefault("sections", {})[section_name] = "failed" section_method = (timings.get(section_name) or {}).get("method") or method if not any(item.get("section") == section_name for item in partial.get("failed_sections") or []): partial.setdefault("failed_sections", []).append( {"section": section_name, "method": section_method, "status": "timeout", "diagnostics": {"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds"}} ) for section in queued_steps: section_name = str(section or "").strip() if not section_name: continue partial.setdefault("sections", {})[section_name] = "not_started_due_to_job_timeout" section_method = (timings.get(section_name) or {}).get("method") or method if not any(item.get("section") == section_name for item in partial.get("failed_sections") or []): partial.setdefault("failed_sections", []).append( {"section": section_name, "method": section_method, "status": "not_started_due_to_job_timeout", "diagnostics": {"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds before section start"}} ) job["partial_result"] = partial job["result"] = partial job.update(adapter_public_error(method, "job_timeout", {"message": f"Adapter job timeout after {timeout_seconds:.0f} seconds", "running_steps": running_steps})) job["status"] = "error" job["finished_at"] = adapter_now() job["updated_at"] = job["finished_at"] adapter_save_jobs_to_store() threading.Thread(target=worker, name=f"onec-adapter-job-{job_id[:8]}", daemon=True).start() if not adapter_job_process_isolation_enabled(method): threading.Thread(target=watchdog, name=f"onec-adapter-timeout-{job_id[:8]}", daemon=True).start() return { "schema": "onec_adapter_job.v1", "status": "accepted", "job_id": job_id, "method": method, "base_id": job_payload.get("base_id"), "timeout_seconds": timeout_seconds, "poll": {"method": "adapter.job.get", "payload": {"job_id": job_id}}, "cancel": {"method": "adapter.job.cancel", "payload": {"job_id": job_id}}, } def adapter_get_job(payload: dict[str, Any]) -> dict[str, Any]: adapter_load_jobs_from_store() raw_job_id = payload.get("job_id") if raw_job_id is not None and not isinstance(raw_job_id, str): return invalid_argument("adapter.job.get", "job_id", "job_id must be a JSON string.") consume, consume_error = strict_bool_argument(payload, "consume", method="adapter.job.get", default=False) if consume_error: return consume_error include_partial_result, include_partial_result_error = strict_bool_argument(payload, "include_partial_result", method="adapter.job.get", default=False) if include_partial_result_error: return include_partial_result_error job_id = str(raw_job_id or "").strip() if not job_id: return invalid_argument("adapter.job.get", "job_id", "payload.job_id is required.") adapter_cleanup_jobs() with ADAPTER_JOB_LOCK: job = dict(ADAPTER_JOBS.get(job_id) or {}) if consume and job.get("status") in {"done", "error", "cancelled", "not_found"}: ADAPTER_JOBS.pop(job_id, None) adapter_save_jobs_to_store() if not job: return { "schema": "onec_adapter_job.v1", "status": "not_found", "error": "not_found", "job_id": job_id, "diagnostics": {"message": "Job was not found. It may have expired or the adapter was restarted."}, } if job.get("status") == "done" and "result" in job and not include_partial_result: job.pop("partial_result", None) return job def adapter_cancel_job(payload: dict[str, Any]) -> dict[str, Any]: adapter_load_jobs_from_store() raw_job_id = payload.get("job_id") if raw_job_id is not None and not isinstance(raw_job_id, str): return invalid_argument("adapter.job.cancel", "job_id", "job_id must be a JSON string.") job_id = str(raw_job_id or "").strip() if not job_id: return invalid_argument("adapter.job.cancel", "job_id", "payload.job_id is required.") adapter_cleanup_jobs() with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) if not job: return { "schema": "onec_adapter_job.v1", "status": "not_found", "error": "not_found", "job_id": job_id, "diagnostics": {"message": "Job was not found. It may have expired or the adapter was restarted."}, } if job.get("status") in {"done", "error", "cancelled"}: return dict(job) job["cancel_requested"] = True job["status"] = "cancelled" job["finished_at"] = adapter_now() job["updated_at"] = job["finished_at"] isolated_process = adapter_job_process_isolation_enabled(str(job.get("method") or "")) job["diagnostics"] = { "message": ( "Cancellation requested. The isolated worker process will be terminated." if isolated_process else "Cancellation requested. A running in-process section may finish in the background, but this adapter job will remain cancelled." ), "termination": "hard_process" if isolated_process else "cooperative", } if job.get("method") == "metadata.object.attributes" and isinstance(job.get("partial_result"), dict): partial = dict(job.get("partial_result") or {}) progress = adapter_mark_attributes_cancelled(partial, started_at=float(job.get("started_at") or job.get("created_at") or adapter_now())) job["partial_result"] = partial job["result"] = partial job["progress"] = progress job["current_step"] = "cancelled" adapter_save_jobs_to_store() return dict(job) def metadata_object_attributes(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.attributes") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload base_id_or_error = require_base_id(payload, "metadata.object.attributes") if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error validated, validation_error = validate_metadata_object_attributes_payload(payload) if validation_error: return validation_error include_storage = bool((validated or {}).get("include_storage")) use_cache = bool((validated or {}).get("use_cache")) resolve_generated_types = bool((validated or {}).get("resolve_generated_types", False)) member = str((validated or {}).get("member") or "").strip() only = str((validated or {}).get("only") or "all") table = str((validated or {}).get("table") or "Config") lookup_limit = int((validated or {}).get("limit") or 20) view = str((validated or {}).get("view") or "effective") selector_kind = payload.get("kind") selector_name = str(payload.get("name") or payload.get("guid") or "") extension_guid = str(payload.get("extension_guid") or "").strip().lower() or None ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") ordinal_object = None if ordinal_value not in {None, ""}: ordinal, ordinal_error = parse_ordinal(ordinal_value, "metadata.object.attributes") if ordinal_error: return ordinal_error ordinal_result = list_objects( selector_kind, base_id=base_id, limit=1, offset=int(ordinal or 1) - 1, include_storage=False, table=table, ) if ordinal_result.get("status") != "ok" or not ordinal_result.get("objects"): result = dict(ordinal_result) result["method"] = "metadata.object.attributes" result["status"] = "not_found" result["diagnostics"] = {"message": f"Object ordinal {ordinal} was not found for kind {selector_kind}."} return result ordinal_object = (ordinal_result.get("objects") or [])[0] selector_kind = ordinal_object.get("kind") or selector_kind selector_name = str(ordinal_object.get("guid") or "") object_result = get_object( selector_kind, selector_name, base_id=base_id, view=view, limit=lookup_limit, include_storage=include_storage, table=table, extension_guid=extension_guid, timeout_seconds=int(payload.get("timeout_seconds") or 60), resolve_semantic_types=False, include_semantic=False, ) if object_result.get("status") != "ok": result = dict(object_result) result["method"] = "metadata.object.attributes" return result object_card = object_result.get("object") or {} object_guid = str(object_card.get("guid") or "").lower() config, _ = sql_config_for_base(base_id) cache_role = metadata_attributes_cache_role(only) if config and object_guid and not include_storage and use_cache and not member and not truthy(payload.get("refresh_cache")): cached_result = metadata_guid_index_lookup_payload(config, object_guid, cache_role) if cached_result: cached_public = dict(cached_result) cached_counts = dict(cached_public.get("counts") or {}) cached_counts.update( public_reference_type_counts( cached_public.get("dimensions") or [], cached_public.get("resources") or [], cached_public.get("attributes") or [], cached_public.get("tabular_sections") or [], ) ) cached_public["counts"] = cached_counts cached_public["cache"] = {"status": "hit", "role": cache_role} cached_public["query"] = { **(cached_public.get("query") or {}), "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), **({"extension_guid": extension_guid} if extension_guid else {}), **({"ordinal": int(ordinal_value)} if ordinal_value not in {None, ""} and str(ordinal_value).isdigit() else {}), "only": only, "include_storage": include_storage, "table": table, "use_cache": use_cache, "resolve_generated_types": resolve_generated_types, **({"member": member} if member else {}), } return cached_public include_attributes = only not in {"tabular_sections", "tabularsections", "tabs", "table_parts", "dimensions", "измерения", "resources", "ресурсы"} include_tabular_sections = only not in {"attributes", "requisites", "attrs", "dimensions", "измерения", "resources", "ресурсы"} include_dimensions = only in {"all", "", "dimensions", "измерения", "register_fields", "поля_регистра"} include_resources = only in {"all", "", "resources", "ресурсы", "register_fields", "поля_регистра"} semantic_categories: list[str] = [] if include_attributes: semantic_categories.append("Attribute") if include_tabular_sections: semantic_categories.append("TabularSection") if include_dimensions: semantic_categories.append("Dimension") if include_resources: semantic_categories.append("Resource") semantic_result = get_object( object_card.get("kind") or selector_kind, object_guid or selector_name, base_id=base_id, view=str(payload.get("view") or "effective"), limit=int(payload.get("limit") or 20), include_storage=True, timeout_seconds=int(payload.get("timeout_seconds") or 60), table=table, extension_guid=extension_guid, resolve_semantic_types=False, semantic_include_generic=False, semantic_categories=semantic_categories, semantic_lightweight=not include_storage, ) if semantic_result.get("status") != "ok": result = dict(semantic_result) result["method"] = "metadata.object.attributes" return result semantic = semantic_result.get("semantic") or {} sections = semantic.get("sections") or [] selected_sections = [ section for section in sections if (section.get("category") == "Attribute" and include_attributes) or (section.get("category") == "TabularSection" and include_tabular_sections) or (section.get("category") == "Dimension" and include_dimensions) or (section.get("category") == "Resource" and include_resources) ] if member: normalized_member = normalize(member) filtered_sections = [] for section in selected_sections: selected_records = [] for record in section.get("records") or []: if not isinstance(record, dict): continue record_name = best_text_variant(record.get("likely_name")) columns = record.get("columns") or [] matching_columns = [ column for column in columns if isinstance(column, dict) and normalize(best_text_variant(column.get("likely_name"))) == normalized_member ] if normalize(record_name) == normalized_member or matching_columns: record_copy = dict(record) if matching_columns and normalize(record_name) != normalized_member: record_copy["columns"] = matching_columns selected_records.append(record_copy) if selected_records: section_copy = dict(section) section_copy["records"] = selected_records filtered_sections.append(section_copy) selected_sections = filtered_sections type_guids = collect_reference_type_guids_from_sections(selected_sections) resolved_types = resolve_type_guids( base_id, type_guids, timeout_seconds=int(payload.get("timeout_seconds") or 60), table=table, resolve_generated_live=resolve_generated_types, ) extensions_by_guid = extension_map_by_guid(base_id) def records(category: str) -> list[dict[str, Any]]: result = [] owner = semantic_result.get("object") or object_card or {} owner_kind = str(owner.get("kind") or "") for section in selected_sections: if section.get("category") != category: continue for record in section.get("records") or []: item = public_metadata_item(record, resolved_types, include_storage=include_storage, owner_kind=owner_kind, extensions_by_guid=extensions_by_guid) identity = record.get("identity") if isinstance(record.get("identity"), dict) else {} metadata_field_type_cache_upsert(config, str(identity.get("guid") or ""), item.get("type"), owner=owner, field_name=item.get("name")) if category == "TabularSection": columns = [] for column in record.get("columns") or []: column_item = public_metadata_item(column, resolved_types, include_storage=include_storage, owner_kind=owner_kind, extensions_by_guid=extensions_by_guid) column_identity = column.get("identity") if isinstance(column.get("identity"), dict) else {} metadata_field_type_cache_upsert(config, str(column_identity.get("guid") or ""), column_item.get("type"), owner=owner, field_name=column_item.get("name")) columns.append(column_item) item["columns"] = columns item["counts"] = {"columns": len(columns)} result.append(item) return result attributes = records("Attribute") if include_attributes else [] tabular_sections = records("TabularSection") if include_tabular_sections else [] dimensions = records("Dimension") if include_dimensions else [] resources = records("Resource") if include_resources else [] member_found = bool(attributes or tabular_sections or dimensions or resources) reference_counts = public_reference_type_counts(dimensions, resources, attributes, tabular_sections) result = { "schema": "onec_metadata_object_attributes.v1", "status": "ok" if not member or member_found else "not_found", "base_id": base_id, "source": {"kind": "live_metadata"}, "query": { "guid": payload.get("guid"), "kind": payload.get("kind"), "name": payload.get("name"), **({"extension_guid": extension_guid} if extension_guid else {}), **({"ordinal": int(ordinal_value)} if ordinal_value not in {None, ""} and str(ordinal_value).isdigit() else {}), "only": only, "include_storage": include_storage, "use_cache": use_cache, "resolve_generated_types": resolve_generated_types, **({"member": member} if member else {}), }, "object": public_metadata_row(semantic_result.get("object") or object_card, include_storage=include_storage), "dimensions": dimensions, "resources": resources, "attributes": attributes, "tabular_sections": tabular_sections, **({"member_type_evidence": public_member_type_evidence(attributes, tabular_sections, dimensions, resources)} if member else {}), "counts": { "dimensions": len(dimensions), "resources": len(resources), "attributes": len(attributes), "tabular_sections": len(tabular_sections), **reference_counts, }, **({"diagnostics": {"code": "member_not_found", "message": "The requested member was not found in the selected object sections."}} if member and not member_found else {}), } if config and object_guid and not include_storage: metadata_guid_index_upsert( config, { "guid": object_guid, "guid_role": cache_role, "kind": (semantic_result.get("object") or object_card or {}).get("kind"), "kind_ru": (semantic_result.get("object") or object_card or {}).get("kind_ru"), "public_kind": (semantic_result.get("object") or object_card or {}).get("public_kind"), "name": (semantic_result.get("object") or object_card or {}).get("name"), "synonym": (semantic_result.get("object") or object_card or {}).get("synonym"), "presentation": ".".join( part for part in [ (semantic_result.get("object") or object_card or {}).get("kind_ru"), (semantic_result.get("object") or object_card or {}).get("name"), ] if part ), "payload": result, "source_file": object_guid, }, ) result["cache"] = {"status": "stored", "role": cache_role} return result def metadata_relationship_verify(payload: dict[str, Any]) -> dict[str, Any]: """Verify one metadata relation from an explicitly declared field type only.""" payload = normalize_object_selector_aliases(payload, "metadata.relationship.verify") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload validation_error = validate_metadata_relationship_verify_payload(payload) if validation_error: return validation_error base_id = str(payload.get("base_id") or "") member = str(payload.get("member") or "").strip() timeout_seconds = int(payload.get("timeout_seconds") or 60) attributes_payload = { key: payload[key] for key in ( "base_id", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension", "extension_guid", "view", "table", "ordinal", "index", "object_index", ) if key in payload } attributes_payload.update({ "member": member, "only": "all", "resolve_generated_types": True, "timeout_seconds": timeout_seconds, }) declared = metadata_object_attributes(attributes_payload) if declared.get("status") not in {"ok", "not_found"}: return { "schema": "onec_metadata_relationship_verify.v1", "method": "metadata.relationship.verify", "status": "partial", "base_id": base_id, "source": {"ref": payload.get("ref"), "member": member}, "diagnostics": [{"code": "source_member_unreadable", "details": declared.get("diagnostics") or declared}], } evidence = list(declared.get("member_type_evidence") or []) target_ref = str(payload.get("target_ref") or "").strip() target_kind, target_name = parse_object_query(None, target_ref) if target_ref else (None, None) canonical_target_ref = object_selector_ref(target_kind, target_name) if target_kind and target_name else None declared_targets = [ target for item in evidence if isinstance(item, dict) for target in (item.get("targets") or []) if isinstance(target, dict) ] target_matches = [ target for target in declared_targets if canonical_target_ref and normalize_exact(str(target.get("ref") or "")) == normalize_exact(canonical_target_ref) ] if not evidence: status = "not_found" relation_status = "member_not_found" elif canonical_target_ref and target_matches: status = "confirmed" relation_status = "declared_target_matches" elif canonical_target_ref: status = "not_confirmed" relation_status = "declared_target_does_not_match" elif len(declared_targets) == 1: status = "confirmed" relation_status = "single_declared_target" else: status = "partial" relation_status = "declared_target_ambiguous" source_object = declared.get("object") if isinstance(declared.get("object"), dict) else {} result: dict[str, Any] = { "schema": "onec_metadata_relationship_verify.v1", "method": "metadata.relationship.verify", "status": status, "base_id": base_id, "relation_status": relation_status, "source": { "ref": source_object.get("ref") or object_selector_ref(source_object.get("kind"), source_object.get("name")), "member": member, }, **({"target_ref": canonical_target_ref} if canonical_target_ref else {}), "declared_targets": declared_targets, "evidence": evidence, "evidence_policy": "Only an explicit metadata declared type can confirm this relation. Matching names, BSL mentions, and data values are not treated as proof.", } if status == "not_confirmed": result["diagnostics"] = [{ "code": "target_not_declared_by_member_type", "message": "The proposed target is not among the source member's declared metadata reference targets.", }] elif status == "partial": result["diagnostics"] = [{ "code": "declared_target_not_concrete", "message": "The member is not declared as one concrete metadata object; do not infer a relation without another explicit static rule.", }] return result def metadata_relationship_find(payload: dict[str, Any]) -> dict[str, Any]: """Find direct, statically declared references from one object to another.""" execution_mode = str(payload.get("execution_mode") or "").strip().casefold() if execution_mode: if execution_mode != "job": return invalid_argument("metadata.relationship.find", "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) job_payload = {key: value for key, value in payload.items() if key != "execution_mode"} return adapter_start_job({ "method": "metadata.relationship.find", "payload": job_payload, "timeout_seconds": payload.get("timeout_seconds") or 60, }) payload = normalize_object_selector_aliases(payload, "metadata.relationship.find") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload validation_error = validate_metadata_relationship_find_payload(payload) if validation_error: return validation_error base_id = str(payload.get("base_id") or "") timeout_seconds = int(payload.get("timeout_seconds") or 60) direction = str(payload.get("direction") or "outgoing").strip().casefold() target_kind, target_name = parse_object_query(None, str(payload.get("target_ref") or "").strip()) target_ref = object_selector_ref(target_kind, target_name) if target_kind and target_name else str(payload.get("target_ref") or "") source_selector = { key: payload[key] for key in ( "base_id", "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension", "extension_guid", "view", "table", "ordinal", "index", "object_index", ) if key in payload } source_ref = str(payload.get("ref") or object_selector_ref(payload.get("kind"), payload.get("name")) or "") def scan(selector: dict[str, Any], wanted_ref: str, edge_direction: str) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]: # Direct relation discovery must stay bounded: generated/defined types # can require a configuration-wide scan and cannot prove one concrete # object edge without that explicit declaration anyway. response = metadata_object_attributes({**selector, "only": "all", "resolve_generated_types": False, "timeout_seconds": timeout_seconds}) if response.get("status") != "ok": return [], None, response evidence = public_member_type_evidence( list(response.get("attributes") or []), list(response.get("tabular_sections") or []), list(response.get("dimensions") or []), list(response.get("resources") or []), ) object_info = response.get("object") if isinstance(response.get("object"), dict) else {} current_ref = str(object_info.get("ref") or object_selector_ref(object_info.get("kind"), object_info.get("name")) or "") matches = [] for item in evidence: if not any(normalize_exact(str(target.get("ref") or "")) == normalize_exact(wanted_ref) for target in (item.get("targets") or []) if isinstance(target, dict)): continue matches.append({ **item, "edge": {"direction": edge_direction, "from_ref": current_ref, "to_ref": wanted_ref}, }) return matches, object_info, None matches: list[dict[str, Any]] = [] diagnostics: list[dict[str, Any]] = [] source_object: dict[str, Any] | None = None def publish_partial() -> None: job_id = str(payload.get("_job_id") or "") if not job_id: return adapter_job_set(job_id, partial_result={ "schema": "onec_metadata_relationship_find.v1", "method": "metadata.relationship.find", "status": "partial", "base_id": base_id, "target_ref": target_ref, "direction": direction, "matches": list(matches), "counts": {"matches": len(matches)}, **({"diagnostics": list(diagnostics)} if diagnostics else {}), }) if direction in {"outgoing", "either"}: outgoing, source_object, outgoing_error = scan(source_selector, target_ref, "outgoing") matches.extend(outgoing) if outgoing_error: diagnostics.append({"code": "source_unreadable", "details": outgoing_error.get("diagnostics") or outgoing_error}) publish_partial() if direction in {"incoming", "either"}: incoming, _target_object, incoming_error = scan({"base_id": base_id, "ref": target_ref}, source_ref, "incoming") matches.extend(incoming) if incoming_error: diagnostics.append({"code": "target_unreadable", "details": incoming_error.get("diagnostics") or incoming_error}) publish_partial() if diagnostics and not matches: result_status = "partial" else: result_status = "confirmed" if matches else "not_found" result = { "schema": "onec_metadata_relationship_find.v1", "method": "metadata.relationship.find", "status": result_status, "base_id": base_id, "source": {"ref": (source_object or {}).get("ref") or object_selector_ref((source_object or {}).get("kind"), (source_object or {}).get("name")) or source_ref}, "target_ref": target_ref, "direction": direction, "matches": matches, "counts": {"matches": len(matches)}, "evidence_policy": "Only explicit metadata declared types are searched. No BSL text, form binding, or runtime value is treated as a relation.", **({"diagnostics": diagnostics} if diagnostics else ({"diagnostics": [{"code": "direct_declared_relation_not_found", "message": "No checked member is explicitly declared as a reference to the other object."}]} if not matches else {})), } if not matches: query_name = str(target_name or target_ref.rsplit(".", 1)[-1]).strip() result["follow_up"] = [{ "purpose": "find_static_indirect_evidence_modules", "evidence_level": "unconfirmed_static_mentions", "warning": "Results may explain an indirect relation but do not confirm a metadata link by themselves.", "method": "metadata.code_index.search", "request": { "base_id": base_id, "ref": source_ref, "query": query_name, "strict": True, "verify": True, "execution_mode": "job", "timeout_seconds": max(60, timeout_seconds), }, }] + [ { "purpose": f"find_static_indirect_evidence_{area}", "evidence_level": "unconfirmed_static_mentions", "warning": "Results may explain an indirect relation but do not confirm a metadata link by themselves.", "method": "metadata.definition.find", "request": { "base_id": base_id, "ref": source_ref, "query": query_name, "areas": [area], "execution_mode": "job", "timeout_seconds": max(60, timeout_seconds), }, } for area in ("form", "templates") ] return result REPOSITORY_FORM_SEGMENTS = {"form", "форма"} REPOSITORY_FORM_OWNER_KINDS = {"Catalog", "Document", "DataProcessor", "Report"} def repository_form_reference_parts(value: str) -> tuple[str, str, str] | None: """Return (owner_kind, owner_name, form_name) for a public child-form ref.""" parts = [part.strip() for part in value.strip().split(".")] if len(parts) < 4 or parts[2].casefold() not in REPOSITORY_FORM_SEGMENTS: return None owner_kind = canonical_kind(parts[0]) or parts[0] if owner_kind not in REPOSITORY_FORM_OWNER_KINDS or not parts[1] or not any(parts[3:]): return None return owner_kind, parts[1], ".".join(parts[3:]).strip() def repository_resolve_owned_form_sql( *, method: str, base_id: str, owner_kind: str, owner_name: str, form_name: str, expected_form_guid: str | None = None, extension_guid: str | None = None, timeout_seconds: int = 60, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: attempts: list[dict[str, Any]] = [] for table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): owner_result = get_object(owner_kind, owner_name, base_id=base_id, view="effective", limit=20, timeout_seconds=timeout_seconds, table=table, extension_guid=extension_guid, include_semantic=False) attempts.append({"stage": "owner", "table": table, "status": owner_result.get("status")}) owner = owner_result.get("object") if owner_result.get("status") == "ok" and isinstance(owner_result.get("object"), dict) else None if not owner: continue forms_payload = { "base_id": base_id, "kind": owner.get("kind") or owner_kind, "name": owner.get("name") or owner_name, "guid": owner.get("guid"), "form": form_name, "table": table, "include_storage": True, "timeout_seconds": timeout_seconds, } # `metadata.object.forms` deliberately distinguishes an absent optional # extension selector from an explicitly invalid empty selector. if extension_guid: forms_payload["extension_guid"] = extension_guid forms_result = metadata_object_forms(forms_payload) attempts.append({"stage": "form", "table": table, "status": forms_result.get("status")}) forms = forms_result.get("forms") if forms_result.get("status") == "ok" and isinstance(forms_result.get("forms"), list) else [] form = next((item for item in forms if isinstance(item, dict) and (not expected_form_guid or str(item.get("guid") or "").casefold() == expected_form_guid.casefold())), None) if not isinstance(form, dict): continue resolved_kind = str(owner.get("kind") or owner_kind).strip() resolved_owner_name = str(owner.get("name") or owner_name).strip() resolved_form_name = str(form.get("name") or form_name).strip() origin = owner.get("origin") if isinstance(owner.get("origin"), dict) else {} extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} resolved_extension_guid = str(extension.get("guid") or extension_guid or "").lower() cache_config, _ = sql_config_for_base(base_id) metadata_form_owner_cache_upsert( cache_config, base_id=base_id, owner_kind=resolved_kind, owner_name=resolved_owner_name, owner_guid=str(owner.get("guid") or ""), form_name=resolved_form_name, form_guid=str(form.get("guid") or ""), table=table, file_name=f"{str(form.get('guid') or '').lower()}.0", extension={"guid": resolved_extension_guid} if is_guid_text(resolved_extension_guid) else None, ) return { "requested_object": f"{owner_kind}.{owner_name}.Form.{form_name}", "resolved_object": f"{resolved_kind}.{resolved_owner_name}.Form.{resolved_form_name}", "form_guid": str(form.get("guid") or "").lower(), "owner": {"kind": resolved_kind, "name": resolved_owner_name, "guid": owner.get("guid")}, "table": table, "source": "live_sql_form_owner", "attempts": attempts, **({"extension_guid": resolved_extension_guid} if is_guid_text(resolved_extension_guid) else {}), }, None return None, {"schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_form_not_found_in_sql", "object": f"{owner_kind}.{owner_name}.Form.{form_name}", "diagnostics": {"attempts": attempts}} def repository_resolve_owned_form_across_layers_sql( *, method: str, base_id: str, owner_kind: str, owner_name: str, form_name: str, extension_guid: str | None = None, timeout_seconds: int = 60, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Resolve a form in its SQL layer, discovering an extension only when needed.""" resolved, base_error = repository_resolve_owned_form_sql( method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, form_name=form_name, extension_guid=extension_guid, timeout_seconds=timeout_seconds, ) if resolved or extension_guid: return resolved, base_error listed = list_extensions({"base_id": base_id, "timeout_seconds": timeout_seconds}) extensions = listed.get("extensions") if listed.get("status") == "ok" and isinstance(listed.get("extensions"), list) else [] matches: list[dict[str, Any]] = [] extension_attempts: list[dict[str, Any]] = [] for extension in extensions: guid = str(extension.get("guid") or "").strip().lower() if isinstance(extension, dict) else "" if not is_guid_text(guid): continue candidate, candidate_error = repository_resolve_owned_form_sql( method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, form_name=form_name, extension_guid=guid, timeout_seconds=timeout_seconds, ) extension_attempts.append({"extension_guid": guid, "status": "ok" if candidate else str((candidate_error or {}).get("status") or "not_found")}) if candidate: matches.append(candidate) if len(matches) == 1: matches[0]["source"] = "live_sql_extension_form_owner" return matches[0], None if len(matches) > 1: return None, { "schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "ambiguous", "error": "ambiguous_repository_form_extension_origin", "object": f"{owner_kind}.{owner_name}.Form.{form_name}", "diagnostics": {"extension_matches": [item.get("extension_guid") for item in matches]}, } error = dict(base_error or {}) diagnostics = dict(error.get("diagnostics") or {}) diagnostics["extension_attempts"] = extension_attempts if listed.get("status") != "ok": diagnostics["extensions_list"] = listed error["diagnostics"] = diagnostics return None, error def repository_resolve_form_guid_sql(*, method: str, base_id: str, form_guid: str, timeout_seconds: int = 60) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: config, _ = sql_config_for_base(base_id) for table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): cached = metadata_form_owner_cache_lookup(config, form_guid=form_guid) owner = cached.get("owner") if isinstance((cached or {}).get("owner"), dict) else {} form = cached.get("form") if isinstance((cached or {}).get("form"), dict) else {} owner_kind = canonical_kind(str(owner.get("kind") or "")) or str(owner.get("kind") or "") owner_name, form_name = str(owner.get("name") or "").strip(), str(form.get("name") or "").strip() if owner_kind in REPOSITORY_FORM_OWNER_KINDS and owner_name and form_name: extension = cached.get("extension") if isinstance(cached.get("extension"), dict) else {} resolved, error = repository_resolve_owned_form_sql(method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, form_name=form_name, expected_form_guid=form_guid, extension_guid=str(extension.get("guid") or "") or None, timeout_seconds=timeout_seconds) if resolved: resolved.update({"requested_object": f"Form.{form_guid}", "source": "live_sql_form_guid_validated"}) return resolved, error # A cache miss is not a negative result. Build the owner relationship from # live SQL descriptors, keeping the scan bounded to form-capable owners. for owner_kind in sorted(REPOSITORY_FORM_OWNER_KINDS): owners_result = list_objects( owner_kind, base_id=base_id, limit=5000, include_storage=True, refresh_cache=True, table="Config", ) owners = owners_result.get("objects") if owners_result.get("status") == "ok" and isinstance(owners_result.get("objects"), list) else [] for owner in owners: if not isinstance(owner, dict) or not str(owner.get("guid") or "").strip(): continue related = metadata_object_related({ "base_id": base_id, "kind": owner_kind, "guid": owner.get("guid"), "table": "Config", "include_storage": False, "timeout_seconds": timeout_seconds, }) related_items = related.get("related") if related.get("status") == "ok" and isinstance(related.get("related"), list) else [] match = next( ( item for item in related_items if isinstance(item, dict) and str(item.get("category") or "").casefold() == "form" and str(((item.get("identity") or {}).get("guid") if isinstance(item.get("identity"), dict) else item.get("guid")) or "").casefold() == form_guid.casefold() ), None, ) identity = match.get("identity") if isinstance((match or {}).get("identity"), dict) else {} form_name = str(identity.get("name") or "").strip() owner_name = str(owner.get("name") or "").strip() if not form_name or not owner_name: continue resolved, error = repository_resolve_owned_form_sql( method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, form_name=form_name, expected_form_guid=form_guid, timeout_seconds=timeout_seconds, ) if resolved: resolved.update({"requested_object": f"Form.{form_guid}", "source": "live_sql_form_guid_scan"}) return resolved, error return None, {"schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_form_owner_not_found_in_sql", "object": f"Form.{form_guid}", "diagnostics": {"message": "Pass OwnerKind.OwnerName.Form.FormName or build a live SQL form-owner index before using Form.."}} _FORM_MODULE_FILE_RE = re.compile(r"^(?:(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})__)?(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.0$", re.IGNORECASE) def repository_resolve_saved_form_module_sql( *, method: str, base_id: str, table: str, file_name: str, form_guid: str, extension_guid: str | None, timeout_seconds: int, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Resolve one concrete saved form module without scanning all metadata owners.""" cache_config, _cache_config_error = sql_config_for_base(base_id) cached = metadata_form_owner_cache_lookup( cache_config, table=table, file_name=file_name, ) if isinstance(cached, dict): cached_owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} cached_form = cached.get("form") if isinstance(cached.get("form"), dict) else {} cached_extension = cached.get("extension") if isinstance(cached.get("extension"), dict) else {} cached_payload_sha1 = str(cached.get("payload_sha1") or "").strip().lower() current_data, _current_config, current_error = read_storage_file_bytes( base_id, table, file_name, timeout_seconds=timeout_seconds, ) current_payload_sha1 = ( hashlib.sha1(current_data).hexdigest() if current_data is not None and current_error is None else "" ) cached_owner_kind = canonical_kind(str(cached_owner.get("kind") or "")) or str( cached_owner.get("kind") or "" ) cached_owner_name = best_text_variant(cached_owner.get("name")) cached_form_name = best_text_variant(cached_form.get("name")) cached_form_guid = str(cached_form.get("guid") or "").strip().lower() cached_extension_guid = str(cached_extension.get("guid") or "").strip().lower() cache_is_current = bool( cached_payload_sha1 and current_payload_sha1 and cached_payload_sha1 == current_payload_sha1 ) cache_matches_target = bool( cached_owner_kind in REPOSITORY_FORM_OWNER_KINDS and cached_owner_name and cached_form_name and (not cached_form_guid or cached_form_guid == form_guid.lower()) and ( not extension_guid or not cached_extension_guid or cached_extension_guid == str(extension_guid).strip().lower() ) ) if cache_is_current and cache_matches_target: canonical_object = ( f"{cached_owner_kind}.{cached_owner_name}.Form.{cached_form_name}" ) return { "schema": "onec_repository_object_resolution.v1", "method": method, "status": "resolved", "base_id": base_id, "requested_object": f"Form.{form_guid}", "resolved_object": canonical_object, "owner": { "kind": cached_owner_kind, "name": cached_owner_name, "guid": cached_owner.get("guid"), }, "form": { "name": cached_form_name, "guid": cached_form_guid or form_guid.lower(), }, "extension_guid": ( str(extension_guid or cached_extension_guid).strip().lower() or None ), "source": "metadata_form_owner_cache_live_verified", "storage": { "table": table, "file_name": file_name, "payload_sha1": current_payload_sha1, }, }, None found = metadata_saved_state_modules_search( { "base_id": base_id, "tables": [table], "file_name": file_name, "limit": 1, "scan_limit": 10, "include_storage": True, "timeout_seconds": timeout_seconds, } ) modules = found.get("modules") if found.get("status") == "ok" and isinstance(found.get("modules"), list) else [] for module in modules: if not isinstance(module, dict): continue source = module.get("source") if isinstance(module.get("source"), dict) else {} if str(source.get("table") or module.get("table") or "") != table: continue if str(source.get("file_name") or module.get("file_name") or "") != file_name: continue owner = module.get("owner") if isinstance(module.get("owner"), dict) else {} form = module.get("form") if isinstance(module.get("form"), dict) else {} owner_kind = canonical_kind(str(owner.get("kind") or "")) or str(owner.get("kind") or "") owner_name = best_text_variant(owner.get("name")) form_name = best_text_variant(form.get("name")) resolved_form_guid = str(form.get("guid") or "").strip().lower() if owner_kind not in REPOSITORY_FORM_OWNER_KINDS or not owner_name or not form_name: continue if resolved_form_guid and resolved_form_guid != form_guid.lower(): continue canonical_object = f"{owner_kind}.{owner_name}.Form.{form_name}" return { "schema": "onec_repository_object_resolution.v1", "method": method, "status": "resolved", "base_id": base_id, "requested_object": f"Form.{form_guid}", "resolved_object": canonical_object, "owner": { "kind": owner_kind, "name": owner_name, "guid": owner.get("guid"), }, "form": { "name": form_name, "guid": resolved_form_guid or form_guid.lower(), }, "extension_guid": str(extension_guid or "").strip().lower() or None, "source": "live_sql_saved_form_module", "storage": {"table": table, "file_name": file_name}, }, None return None, { "schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_form_owner_not_found_for_module", "object": f"Form.{form_guid}", "diagnostics": { "message": "The concrete saved form module did not expose an unambiguous public owner.", }, } def development_layer_write_block(payload: dict[str, Any]) -> dict[str, Any] | None: resolution = payload.get("development_layer_resolution") if isinstance(payload.get("development_layer_resolution"), dict) else {} status = str(resolution.get("status") or "").strip().casefold() if not status or status == "resolved": return None diagnostics = resolution.get("diagnostics") if isinstance(resolution.get("diagnostics"), dict) else {} error = str(diagnostics.get("error") or "extension_layer_not_resolved") return { "error": error, "layer_id": resolution.get("layer_id") or "extension:unresolved", "development_layer_resolution": resolution, } def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: """Derive the exact development layer and form ownership before gates. This is read-only and deliberately does *not* select a lock session. The caller must still present the lock_session_id returned by confirmation. """ result = dict(payload) target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} origin = target.get("origin") if isinstance(target.get("origin"), dict) else {} origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} explicit_extension_guid = str( payload.get("extension_guid") or target.get("extension_guid") or origin_extension.get("guid") or "" ).strip().lower() requested_extension_value = ( target.get("extension") or payload.get("extension") or target.get("preferred_extension") or payload.get("preferred_extension") or "" ) if isinstance(requested_extension_value, dict): explicit_extension_guid = str( explicit_extension_guid or requested_extension_value.get("guid") or "" ).strip().lower() requested_extension = str( requested_extension_value.get("name") or requested_extension_value.get("guid") or "" ).strip() else: requested_extension = str(requested_extension_value or "").strip() base_id = str(payload.get("base_id") or "").strip() resolved_extension_guid = "" extension_error: dict[str, Any] | None = None selector_conflict: dict[str, Any] | None = None if explicit_extension_guid and not is_guid_text(explicit_extension_guid): selector_conflict = { "status": "invalid_argument", "error": "invalid_extension_guid", "extension_guid": explicit_extension_guid, } resolved_name_guid: str | None = None if requested_extension and base_id and selector_conflict is None: resolved_name_guid, extension_error = extension_filter_to_guid( base_id, requested_extension, method="write.gate_context", ) if extension_error: selector_conflict = extension_error elif explicit_extension_guid and resolved_name_guid != explicit_extension_guid: selector_conflict = { "status": "conflict", "error": "extension_selector_conflict", "base_id": base_id, "query": { "extension": requested_extension, "extension_guid": explicit_extension_guid, }, "resolved_extension_guid": resolved_name_guid, "diagnostics": { "message": "The public extension name resolves to a different GUID than the supplied write context.", }, } elif requested_extension and not base_id and selector_conflict is None: selector_conflict = { "status": "not_resolved", "error": "extension_resolution_requires_base_id", "base_id": None, } if selector_conflict is None: resolved_extension_guid = str(resolved_name_guid or explicit_extension_guid or "").lower() if resolved_extension_guid: result["extension_guid"] = resolved_extension_guid target["extension_guid"] = resolved_extension_guid target["origin"] = { **origin, "source": "extension", "status": "ok", "extension": { **origin_extension, "guid": resolved_extension_guid, **({"name": requested_extension} if requested_extension and not is_guid_text(requested_extension) else {}), }, } result["development_layer_resolution"] = { "status": "resolved", "source": "explicit_extension_guid" if explicit_extension_guid else "extension_name", "extension": { "guid": resolved_extension_guid, **({"name": requested_extension} if requested_extension and not is_guid_text(requested_extension) else {}), }, "layer_id": f"extension:{resolved_extension_guid}", } elif requested_extension or selector_conflict: # An explicit preflight/apply block consumes this resolution. The # synthetic layer also prevents accidental inheritance of base policy. result.pop("extension_guid", None) target.pop("extension_guid", None) result["layer_id"] = "extension:unresolved" target["layer_id"] = "extension:unresolved" unresolved_status = "conflict" if str((selector_conflict or {}).get("error") or "") == "extension_selector_conflict" else "not_resolved" result["development_layer_resolution"] = { "status": unresolved_status, "source": "extension_name", "extension": { **({"name": requested_extension} if requested_extension else {}), **({"supplied_guid": explicit_extension_guid} if explicit_extension_guid else {}), }, "layer_id": "extension:unresolved", "diagnostics": selector_conflict or extension_error or { "status": "not_resolved", "error": "extension_resolution_requires_base_id", "base_id": base_id or None, }, } if target: result["target"] = target if development_layer_write_block(result): return result existing = payload.get("owner_resolution") if isinstance(payload.get("owner_resolution"), dict) else {} if existing.get("status") == "resolved" and str(existing.get("repository_object") or "").strip(): return result # Retain the opaque active stream returned by code.search/code.read. It # is already exact provenance and must not be dropped before the module # writer can perform the proven ConfigCAS -> ConfigCASSave transition. module_ref = str( target.get("module_ref") or target.get("module_id") or target.get("concrete_reference") or payload.get("module_ref") or payload.get("module_id") or payload.get("concrete_reference") or "" ).strip() table, file_name, _stream = parse_module_id(module_ref) match = _FORM_MODULE_FILE_RE.fullmatch(file_name or "") if table and file_name else None if not match: return result if not base_id: return result form_guid = str(match.group("form") or "").lower() physical_extension_guid = str(match.group("extension") or "").lower() or None resolved = None error = None if table in FORM_ELEMENT_SAVED_STATE_TABLES: resolved, error = repository_resolve_saved_form_module_sql( method="write.gate_context", base_id=base_id, table=str(table), file_name=str(file_name), form_guid=form_guid, extension_guid=physical_extension_guid, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) else: resolved, error = repository_resolve_form_guid_sql( method="write.gate_context", base_id=base_id, form_guid=form_guid, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) if not resolved: result["owner_resolution"] = { "status": "not_resolved", "module_ref": module_ref, "form_guid": form_guid, "diagnostics": error, } return result canonical_object = str(resolved.get("resolved_object") or "").strip() resolved_extension_guid = str(resolved.get("extension_guid") or "").strip().lower() # For a physical form module, SQL ownership is authoritative. Do not let # a caller choose a different repository scope or configuration layer. result["repository_object"] = canonical_object result["support_object_guid"] = form_guid if resolved_extension_guid: result["extension_guid"] = resolved_extension_guid result.pop("layer_id", None) target["extension_guid"] = resolved_extension_guid target.pop("layer_id", None) origin = target.get("origin") if isinstance(target.get("origin"), dict) else {} previous_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} target["origin"] = { **origin, "source": "extension", "status": "ok", "extension": { "guid": resolved_extension_guid, **( {"name": previous_extension.get("name")} if str(previous_extension.get("guid") or "").strip().lower() == resolved_extension_guid and str(previous_extension.get("name") or "").strip() else {} ), }, } else: result.pop("extension_guid", None) result.pop("layer_id", None) target.pop("extension_guid", None) target.pop("layer_id", None) if isinstance(target.get("origin"), dict): origin = dict(target["origin"]) origin.pop("extension", None) origin["source"] = "configuration" origin["status"] = "ok" target["origin"] = origin if target: result["target"] = target result["owner_resolution"] = { "status": "resolved", "source": str(resolved.get("source") or "live_sql_form_guid"), "module_ref": module_ref, "form_guid": form_guid, "repository_object": canonical_object, "owner": resolved.get("owner"), "layer_id": f"extension:{resolved_extension_guid}" if resolved_extension_guid else "base", } result["development_layer_resolution"] = { "status": "resolved", "source": str(resolved.get("source") or "live_sql_form_guid"), "extension": {"guid": resolved_extension_guid} if resolved_extension_guid else None, "layer_id": f"extension:{resolved_extension_guid}" if resolved_extension_guid else "base", } return result def validate_repository_request_objects_sql(payload: dict[str, Any], *, method: str = repository_control.METHOD_LOCK_REQUEST) -> tuple[dict[str, Any], dict[str, Any] | None]: payload = dict(payload) requested_extension = str(payload.get("extension") or "").strip() supplied_extension_guid = str(payload.get("extension_guid") or "").strip().lower() if requested_extension: resolved_extension_guid, extension_error = extension_filter_to_guid( str(payload.get("base_id") or ""), requested_extension, method=method ) if extension_error: return payload, extension_error if supplied_extension_guid and supplied_extension_guid != resolved_extension_guid: return payload, invalid_argument(method, "extension_guid", "extension and extension_guid resolve to different extensions.") payload["extension_guid"] = str(resolved_extension_guid or "").lower() objects, object_error = repository_control._requested_objects(payload) if object_error: return payload, {"schema": "onec_repository_lock_request.v1", "method": method, **object_error} base_id = str(payload.get("base_id") or "").strip() canonical_objects: list[str] = [] resolutions: list[dict[str, Any]] = [] for public_ref in objects or []: form_parts = repository_form_reference_parts(public_ref) if form_parts: resolved_form, form_error = repository_resolve_owned_form_across_layers_sql( method=method, base_id=base_id, owner_kind=form_parts[0], owner_name=form_parts[1], form_name=form_parts[2], extension_guid=str(payload.get("extension_guid") or "").strip().lower() or None, timeout_seconds=int(payload.get("timeout_seconds") or 60), ) if form_error: return payload, form_error canonical_objects.append(str(resolved_form["resolved_object"])) resolutions.append(resolved_form) continue parts = public_ref.strip().split(".") if len(parts) == 2 and parts[0].casefold() in REPOSITORY_FORM_SEGMENTS and is_guid_text(parts[1].strip()): resolved_form, form_error = repository_resolve_form_guid_sql( method=method, base_id=base_id, form_guid=parts[1].strip().lower(), timeout_seconds=int(payload.get("timeout_seconds") or 60), ) if form_error: return payload, form_error canonical_objects.append(str(resolved_form["resolved_object"])) resolutions.append(resolved_form) continue kind_text, separator, name = public_ref.partition(".") if not separator or not kind_text.strip() or not name.strip(): return payload, invalid_argument(method, "objects", f"Repository object '{public_ref}' must use a public Kind.Name reference.") result: dict[str, Any] = {} resolved_table = "" attempts: list[dict[str, Any]] = [] extension_guid = str(payload.get("extension_guid") or "").strip().lower() tables = ("ConfigCAS", "ConfigCASSave") if is_guid_text(extension_guid) else ("Config", "ConfigSave", "ConfigCASSave") for table in tables: result = get_object( canonical_kind(kind_text), name, base_id=base_id, view="effective", limit=20, timeout_seconds=int(payload.get("timeout_seconds") or 60), table=table, include_semantic=False, extension_guid=extension_guid or None, ) attempts.append({"table": table, "status": result.get("status")}) if result.get("status") == "ok" and isinstance(result.get("object"), dict): resolved_table = table break if result.get("status") != "ok" or not isinstance(result.get("object"), dict): return payload, { "schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_object_not_found_in_sql", "object": public_ref, "diagnostics": {"attempts": attempts, "last_result": result}, } card = result["object"] resolved_kind = str(card.get("kind_ru") or card.get("public_kind") or card.get("kind") or kind_text).strip() resolved_name = str(card.get("name") or name).strip() canonical_objects.append(f"{resolved_kind}.{resolved_name}") resolutions.append({ "requested_object": public_ref, "resolved_object": canonical_objects[-1], "table": resolved_table, "source": "live_sql", **({"extension_guid": extension_guid} if extension_guid else {}), }) normalized = dict(payload) normalized["objects"] = canonical_objects normalized["sql_resolution"] = resolutions resolved_extensions = {str(item.get("extension_guid") or "").lower() for item in resolutions if isinstance(item, dict) and is_guid_text(str(item.get("extension_guid") or ""))} if len(resolved_extensions) > 1: return payload, invalid_argument(method, "objects", "One lock request cannot mix objects from different extension layers.") if resolved_extensions: resolved_extension = next(iter(resolved_extensions)) requested_extension = str(payload.get("extension_guid") or "").strip().lower() if requested_extension and requested_extension != resolved_extension: return payload, invalid_argument(method, "extension_guid", "extension_guid does not match the resolved object origin.") normalized["extension_guid"] = resolved_extension return normalized, None def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, Any]: if payload is None: payload = {} elif not isinstance(payload, dict): return invalid_argument(method, "payload", "payload must be a JSON object.") source_policy_error = validate_sql_only_runtime_payload(method, payload) if source_policy_error: return source_policy_error inactive_extension_error = reject_inactive_extension_scope(payload, method=method) if inactive_extension_error: return inactive_extension_error if "base_id" in payload and payload.get("base_id") is not None and not isinstance(payload.get("base_id"), str): return invalid_argument(method, "base_id", "base_id must be a JSON string.") if method in OBJECT_SELECTOR_ALIAS_METHODS: template_view_method = method in {"templates.read", "templates.analyze", "templates.map"} selector_argument_error = validate_object_selector_arguments(payload, method, include_view=not template_view_method) if selector_argument_error: return selector_argument_error normalized_payload = normalize_selector_payload_for_method(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload selector_error = validate_optional_string_arguments(payload, method, ["view"]) if selector_error: return selector_error base_id = str(payload.get("base_id")) if payload.get("base_id") else None if method == "health": return STATE.health(base_id=base_id) if method == "help.methods": validation_error = validate_help_methods_payload(payload) if validation_error: return validation_error selected = payload.get("method") registry = adapter_method_registry() methods = [row for name, row in registry.items() if not selected or name == selected] return { "schema": "onec_adapter_methods.v1", "contract_version": ADAPTER_CONTRACT_VERSION, "methods": methods, "count": len(methods), "registry": adapter_method_registry_diagnostics(), } if method == "repository.layer.connection.plan": validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error return repository_layer_connection_plan(payload) if method == "repository.layer.connection.set": validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error return repository_layer_connection_set(payload) if method == "repository.layers.audit": validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error return repository_layers_audit(payload) if method in repository_control.METHODS: validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error if method in {repository_control.METHOD_LOCK_PLAN, repository_control.METHOD_LOCK_REQUEST}: payload, repository_object_error = validate_repository_request_objects_sql(payload, method=method) if repository_object_error: return repository_object_error return repository_control.call(method, payload) if method == "adapter.job.start": return adapter_start_job(payload) if method in {"adapter.job.get", "mcp.job.get", "onec.job.get"}: return adapter_get_job(payload) if method in {"adapter.job.cancel", "mcp.job.cancel", "onec.job.cancel"}: return adapter_cancel_job(payload) if method == "metadata.kinds": validation_error = validate_metadata_kinds_payload(payload) if validation_error: return validation_error return get_kinds(base_id) if method == "metadata.capabilities": return metadata_capabilities(payload) if method == "metadata.adapter.audit": return metadata_adapter_audit(payload) if method == "metadata.write.capabilities": return metadata_write_capabilities(payload) if method == "metadata.objects.list": validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error if payload.get("extension") not in {None, ""}: return metadata_objects_list_extension_not_supported(payload) table_or_error = metadata_storage_table(payload, "metadata.objects.list") if isinstance(table_or_error, dict): return table_or_error table = table_or_error include_storage, include_storage_error = strict_bool_argument(payload, "include_storage", method="metadata.objects.list", default=False) if include_storage_error: return include_storage_error include_missing, include_missing_error = strict_bool_argument(payload, "include_missing", method="metadata.objects.list", default=False) if include_missing_error: return include_missing_error only_missing, only_missing_error = strict_bool_argument(payload, "only_missing", method="metadata.objects.list", default=False) if only_missing_error: return only_missing_error exact_counts, exact_counts_error = strict_bool_argument(payload, "exact_counts", method="metadata.objects.list", default=False) if exact_counts_error: return exact_counts_error refresh_cache, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method="metadata.objects.list", default=False) if refresh_cache_error: return refresh_cache_error return list_objects( payload.get("kind"), base_id=base_id, limit=payload.get("limit", 200), offset=payload.get("offset", 0), include_storage=bool(include_storage), include_missing=bool(include_missing), only_missing=bool(only_missing), exact_counts=bool(exact_counts), refresh_cache=bool(refresh_cache), table=table, name_filter=payload.get("name_filter") or payload.get("name_contains"), ) if method == "metadata.object.get": if not has_object_selector(payload): return invalid_argument("metadata.object.get", "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) guid_error = validate_explicit_guid_argument(payload, "metadata.object.get") if guid_error: return guid_error if "mode" in payload and (payload.get("mode") is None or payload.get("mode") == ""): return invalid_argument("metadata.object.get", "mode", "mode must be one of: card, semantic.", allowed_values=["card", "semantic"]) if "mode" in payload and not isinstance(payload.get("mode"), str): return invalid_argument("metadata.object.get", "mode", "mode must be a JSON string.", allowed_values=["card", "semantic"]) ordinal_error = validate_explicit_ordinal_arguments(payload, "metadata.object.get") if ordinal_error: return ordinal_error limit, limit_error = parse_int_argument(payload, "limit", method="metadata.object.get", default=20, minimum=1, maximum=5000) if limit_error: return limit_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.get", default=60, minimum=1) if timeout_error: return timeout_error view, view_error = parse_view_argument(payload, "metadata.object.get") if view_error: return view_error mode = str(payload.get("mode") or "card").strip().casefold() if mode not in {"card", "semantic"}: return { "schema": "onec_adapter_request_error.v1", "method": "metadata.object.get", "status": "invalid_argument", "error": "invalid_argument", "argument": "mode", "allowed_values": ["card", "semantic"], "diagnostics": {"message": "mode must be one of: card, semantic."}, } include_storage, include_storage_error = strict_bool_argument(payload, "include_storage", method="metadata.object.get", default=False) if include_storage_error: return include_storage_error include_semantic, include_semantic_error = strict_bool_argument(payload, "include_semantic", method="metadata.object.get", default=False) if include_semantic_error: return include_semantic_error table_or_error = metadata_storage_table(payload, "metadata.object.get") if isinstance(table_or_error, dict): return table_or_error table = table_or_error semantic_requested = bool(include_semantic or mode == "semantic") return get_object( payload.get("kind"), str(payload.get("guid") or payload.get("name") or ""), base_id=base_id, view=str(view or "effective"), limit=int(limit or 20), include_storage=bool(include_storage), ordinal=first_non_empty_arg(payload, "ordinal", "index", "object_index"), table=str(table or "Config"), file_name=str(payload.get("file_name") or "") or None, extension_guid=str(payload.get("extension_guid") or "") or None, include_semantic=semantic_requested, timeout_seconds=int(timeout_seconds or 60), ) if method == "metadata.object.properties": return metadata_object_properties(payload) if method == "metadata.object.decode": return decode_metadata_object(payload) if method == "metadata.object.parts": return metadata_object_parts(payload) if method == "metadata.object.components": return metadata_object_components(payload) if method == "metadata.object.modules": return metadata_object_modules(payload) if method == "metadata.object.related": return metadata_object_related(payload) if method == "report.inspect": return report_inspect(payload) if method == "appearance.inspect": return appearance_inspect(payload) if method == "scd.inspect": return scd_inspect(payload) if method == "scd.prepare": return scd_prepare(payload) if method == "scd.prepare.rollback": return scd_prepare_rollback(payload) if method == "scd.compare": return scd_compare(payload) if method == "scd.agent_report": return scd_agent_report(payload) if method == "dcs.field.lineage": return dcs_field_lineage(payload) if method == "form.field.lineage": return form_field_lineage(payload) if method == "scd.patch": return scd_patch(payload) if method == "metadata.object.forms": return metadata_object_forms(payload) if method == "metadata.object.form.details": return metadata_object_form_details(payload) if method == "metadata.object.templates": return metadata_object_templates(payload) if method == "metadata.object.template.details": return metadata_object_template_details(payload) if method == "templates.read": validation_error = validate_templates_read_payload(payload, method) if validation_error: return validation_error return templates_read(payload, analyze=False) if method == "templates.analyze": validation_error = validate_templates_read_payload(payload, method) if validation_error: return validation_error return templates_read(payload, analyze=True) if method == "templates.map": validation_error = validate_templates_read_payload(payload, method) if validation_error: return validation_error return templates_map(payload) if method == "templates.areas.find": validation_error = validate_templates_areas_find_payload(payload) if validation_error: return validation_error return templates_areas_find(payload) if method == "metadata.object.commands": return metadata_object_commands(payload) if method == "metadata.definition.find": validation_error = validate_metadata_definition_find_payload(payload) if validation_error: return validation_error return metadata_definition_find(payload) if method == "metadata.route.resolve": validation_error = validate_extension_objects_find_payload(payload, method) if validation_error: return validation_error return metadata_route_resolve(payload) if method == "metadata.object.special.details": return metadata_object_special_details(payload) if method == "metadata.form.decode": return metadata_form_decode(payload) if method == FORM_OWNER_INDEX_BUILD_METHOD: validation_error = validate_metadata_form_owner_index_build_payload(payload) if validation_error: return validation_error return metadata_form_owner_index_build(payload) if method == "metadata.form.write_target.resolve": validation_error = validate_metadata_form_write_target_resolve_payload(payload) if validation_error: return validation_error return metadata_form_write_target_resolve(payload) if method == FORM_WRITE_TARGET_VERIFY_METHOD: validation_error = validate_metadata_form_write_target_resolve_payload(payload, method=FORM_WRITE_TARGET_VERIFY_METHOD) if validation_error: return validation_error return metadata_form_write_target_verify(payload) if method == "metadata.saved_state.forms.search": return metadata_saved_state_forms_search(payload) if method == "metadata.saved_state.prepare": validation_error = validate_metadata_saved_state_prepare_payload(payload) if validation_error: return validation_error return metadata_saved_state_prepare(payload) if method == "metadata.saved_state.ensure": validation_error = validate_metadata_saved_state_prepare_payload(payload, method=method) if validation_error: return validation_error return metadata_saved_state_ensure(payload) if method == "metadata.saved_state.ensure.rollback": return metadata_saved_state_ensure_rollback(payload) if method == SAVED_STATE_STATUS_METHOD: validation_error = validate_metadata_saved_state_status_payload(payload) if validation_error: return validation_error return metadata_saved_state_status(payload) if method == CONFIGURATION_ACTIVATION_STATUS_METHOD: validation_error = validate_configuration_activation_payload(payload, method=method) if validation_error: return validation_error return configuration_activation_status(payload) if method == CONFIGURATION_ACTIVATION_PLAN_METHOD: validation_error = validate_configuration_activation_payload(payload, method=method) if validation_error: return validation_error return configuration_activation_plan(payload) if method == CONFIGURATION_ACTIVATION_REQUEST_METHOD: validation_error = validate_configuration_activation_request_payload(payload) if validation_error: return validation_error return configuration_activation_request(payload) if method == CONFIGURATION_ACTIVATION_REQUEST_STATUS_METHOD: validation_error = validate_configuration_activation_request_status_payload(payload) if validation_error: return validation_error return configuration_activation_request_status(payload) if method == CONFIGURATION_ACTIVATION_REQUEST_CANCEL_METHOD: validation_error = validate_configuration_activation_request_cancel_payload(payload) if validation_error: return validation_error return configuration_activation_request_cancel(payload) if method == CONFIGURATION_ACTIVATION_AUDIT_METHOD: validation_error = validate_configuration_activation_audit_payload(payload) if validation_error: return validation_error return configuration_activation_audit(payload) if method == CONFIGURATION_ACTIVATION_CAPABILITIES_METHOD: validation_error = validate_configuration_activation_capabilities_payload(payload) if validation_error: return validation_error return configuration_activation_capabilities(payload) if method == CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD: validation_error = validate_configuration_activation_bridge_probe_payload(payload) if validation_error: return validation_error return configuration_activation_bridge_probe(payload) if method == CONFIGURATION_ACTIVATION_EXECUTE_METHOD: validation_error = validate_configuration_activation_execute_payload(payload) if validation_error: return validation_error return configuration_activation_execute(payload) if method == CONFIGURATION_ACTIVATION_VERIFY_METHOD: validation_error = validate_configuration_activation_verify_payload(payload) if validation_error: return validation_error return configuration_activation_verify(payload) if method == SAVED_STATE_DIFF_METHOD: validation_error = validate_metadata_saved_state_diff_payload(payload) if validation_error: return validation_error return metadata_saved_state_diff(payload) if method == SAVED_STATE_CHANGES_LIST_METHOD: validation_error = validate_metadata_saved_state_changes_list_payload(payload) if validation_error: return validation_error return metadata_saved_state_changes_list(payload) if method == SAVED_STATE_MODULES_SEARCH_METHOD: return metadata_saved_state_modules_search(payload) if method == "metadata.form.element.write": return metadata_form_element_write(payload) if method == "metadata.form.element.write_apply": return metadata_form_element_write_apply(payload) if method == OBJECT_PROPERTY_WRITE_METHOD: validation_error = validate_metadata_object_property_write_payload(payload) if validation_error: return validation_error return metadata_object_property_write(payload) if method == OBJECT_MEMBER_ADD_METHOD: validation_error = validate_metadata_object_member_add_payload(payload) if validation_error: return validation_error return metadata_object_member_add(payload) if method == FORM_TARGET_MOVE_METHOD: return metadata_form_target_move(payload) if method == FORM_COMMAND_BUTTON_WRITE_METHOD: return metadata_form_command_button_write(payload) if method == FORM_COMMAND_BUTTON_VERIFY_METHOD: return metadata_form_command_button_verify(payload) if method == MODULE_WRITE_APPLY_METHOD: return metadata_module_write_apply(payload) if method == METADATA_WRITE_TARGET_RESOLVE_METHOD: return metadata_write_target_resolve(payload) if method == METADATA_WRITE_PLAN_METHOD: validation_error = validate_metadata_write_plan_payload(payload) if validation_error: return validation_error return metadata_write_plan(payload) if method == METADATA_WRITE_PREFLIGHT_METHOD: validation_error = validate_metadata_write_preflight_payload(payload) if validation_error: return validation_error return metadata_write_preflight(payload) if method == METADATA_WRITE_METHOD: return metadata_write(payload) if method == "metadata.write.history": validation_error = validate_metadata_write_history_payload(payload) if validation_error: return validation_error return metadata_write_history(payload) if method == METADATA_WRITE_ROLLBACK_METHOD: validation_error = validate_metadata_write_rollback_payload(payload) if validation_error: return validation_error return metadata_write_rollback(payload) if method == FORM_WRITE_MATRIX_BUILD_METHOD: return metadata_form_write_matrix_build(payload) if method == FORM_WRITE_MATRIX_SMOKE_METHOD: return metadata_form_write_matrix_smoke(payload) if method == "metadata.write_learning.capture_before": return metadata_write_learning_capture(payload, "before") if method == "metadata.write_learning.capture_after": return metadata_write_learning_capture(payload, "after") if method == "metadata.write_learning.diff": return metadata_write_learning_diff(payload) if method == "metadata.write_learning.infer_rule": return metadata_write_learning_infer_rule(payload) if method == "metadata.object.attributes": return metadata_object_attributes(payload) if method == "metadata.relationship.verify": return metadata_relationship_verify(payload) if method == "metadata.relationship.find": return metadata_relationship_find(payload) if method == "metadata.object.full": return metadata_object_full(payload) if method == "metadata.snapshot": return metadata_snapshot(payload) if method == "metadata.cache.status": return metadata_cache_status(payload) if method == "metadata.cache.lookup": return metadata_cache_lookup(payload) if method == "metadata.cache.rebuild": return metadata_cache_rebuild(payload) if method == "metadata.cache.invalidate": return metadata_cache_invalidate(payload) if method == "infobase.users.search": return infobase_users_search(payload) if method == "infobase.user.get": return infobase_user_get(payload) if method == "infobase.user.password.status": return infobase_user_password_status(payload) if method == "infobase.user.password.capabilities": return infobase_user_password_capabilities(payload) if method == "infobase.user.password.set": return infobase_user_password_change(payload, operation="set") if method == "infobase.user.password.clear": return infobase_user_password_change(payload, operation="clear") if method == "access.snapshot.extract": return access_snapshot_extract(payload) if method == "access.graph.build": return access_graph_build(payload) if method == "access.user.explain": return access_user_explain(payload) if method == "access.users.search": return access_users_search(payload) if method == "access.keys.query": return access_keys_query(payload) if method == "access.object_keys.resolve": return access_object_keys_resolve(payload) if method == "access.object.explain": return access_object_explain(payload) if method == "access.object.roles": return access_object_roles(payload) if method == "access.object.subjects": return access_object_subjects(payload) if method == "access.rls.discover": return access_rls_discover(payload) if method == "access.role.profiles": return access_role_profiles(payload) if method == "access.role.users": return access_role_users(payload) if method == "access.role.audit_export": return access_role_audit_export(payload) if method == "access.role.audit_analyze": return access_role_audit_analyze(payload) if method == "semantic.cache.search": validation_error = validate_semantic_cache_search_payload(payload) if validation_error: return validation_error return semantic_cache_search(payload) if method == "semantic.cache.status": validation_error = validate_semantic_cache_status_payload(payload) if validation_error: return validation_error return semantic_cache_status(payload) if method == "semantic.cache.validate": validation_error = validate_semantic_cache_validate_payload(payload) if validation_error: return validation_error return semantic_cache_validate(payload) if method == "semantic.cache.validate_batch": validation_error = validate_semantic_cache_validate_batch_payload(payload) if validation_error: return validation_error return semantic_cache_validate_batch(payload) if method == "semantic.cache.refresh": validation_error = validate_semantic_cache_refresh_payload(payload) if validation_error: return validation_error return semantic_cache_refresh(payload) if method == "semantic.cache.rebuild": validation_error = validate_semantic_cache_rebuild_payload(payload) if validation_error: return validation_error return semantic_cache_rebuild(payload) if method == "semantic.cache.pending": validation_error = validate_semantic_cache_pending_payload(payload) if validation_error: return validation_error return semantic_cache_pending(payload) if method == "semantic.cache.embedding.upsert": validation_error = validate_semantic_cache_embedding_upsert_payload(payload) if validation_error: return validation_error return semantic_cache_embedding_upsert(payload) if method == "metadata.code_index.build": return metadata_code_index_build(payload) if method == "metadata.code_index.status": return metadata_code_index_status(payload) if method == "metadata.code_index.search": return metadata_code_index_search(payload) if method == "metadata.code_index.verify": return metadata_code_index_verify(payload) if method == "metadata.code_index.refresh_changed": return metadata_code_index_refresh_changed(payload) if method == "metadata.code_index.sync_pending": return metadata_code_index_sync_pending(payload) if method == "metadata.code_index.poll_changes": return metadata_code_index_poll_changes(payload) if method == "metadata.code_vector.pending": return metadata_code_vector_pending(payload) if method == "metadata.code_vector.embedding.upsert": return metadata_code_vector_embedding_upsert(payload) if method == "metadata.code_vector.search": return metadata_code_vector_search(payload) if method == "metadata.module_owner_cache.backfill": validation_error = validate_metadata_module_owner_cache_backfill_payload(payload) if validation_error: return validation_error return metadata_module_owner_cache_backfill(payload) if method == "metadata.module_owner_cache.prune": return metadata_module_owner_cache_prune(payload) if method == "extensions.list": return list_extensions(payload) if method == "extension.cache.status": validation_error = validate_extension_cache_status_payload(payload, method) if validation_error: return validation_error return extension_cache_status(payload) if method == "extension.cache.rebuild": validation_error = validate_extension_cache_rebuild_payload(payload, method) if validation_error: return validation_error return extension_cache_rebuild(payload) if method == "extension.cache.validate": validation_error = validate_extension_cache_validate_payload(payload, method) if validation_error: return validation_error return extension_cache_validate(payload) if method == "extension.objects.find": validation_error = validate_extension_objects_find_payload(payload, method) if validation_error: return validation_error return extension_objects_find(payload) if method == "schema.tables.list": return schema_tables_list(payload) if method == "storage.files.list": return storage_files_list(payload) if method == "storage.file.get": return storage_file_get(payload) if method == "storage.saved_state.apply_proposal": return storage_saved_state_apply_proposal(payload) if method == "storage.saved_state.rollback": return storage_saved_state_rollback(payload) if method == "storage.saved_state.backups.list": return storage_saved_state_backups_list(payload) if method == "storage.saved_state.backups.prune": return storage_saved_state_backups_prune(payload) if method == "metadata.dbnames.summary": return metadata_dbnames_summary(payload) if method == "metadata.support.decode": return metadata_support_decode(payload) if method == "repository.sql_state.snapshot": return repository_sql_state_snapshot(payload) if method == "repository.sql_state.diff": return repository_sql_state_diff(payload) if method == "payload.diff": validation_error = validate_payload_diff_payload(payload) if validation_error: return validation_error return payload_diff(payload) if method == "codec.decode": return codec_decode(payload) if method == "codec.encode": return codec_encode(payload) if method == "query.validate": return validate_query(payload) if method == "query.run": return run_readonly_query(payload) if method == "data.schema": return data_object_schema(payload) if method in {"data.list", "data.get"}: if method == "data.get" and not data_record_ref(payload): return invalid_argument(method, "record_ref", "record_ref is required for data.get.") return data_read(payload, method=method) if method == "data.count": return data_read(payload, count_only=True, method=method) if method == "data.query": return data_read(payload, count_only=payload.get("count_only") is True, method=method) if method == "additional_attributes.find": return additional_attributes_find(payload) if method == "additional_attributes.storage.resolve": return additional_attributes_storage_resolve(payload) if method == "data.present": return data_present(payload) if method == "data.movements": return data_movements(payload) if method == "data.virtual": return data_virtual(payload) if method == "changes.propose": return changes_propose(payload) if method == "modules.search": return search_modules(payload) if method == "modules.read": return read_module(payload) if method == "metadata.resolve_overrides": validation_error = validate_metadata_resolve_overrides_payload(payload) if validation_error: return validation_error return metadata_resolve_overrides(payload) if method == "code.search": validation_error = validate_code_search_payload(payload) if validation_error: return validation_error return code_search(payload) if method == "code.read": validation_error = validate_code_read_payload(payload) if validation_error: return validation_error return code_read(payload) if method == CODE_WRITE_METHOD: validation_error = validate_code_write_payload(payload) if validation_error: return validation_error return code_write(payload) if method == "code.symbol.resolve": validation_error = validate_code_symbol_resolve_payload(payload) if validation_error: return validation_error return code_symbol_resolve(payload) if method == "templates.bindings": validation_error = validate_templates_bindings_payload(payload) if validation_error: return validation_error return templates_bindings(payload) if method == "template.field.lineage": validation_error = validate_template_field_lineage_payload(payload) if validation_error: return validation_error return template_field_lineage(payload) if method == "diagnostics.call_chain": validation_error = validate_diagnostics_call_chain_payload(payload) if validation_error: return validation_error return diagnostics_call_chain(payload) return {"schema": "onec_adapter_error.v1", "error": "unknown_method", "method": method, "known_methods": [row["name"] for row in METHODS]} def call_method(method: str, payload: dict[str, Any] | None) -> dict[str, Any]: if payload is None: payload = {} elif not isinstance(payload, dict): return invalid_argument(method, "payload", "payload must be a JSON object.") try: result = call_method_impl(method, payload) except Exception as exc: result = { "schema": "onec_adapter_method_error.v1", "status": "error", "method": method, "base_id": payload.get("base_id"), "error": "method_exception", "diagnostics": { "message": str(exc), }, } if truthy(os.environ.get("ONEC_ADAPTER_DEBUG_DIAGNOSTICS")) and truthy( payload.get("diagnostic") or payload.get("_allow_diagnostic") ): result["diagnostics"]["traceback"] = traceback.format_exc(limit=8) if truthy(payload.get("diagnostic") or payload.get("_allow_diagnostic") or payload.get("include_storage")): return attach_write_history_operation(payload, method, result) if method in TECHNICAL_WRITE_METHODS: return attach_write_history_operation(payload, method, result) result = attach_write_history_operation(payload, method, result) return sanitize_public_result(result) GET_BOOL_PARAMS = { "include_storage", "include_semantic", "include_modules", "include_missing", "only_missing", "exact_counts", "refresh_cache", "preview", "include_text", "summary", "routines_only", "include_line_numbers", "include_context", "include_container_preview", "diagnostic", "_allow_diagnostic", } GET_INT_PARAMS = { "limit", "offset", "timeout_seconds", "ordinal", "index", "object_index", "module_ordinal", "module_index", "module_number", "scan_limit", "max_matches", "bsl_offset", "max_chars", "container_preview_chars", } def coerce_get_params(params: dict[str, Any]) -> dict[str, Any]: result = dict(params) for name in GET_BOOL_PARAMS: if name not in result: continue value = str(result.get(name) or "").strip().casefold() if value in {"true", "1", "yes", "on", "да"}: result[name] = True elif value in {"false", "0", "no", "off", "нет"}: result[name] = False for name in GET_INT_PARAMS: if name not in result: continue value = str(result.get(name) or "").strip() if re.fullmatch(r"-?\d+", value): result[name] = int(value) return result def adapter_service_token() -> str: return str(os.getenv("ONEC_ADAPTER_SERVICE_TOKEN") or "").strip() def adapter_request_authorized(authorization: Any) -> bool: expected = adapter_service_token() if not expected: return True provided = str(authorization or "").strip() if not provided.lower().startswith("bearer "): return False return hmac.compare_digest(provided[7:].strip(), expected) def adapter_audit_log_path() -> Path: return Path(os.environ.get("ONEC_ADAPTER_AUDIT_LOG_PATH") or "/data/adapter-audit.jsonl") ADAPTER_AUDIT_LOCK = threading.Lock() def adapter_audit_rotate_locked(path: Path) -> None: """Bound the development audit log while preserving recent history.""" try: max_bytes = max(1_048_576, int(os.environ.get("ONEC_ADAPTER_AUDIT_MAX_BYTES") or 52_428_800)) keep_files = max(1, min(20, int(os.environ.get("ONEC_ADAPTER_AUDIT_KEEP_FILES") or 10))) except ValueError: return try: if not path.exists() or path.stat().st_size < max_bytes: return for index in range(keep_files - 1, 0, -1): source = path.with_name(f"{path.name}.{index}") target = path.with_name(f"{path.name}.{index + 1}") if source.exists(): source.replace(target) path.replace(path.with_name(f"{path.name}.1")) except OSError: return def adapter_audit_event(event: dict[str, Any]) -> None: """Persist development telemetry without BSL text, payload bytes, or credentials.""" try: path = adapter_audit_log_path() path.parent.mkdir(parents=True, exist_ok=True) with ADAPTER_AUDIT_LOCK: adapter_audit_rotate_locked(path) with path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str) + "\n") except OSError: pass def adapter_audit_request_summary(payload: dict[str, Any]) -> dict[str, Any]: keys = ("base_id", "extension", "extension_guid", "ref", "kind", "name", "object_type", "object_name", "module_ordinal", "mode", "execution_mode") return {key: payload.get(key) for key in keys if payload.get(key) not in {None, ""}} def adapter_audit_result_summary(result: Any) -> dict[str, Any]: """Keep diagnosis-grade RPC outcome evidence without code or SQL details.""" if not isinstance(result, dict): return {} summary: dict[str, Any] = {} for key in ("status", "error", "target_kind", "routed_method", "operation", "applied"): value = result.get(key) if value not in (None, ""): summary[key] = value for key in ("timings_ms", "counts", "route_resolution", "preparation"): value = result.get(key) if isinstance(value, dict): # These fields are constructed by public routes and must never # contain physical file names, payloads, or module text. summary[key] = value resolution = result.get("resolution") if isinstance(resolution, dict): summary["resolution"] = { key: resolution.get(key) for key in ("method", "status", "counts", "timings_ms", "prepare_status", "retry_search_status", "retry_search_counts") if resolution.get(key) not in (None, "") } diagnostics = result.get("diagnostics") if isinstance(diagnostics, dict): safe_diagnostics = { key: diagnostics.get(key) for key in ("code", "next_action", "reason") if diagnostics.get(key) not in (None, "") } if safe_diagnostics: summary["diagnostics"] = safe_diagnostics return summary def adapter_audit_request_id(headers: Any) -> str: candidate = str(headers.get("X-Request-ID") or "").strip() if re.fullmatch(r"[A-Za-z0-9_.-]{8,128}", candidate): return candidate return uuid.uuid4().hex def sql_admin_config_path() -> Path: return Path(os.environ.get("ONEC_SQL_BASES_JSON_FILE") or "/data/onec-sql-bases.json") def sql_admin_load() -> dict[str, Any]: if str(os.environ.get("ONEC_SQL_BASES_JSON") or "").strip(): raise ValueError("Редактирование отключено: список задан через ONEC_SQL_BASES_JSON. Перенесите его в ONEC_SQL_BASES_JSON_FILE.") path = sql_admin_config_path() if not path.exists(): return {} data = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(data, dict): raise ValueError("Файл списка баз должен содержать JSON-объект.") return data def sql_admin_public(config: dict[str, Any]) -> list[dict[str, Any]]: result = [] for base_id in sorted(config, key=str.casefold): item = config.get(base_id) if not isinstance(item, dict): continue password_env = str(item.get("password_env") or "") result.append({ "base_id": base_id, "server": str(item.get("server") or ""), "database": str(item.get("database") or ""), "user": str(item.get("user") or ""), "password_env": password_env, "has_password": bool(item.get("password") or (password_env and os.environ.get(password_env))), "repository": repository_control._public_config(item["repository"]) if isinstance(item.get("repository"), dict) else None, "development_layers": repository_control.public_development_layers(base_id), }) return result def sql_admin_save(config: dict[str, Any]) -> None: path = sql_admin_config_path() path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_suffix(path.suffix + ".tmp") temp.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") try: os.chmod(temp, 0o600) except OSError: pass temp.replace(path) def repository_layer_connection_plan(payload: dict[str, Any]) -> dict[str, Any]: """Build a non-mutating exact-layer policy configuration plan.""" method = "repository.layer.connection.plan" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error extension_name = str(payload.get("extension") or "").strip() extension_guid = str(payload.get("extension_guid") or "").strip().lower() requested_layer = str(payload.get("layer_id") or "").strip().lower() if extension_name: resolved_guid, resolve_error = extension_filter_to_guid(base_id_or_error, extension_name, method=method) if resolve_error: return resolve_error if extension_guid and extension_guid != resolved_guid: return invalid_argument(method, "extension_guid", "extension and extension_guid resolve to different extensions; refuse to configure an ambiguous layer.") extension_guid = str(resolved_guid or "").lower() if extension_guid: if not is_guid_text(extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") layer_id = f"extension:{extension_guid}" else: layer_id = requested_layer or "base" if layer_id != "base" and not re.fullmatch(r"extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", layer_id): return invalid_argument(method, "layer_id", "Pass base or extension name/GUID.") requested_state = str(payload.get("connection_state") or "configured").strip().casefold() if requested_state not in repository_control.SUPPORTED_REPOSITORY_CONNECTION_STATES: return invalid_argument(method, "connection_state", "connection_state must be not_configured, configured, unavailable, or unknown.") current, current_error = repository_control.development_layer_config(base_id_or_error, layer_id) current_repository = current.get("repository") if isinstance(current, dict) and isinstance(current.get("repository"), dict) else {} current_state = str(current_repository.get("connection_state") or "").strip().casefold() or None needs_configuration = bool(current_error) or current_state != requested_state return { "schema": "onec_repository_layer_connection_plan.v1", "method": method, "status": "needs_configuration" if needs_configuration else "already_configured", "base_id": base_id_or_error, "target": {"kind": "extension" if layer_id != "base" else "base", **({"extension": extension_name} if extension_name else {})}, "requested_connection_state": requested_state, "current": { "connection_state": current_state, "repository_mode": str(current_repository.get("mode") or current_repository.get("lock_mode") or "").strip().casefold() or None, }, "apply": { "method": "repository.layer.connection.set", "payload": { "base_id": base_id_or_error, **({"extension": extension_name} if extension_name else ({"layer_id": "base"} if layer_id == "base" else {})), "connection_state": requested_state, "confirm_repository_connection_change": True, }, "requires_explicit_administrator_confirmation": True, }, "diagnostics": { "read_only": True, "message": "The plan does not change adapter configuration and never writes a 1C database. Apply only after an administrator confirms the exact extension policy.", }, } def repository_layer_connection_set(payload: dict[str, Any]) -> dict[str, Any]: """Persist one layer's repository connection fact in adapter-owned config.""" method = "repository.layer.connection.set" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error if payload.get("confirm_repository_connection_change") is not True: return invalid_argument( method, "confirm_repository_connection_change", "This changes adapter configuration only. Pass confirm_repository_connection_change=true after confirming the layer connection state.", ) base_id = base_id_or_error extension_guid = str(payload.get("extension_guid") or "").strip().lower() extension_name = str(payload.get("extension") or "").strip() requested_layer = str(payload.get("layer_id") or "").strip().lower() if extension_name and extension_guid: resolved_guid, resolve_error = extension_filter_to_guid(base_id_or_error, extension_name, method=method) if resolve_error: return resolve_error if resolved_guid != extension_guid: return invalid_argument( method, "extension_guid", "extension and extension_guid resolve to different extensions; refuse to configure an ambiguous layer.", ) elif extension_name: resolved_guid, resolve_error = extension_filter_to_guid(base_id_or_error, extension_name, method=method) if resolve_error: return resolve_error extension_guid = str(resolved_guid or "").lower() if extension_guid: if not is_guid_text(extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") layer_id = f"extension:{extension_guid}" else: layer_id = requested_layer or "base" if layer_id != "base" and not re.fullmatch(r"extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", layer_id): return invalid_argument(method, "layer_id", "Pass base or extension:.") connection_state = str(payload.get("connection_state") or "").strip().casefold() if connection_state not in repository_control.SUPPORTED_REPOSITORY_CONNECTION_STATES: return invalid_argument(method, "connection_state", "connection_state must be not_configured, configured, unavailable, or unknown.") repository_user = payload.get("repository_user") if repository_user is not None and (not isinstance(repository_user, str) or not repository_user.strip()): return invalid_argument(method, "repository_user", "repository_user must be a non-empty string when provided.") try: config = sql_admin_load() except ValueError as exc: return {"schema": "onec_repository_layer_connection_set.v1", "method": method, "base_id": base_id, "status": "blocked", "error": "adapter_configuration_not_writable", "message": str(exc)} item = config.get(base_id) if not isinstance(item, dict): return {"schema": "onec_repository_layer_connection_set.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "base_not_configured"} layers = item.get("development_layers") if isinstance(item.get("development_layers"), dict) else {} if not layers and layer_id == "base": legacy_repository = item.get("repository") if isinstance(item.get("repository"), dict) else {} layers = {"base": {"repository": dict(legacy_repository), "support": {"mode": "unknown"}}} layer = layers.get(layer_id) if isinstance(layers.get(layer_id), dict) else {} repository = dict(layer.get("repository") or {}) if isinstance(layer.get("repository"), dict) else {} current_mode = str(repository.get("mode") or repository.get("lock_mode") or "unknown").strip().casefold() if connection_state == "not_configured": repository["mode"] = "none" repository["lock_mode"] = "manual" elif current_mode in {"none", "unknown"}: repository["mode"] = "manual" repository["lock_mode"] = "manual" repository["connection_state"] = connection_state if repository_user is not None: repository["repository_user"] = repository_user.strip() layers[layer_id] = {"repository": repository, "support": dict(layer.get("support") or {"mode": "unknown"})} item["development_layers"] = layers config[base_id] = item sql_admin_save(config) status = repository_control.status({"base_id": base_id, "layer_id": layer_id}) return { "schema": "onec_repository_layer_connection_set.v1", "method": method, "base_id": base_id, "layer_id": layer_id, "status": "saved", "connection_state": connection_state, "repository": status.get("repository"), "repository_status": status.get("status"), "message": "Saved in adapter configuration only; no 1C SQL database was changed.", } def sql_admin_validate(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]: base_id = str(payload.get("base_id") or "").strip() if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", base_id): raise ValueError("base_id: допустимы латинские буквы, цифры, точка, дефис и подчёркивание.") item = {key: str(payload.get(key) or "").strip() for key in ("server", "database", "user")} missing = [key for key, value in item.items() if not value] if missing: raise ValueError("Заполните обязательные поля: " + ", ".join(missing)) password = str(payload.get("password") or "") password_env = str(payload.get("password_env") or "").strip() if password_env: item["password_env"] = password_env elif password: item["password"] = password repository = payload.get("repository") development_layers = payload.get("development_layers") if development_layers is not None: if not isinstance(development_layers, dict): raise ValueError("development_layers должен быть JSON-объектом с ключами base и extension:.") normalized_layers: dict[str, Any] = {} for raw_layer_id, raw_layer in development_layers.items(): layer_id = str(raw_layer_id or "").strip().lower() if layer_id != "base" and not re.fullmatch(r"extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", layer_id): raise ValueError("development_layers: ключ должен быть base или extension:.") if not isinstance(raw_layer, dict): raise ValueError(f"development_layers.{layer_id} должен быть JSON-объектом.") raw_repository = raw_layer.get("repository") if isinstance(raw_layer.get("repository"), dict) else {"mode": "none"} repository_mode = str(raw_repository.get("mode") or raw_repository.get("lock_mode") or "none").strip().casefold() if repository_mode not in repository_control.SUPPORTED_REPOSITORY_MODES: raise ValueError(f"development_layers.{layer_id}.repository.mode: допустимы none, manual, automatic и unknown.") default_connection_state = "unknown" if repository_mode == "unknown" else ("not_configured" if repository_mode == "none" else "configured") connection_state = str(raw_repository.get("connection_state") or raw_repository.get("repository_connection") or raw_repository.get("connection") or default_connection_state).strip().casefold() if connection_state not in repository_control.SUPPORTED_REPOSITORY_CONNECTION_STATES: raise ValueError(f"development_layers.{layer_id}.repository.connection_state: допустимы not_configured, configured, unavailable и unknown.") raw_support = raw_layer.get("support") if isinstance(raw_layer.get("support"), dict) else {"mode": "unknown"} support_mode = str(raw_support.get("mode") or "unknown").strip().casefold() if support_mode not in repository_control.SUPPORTED_SUPPORT_MODES: raise ValueError(f"development_layers.{layer_id}.support.mode: допустимы none, editable, locked, rules и unknown.") rules = raw_support.get("rules") if isinstance(raw_support.get("rules"), dict) else {} normalized_layers[layer_id] = { "repository": {**raw_repository, "mode": repository_mode, "lock_mode": repository_mode if repository_mode != "none" else "manual", "connection_state": connection_state}, "support": {"mode": support_mode, **({"rules": {str(key): str(value) for key, value in rules.items()}} if rules else {})}, } item["development_layers"] = normalized_layers if repository is not None: if not isinstance(repository, dict): raise ValueError("repository должен быть JSON-объектом.") if repository.get("enabled") is False: item["repository"] = None else: backend = str(repository.get("backend") or "").strip().casefold() lock_mode = str(repository.get("lock_mode") or "automatic").strip().casefold() runner_url = str(repository.get("runner_url") or "").strip() runner_token_env = str(repository.get("runner_token_env") or "").strip() if backend not in repository_control.SUPPORTED_BACKENDS: raise ValueError("repository.backend: допустимы direct и karman_bridge.") if lock_mode not in repository_control.SUPPORTED_LOCK_MODES: raise ValueError("repository.lock_mode: допустимы automatic и manual.") if lock_mode == "automatic" and not runner_url: raise ValueError("Для repository требуется runner_url.") item["repository"] = { "backend": backend, "layer": str(repository.get("layer") or "base").strip().casefold(), "lock_mode": lock_mode, "bridge_id": str(repository.get("bridge_id") or "").strip(), "runtime_version": str(repository.get("runtime_version") or "").strip(), "repository_user": str(repository.get("repository_user") or "").strip(), "repository_password_env": str(repository.get("repository_password_env") or "").strip(), "infobase_user": str(repository.get("infobase_user") or "").strip(), "infobase_password_env": str(repository.get("infobase_password_env") or "").strip(), "runner": {"kind": "http", "url": runner_url, "token_env": runner_token_env}, } return base_id, item class GracefulThreadingHTTPServer(ThreadingHTTPServer): """Keep in-flight request threads alive while a container drains.""" daemon_threads = False block_on_close = True class Handler(BaseHTTPRequestHandler): server_version = "adapter-1c-rest/0.1" def log_message(self, fmt: str, *args: Any) -> None: print("%s - - [%s] %s" % (self.client_address[0], self.log_date_time_string(), fmt % args), flush=True) def read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length") or "0") if length <= 0: return {} raw = self.rfile.read(length).decode("utf-8-sig") return json.loads(raw) if raw.strip() else {} def write_json(self, status: int, payload: Any) -> None: body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def write_static(self, path: Path, content_type: str) -> None: body = path.read_bytes() self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def handle_error(self, exc: BaseException) -> None: payload: dict[str, Any] = { "schema": "onec_adapter_exception.v1", "error": "request_exception", "diagnostics": {"message": str(exc)}, } if truthy(os.environ.get("ONEC_ADAPTER_DEBUG_DIAGNOSTICS")): payload["diagnostics"]["traceback"] = traceback.format_exc(limit=8) self.write_json( 500, payload, ) def require_authorization(self, path: str) -> bool: allow_unauthenticated_admin = truthy(os.environ.get("ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN")) if path.startswith("/admin/api/") and allow_unauthenticated_admin: return True if path.startswith("/admin/api/") and not adapter_service_token() and not allow_unauthenticated_admin: self.write_json( 503, {"schema": "onec_adapter_auth.v1", "status": "not_configured", "error": "admin_auth_not_configured", "message": "Задайте ONEC_ADAPTER_SERVICE_TOKEN перед использованием управления базами."}, ) return False if path == "/health" or adapter_request_authorized(self.headers.get("Authorization")): return True self.write_json( 401, {"schema": "onec_adapter_auth.v1", "status": "unauthorized", "error": "bearer_token_required"}, ) return False def audit_request(self, method: str, path: str, rpc_method: str | None = None) -> None: print( json.dumps( { "event": "adapter_request", "time": datetime.now(timezone.utc).isoformat(), "client": self.client_address[0], "http_method": method, "path": path, **({"rpc_method": rpc_method} if rpc_method else {}), }, ensure_ascii=False, ), flush=True, ) def do_GET(self) -> None: try: parsed = urllib.parse.urlparse(self.path) if parsed.path in {"/admin", "/admin/", "/admin/app.js", "/admin/style.css"}: admin_dir = Path(__file__).resolve().parent / "admin" name, content_type = ({ "/admin/app.js": ("app.js", "application/javascript; charset=utf-8"), "/admin/style.css": ("style.css", "text/css; charset=utf-8"), }.get(parsed.path) or ("index.html", "text/html; charset=utf-8")) self.write_static(admin_dir / name, content_type) return if not self.require_authorization(parsed.path): return self.audit_request("GET", parsed.path) params = coerce_get_params({key: values[-1] for key, values in urllib.parse.parse_qs(parsed.query, keep_blank_values=True).items()}) if parsed.path == "/admin/api/bases": self.write_json(200, {"bases": sql_admin_public(sql_admin_load()), "config_path": str(sql_admin_config_path())}) elif parsed.path == "/admin/api/repository/requests": self.write_json(200, repository_control.admin_state(str(params.get("base_id") or ""))) elif parsed.path in HTTP_GET_METHOD_ROUTES: self.write_json(200, call_method(HTTP_GET_METHOD_ROUTES[parsed.path], params)) else: self.write_json(404, {"error": "not_found", "path": parsed.path}) except ValueError as exc: self.write_json(400, {"error": "invalid_config", "message": str(exc)}) except Exception as exc: self.handle_error(exc) def do_POST(self) -> None: try: parsed = urllib.parse.urlparse(self.path) if not self.require_authorization(parsed.path): return payload = self.read_json() if not isinstance(payload, dict): self.write_json(200, invalid_argument("http.post", "body", "HTTP JSON body must be an object.")) return self.audit_request("POST", parsed.path, str(payload.get("method") or "") if parsed.path == "/rpc" else None) if parsed.path == "/admin/api/bases": config = sql_admin_load() base_id, item = sql_admin_validate(payload) if base_id in config: self.write_json(409, {"error": "base_exists", "message": "База с таким base_id уже существует."}) return config[base_id] = item sql_admin_save(config) self.write_json(201, {"base": sql_admin_public({base_id: item})[0]}) elif parsed.path == "/rpc": rpc_method = str(payload.get("method") or "") rpc_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {} started = time.time() request_id = adapter_audit_request_id(self.headers) adapter_runtime_begin_request(request_id, rpc_method) try: result = call_method(rpc_method, rpc_payload) except Exception as exc: adapter_audit_event({ "event": "adapter_rpc", "time": datetime.now(timezone.utc).isoformat(), "request_id": request_id, "method": rpc_method, "request": adapter_audit_request_summary(rpc_payload), "status": "exception", "error": "request_exception", "exception_type": type(exc).__name__, "duration_ms": int((time.time() - started) * 1000), }) raise finally: adapter_runtime_end_request(request_id) adapter_audit_event({ "event": "adapter_rpc", "time": datetime.now(timezone.utc).isoformat(), "request_id": request_id, "method": rpc_method, "request": adapter_audit_request_summary(rpc_payload), "status": result.get("status") if isinstance(result, dict) else None, "error": result.get("error") if isinstance(result, dict) else None, "duration_ms": int((time.time() - started) * 1000), "result_duration_ms": result.get("duration_ms") if isinstance(result, dict) else None, "result_summary": adapter_audit_result_summary(result), }) self.write_json(200, result) elif parsed.path in HTTP_POST_METHOD_ROUTES: self.write_json(200, call_method(HTTP_POST_METHOD_ROUTES[parsed.path], payload)) else: self.write_json(404, {"error": "not_found", "path": parsed.path}) except ValueError as exc: self.write_json(400, {"error": "invalid_config", "message": str(exc)}) except Exception as exc: self.handle_error(exc) def do_PUT(self) -> None: try: parsed = urllib.parse.urlparse(self.path) if not self.require_authorization(parsed.path): return parts = parsed.path.strip("/").split("/") if len(parts) != 4 or parts[:3] != ["admin", "api", "bases"]: self.write_json(404, {"error": "not_found"}) return old_id = urllib.parse.unquote(parts[3]) payload = self.read_json() config = sql_admin_load() if old_id not in config: self.write_json(404, {"error": "base_not_found"}) return base_id, item = sql_admin_validate(payload) old = config[old_id] if isinstance(config[old_id], dict) else {} if not item.get("password") and not item.get("password_env"): if old.get("password_env"): item["password_env"] = old["password_env"] elif old.get("password"): item["password"] = old["password"] if "repository" not in item and isinstance(old.get("repository"), dict): item["repository"] = old["repository"] if base_id != old_id and base_id in config: self.write_json(409, {"error": "base_exists", "message": "База с таким base_id уже существует."}) return del config[old_id] config[base_id] = item sql_admin_save(config) self.write_json(200, {"base": sql_admin_public({base_id: item})[0]}) except ValueError as exc: self.write_json(400, {"error": "invalid_config", "message": str(exc)}) except Exception as exc: self.handle_error(exc) def do_DELETE(self) -> None: try: parsed = urllib.parse.urlparse(self.path) if not self.require_authorization(parsed.path): return parts = parsed.path.strip("/").split("/") if len(parts) != 4 or parts[:3] != ["admin", "api", "bases"]: self.write_json(404, {"error": "not_found"}) return base_id = urllib.parse.unquote(parts[3]) config = sql_admin_load() if base_id not in config: self.write_json(404, {"error": "base_not_found"}) return del config[base_id] sql_admin_save(config) self.write_json(200, {"status": "deleted", "base_id": base_id}) except ValueError as exc: self.write_json(400, {"error": "invalid_config", "message": str(exc)}) except Exception as exc: self.handle_error(exc) def main() -> int: parser = argparse.ArgumentParser(description="Serve a read-first 1C REST adapter.") parser.add_argument("--host", default=os.environ.get("ONEC_ADAPTER_HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(os.environ.get("ONEC_ADAPTER_PORT", "8011"))) args = parser.parse_args() global STATE STATE = AdapterState() httpd = GracefulThreadingHTTPServer((args.host, args.port), Handler) shutdown_requested = threading.Event() def begin_graceful_shutdown(signum: int, _frame: Any) -> None: if shutdown_requested.is_set(): return shutdown_requested.set() with ADAPTER_RUNTIME_LOCK: ADAPTER_RUNTIME["draining"] = True adapter_audit_event({ "event": "adapter_lifecycle", "time": datetime.now(timezone.utc).isoformat(), "phase": "draining", "signal": signal.Signals(signum).name, }) # shutdown must run outside the serve_forever thread. threading.Thread(target=httpd.shutdown, name="adapter-graceful-shutdown", daemon=True).start() signal.signal(signal.SIGTERM, begin_graceful_shutdown) signal.signal(signal.SIGINT, begin_graceful_shutdown) print(json.dumps({"event": "started", "host": args.host, "port": args.port}, ensure_ascii=False), flush=True) try: httpd.serve_forever() finally: # The graceful server waits for non-daemon request threads here, which # lets an in-flight verified write finish before Docker stops it. httpd.server_close() adapter_audit_event({ "event": "adapter_lifecycle", "time": datetime.now(timezone.utc).isoformat(), "phase": "stopped", }) return 0 if __name__ == "__main__": raise SystemExit(main())