Initial project import
This commit is contained in:
@@ -10,6 +10,7 @@ RUN pip install --no-cache-dir pyyaml==6.0.2
|
||||
COPY scripts /app/scripts
|
||||
COPY plugins/1c /app/plugins/1c
|
||||
COPY config /app/config
|
||||
COPY core /app/core
|
||||
COPY registry /app/registry
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -1003,6 +1003,12 @@ ADAPTER_BASE_ID_REQUIRED_PREFIXES = (
|
||||
"storage.",
|
||||
"templates.",
|
||||
)
|
||||
AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS = {
|
||||
"table", "file_name", "file_names", "module_ref", "module_id", "stream_index",
|
||||
"bsl_offset", "cas_key", "storage_key", "include_storage", "guid", "object_guid",
|
||||
"form_guid", "extension_guid",
|
||||
}
|
||||
AGENT_CONFIGURATION_METHOD_PREFIXES = ("metadata.", "modules.", "code.", "templates.", "extension.")
|
||||
|
||||
|
||||
def adapter_method_requires_base_id(method: str) -> bool:
|
||||
@@ -1018,29 +1024,99 @@ def validate_adapter_call(method: str, params: dict[str, Any] | None) -> None:
|
||||
if adapter_method_requires_base_id(method) and not str(params.get("base_id") or "").strip():
|
||||
raise ValueError(f"adapter method {method} requires params.base_id")
|
||||
|
||||
|
||||
def agent_technical_selector_fields(value: Any) -> list[str]:
|
||||
"""Reject storage coordinates even when a caller nests them in JSON."""
|
||||
found: set[str] = set()
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
if key in AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS:
|
||||
found.add(key)
|
||||
found.update(agent_technical_selector_fields(nested))
|
||||
elif isinstance(value, list):
|
||||
for nested in value:
|
||||
found.update(agent_technical_selector_fields(nested))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def prepare_agent_adapter_call(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep the agent on public metadata selectors rather than SQL routes."""
|
||||
prepared = dict(params)
|
||||
diagnostic_allowed = str(os.environ.get("ONEC_AGENT_ALLOW_DIAGNOSTIC") or "").strip().casefold() in {"1", "true", "yes", "on"}
|
||||
technical = agent_technical_selector_fields(prepared)
|
||||
if technical and not diagnostic_allowed:
|
||||
raise ValueError(
|
||||
"agent adapter calls require public names/selectors; forbidden technical fields: " + ", ".join(technical)
|
||||
)
|
||||
if method.startswith(AGENT_CONFIGURATION_METHOD_PREFIXES):
|
||||
prepared.setdefault("configuration_view", "effective_working")
|
||||
prepared.setdefault("source_state", "working")
|
||||
return prepared
|
||||
|
||||
def call_adapter(method: str, params: dict[str, Any] | None, *, base_url: str | None = None) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
"""Call the adapter through its public MCP boundary, never its SQL REST surface."""
|
||||
params = prepare_agent_adapter_call(method, params or {})
|
||||
validate_adapter_call(method, params)
|
||||
adapter_url = normalize_base_url(base_url or os.environ.get("ONEC_ADAPTER_URL", "http://docker-gpu.cin.su:8011"))
|
||||
headers = {"Content-Type": "application/json"}
|
||||
token = os.environ.get("ONEC_ADAPTER_TOKEN", "").strip()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(
|
||||
f"{adapter_url}/rpc",
|
||||
data=json.dumps({"method": method, "payload": params}, ensure_ascii=False).encode("utf-8"),
|
||||
mcp_url = normalize_base_url(base_url or os.environ.get("ONEC_MCP_URL", "http://docker.cin.su:8021"))
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
|
||||
initialize = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"onec-agent-init-{uuid.uuid4().hex}",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "onec-agent", "version": "1"},
|
||||
},
|
||||
}
|
||||
init_request = Request(
|
||||
f"{mcp_url}/mcp",
|
||||
data=json.dumps(initialize, ensure_ascii=False).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(init_request, timeout=30) as response:
|
||||
init_raw = json.loads(response.read().decode("utf-8"))
|
||||
session_id = response.headers.get("Mcp-Session-Id")
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"MCP initialize returned HTTP {exc.code}: {body}") from exc
|
||||
if not isinstance(init_raw, dict) or not isinstance(init_raw.get("result"), dict):
|
||||
raise ValueError("MCP initialize response is not JSON-RPC success")
|
||||
call_headers = dict(headers)
|
||||
if session_id:
|
||||
call_headers["Mcp-Session-Id"] = session_id
|
||||
call = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"onec-agent-call-{uuid.uuid4().hex}",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "onec_request", "arguments": {"method": method, "payload": params}},
|
||||
}
|
||||
request = Request(
|
||||
f"{mcp_url}/mcp",
|
||||
data=json.dumps(call, ensure_ascii=False).encode("utf-8"),
|
||||
headers=call_headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=120) as response:
|
||||
raw = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValueError(f"adapter returned HTTP {exc.code}: {body}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("adapter response is not JSON")
|
||||
return raw
|
||||
raise ValueError(f"MCP tool call returned HTTP {exc.code}: {body}") from exc
|
||||
result = raw.get("result") if isinstance(raw, dict) and isinstance(raw.get("result"), dict) else None
|
||||
content = result.get("content") if isinstance(result, dict) and isinstance(result.get("content"), list) else []
|
||||
text = content[0].get("text") if content and isinstance(content[0], dict) else None
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("MCP tool response has no JSON text content")
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("MCP tool response text is not JSON") from exc
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError("MCP tool response payload is not an object")
|
||||
return decoded
|
||||
|
||||
|
||||
class AgentHandler(BaseHTTPRequestHandler):
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Development oracle for additional requisites
|
||||
|
||||
`additional_attributes_readonly.bsl` is server-side BSL intended only for the
|
||||
isolated `upo_test` development configuration. It is an oracle for comparing
|
||||
the platform result with the SQL adapter result; it is never a dependency of
|
||||
the adapter, including in `upo_test`. It does not write application data or
|
||||
configuration metadata. `additional_attributes_http_handler.bsl` is an
|
||||
optional URL-template handler for manual development checks.
|
||||
|
||||
The service must expose two authenticated read-only operations:
|
||||
|
||||
- `additional_attributes.find` → `НайтиДополнительныеРеквизиты`;
|
||||
- `additional_attributes.storage.resolve` → `МаршрутЗначенийДополнительногоРеквизита`.
|
||||
|
||||
The HTTP wrapper must accept/return JSON and restrict calls to the test
|
||||
network. It must pass only `query`, `include_deleted`, a property UUID, and
|
||||
the public owner selector. Do not accept arbitrary BSL or query text.
|
||||
|
||||
Use the result only to create SQL adapter fixtures and verify its semantic
|
||||
mapping. The adapter itself must not call this endpoint, read its URL, or
|
||||
depend on a 1C runtime connection. A missing physical source in `upo_test`
|
||||
must therefore produce explicit SQL diagnostics, never a platform fallback.
|
||||
|
||||
Before publishing:
|
||||
|
||||
1. Add both modules to a test-only extension or HTTP service in Designer and
|
||||
bind `ОбработатьRPC` to `POST /rpc`.
|
||||
2. Restrict the service to read-only calls and test-network access.
|
||||
3. Create or identify a non-deleted test property `Ответственное направление`.
|
||||
4. Verify that the service returns its UUID, formula identifier, property set,
|
||||
and value type; then verify the storage-route response for
|
||||
`Справочник.СтруктурныеЕдиницы`.
|
||||
@@ -0,0 +1,60 @@
|
||||
// Обработчик URL-шаблона HTTP-сервиса, например POST /runtime-bridge/rpc.
|
||||
// Требует общий серверный модуль ДополнительныеРеквизитыReadOnly
|
||||
// (additional_attributes_readonly.bsl) в составе тестового расширения.
|
||||
|
||||
Функция ОбработатьRPC(Запрос) Экспорт
|
||||
Попытка
|
||||
ДанныеЗапроса = ПрочитатьJSONИзСтроки(Запрос.ПолучитьТелоКакСтроку());
|
||||
Метод = ДанныеЗапроса.method;
|
||||
Параметры = ДанныеЗапроса.payload;
|
||||
Если Метод = "additional_attributes.find" Тогда
|
||||
ТекстПоиска = ПолучитьПараметр(Параметры, "query", "");
|
||||
ВключатьУдаленные = ПолучитьПараметр(Параметры, "include_deleted", Ложь);
|
||||
Результат = Новый Структура("status,properties", "found", ДополнительныеРеквизитыReadOnly.НайтиДополнительныеРеквизиты(ТекстПоиска, ВключатьУдаленные));
|
||||
Если Результат.properties.Количество() = 0 Тогда
|
||||
Результат.status = "not_found";
|
||||
КонецЕсли;
|
||||
ИначеЕсли Метод = "additional_attributes.storage.resolve" Тогда
|
||||
СсылкаСвойства = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.ПолучитьСсылку(Новый УникальныйИдентификатор(Параметры.property_ref));
|
||||
Маршрут = ДополнительныеРеквизитыReadOnly.МаршрутЗначенийДополнительногоРеквизита(СсылкаСвойства, Параметры.owner_ref);
|
||||
Результат = Новый Структура("status,property_ref,owner_ref,storage,scd_join", "confirmed", Параметры.property_ref, Параметры.owner_ref,
|
||||
Новый Структура("source_ref,source_kind,fields", Маршрут.source, "TabularSection", Новый Структура("object,property,value", Маршрут.object_field, Маршрут.property_field, Маршрут.value_field)),
|
||||
Новый Структура("source,alias,condition,value_expression,parameters", Маршрут.source, "ДополнительныеРеквизиты", Маршрут.query_join, "ДополнительныеРеквизиты.Значение", Новый Структура("Свойство", Маршрут.parameter)));
|
||||
Иначе
|
||||
Возврат ОтветJSON(405, Новый Структура("status,error", "invalid_argument", "Unsupported read-only bridge method."));
|
||||
КонецЕсли;
|
||||
Возврат ОтветJSON(200, Результат);
|
||||
Исключение
|
||||
// Не передаем внутренний стек и сведения о подключении.
|
||||
Возврат ОтветJSON(400, Новый Структура("status,error", "error", "Invalid read-only bridge request."));
|
||||
КонецПопытки;
|
||||
КонецФункции
|
||||
|
||||
Функция ПолучитьПараметр(СтруктураПараметров, Имя, ЗначениеПоУмолчанию) Экспорт
|
||||
Значение = ЗначениеПоУмолчанию;
|
||||
Если СтруктураПараметров.Свойство(Имя, Значение) Тогда
|
||||
Возврат Значение;
|
||||
КонецЕсли;
|
||||
Возврат ЗначениеПоУмолчанию;
|
||||
КонецФункции
|
||||
|
||||
Функция ПрочитатьJSONИзСтроки(ТекстJSON) Экспорт
|
||||
ЧтениеJSON = Новый ЧтениеJSON;
|
||||
ЧтениеJSON.УстановитьСтроку(ТекстJSON);
|
||||
Попытка
|
||||
Возврат ПрочитатьJSON(ЧтениеJSON);
|
||||
Наконец
|
||||
ЧтениеJSON.Закрыть();
|
||||
КонецПопытки;
|
||||
КонецФункции
|
||||
|
||||
Функция ОтветJSON(КодСостояния, Данные) Экспорт
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Данные);
|
||||
ТекстJSON = ЗаписьJSON.Закрыть();
|
||||
Ответ = Новый HTTPСервисОтвет(КодСостояния);
|
||||
Ответ.УстановитьТелоИзСтроки(ТекстJSON, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
|
||||
Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
|
||||
Возврат Ответ;
|
||||
КонецФункции
|
||||
@@ -0,0 +1,85 @@
|
||||
// Общий модуль серверного HTTP-сервиса. Все экспортные методы только читают данные.
|
||||
// Модуль предназначен для публикации в тестовой конфигурации, а не для выполнения
|
||||
// из SQL-адаптера. Аутентификацию и разбор HTTP-запроса реализует модуль сервиса.
|
||||
|
||||
Функция НайтиДополнительныеРеквизиты(ТекстПоиска = "", ВключатьПомеченныеНаУдаление = Ложь) Экспорт
|
||||
|
||||
Результат = Новый Массив;
|
||||
Выборка = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.Выбрать();
|
||||
Пока Выборка.Следующий() Цикл
|
||||
Если Не ВключатьПомеченныеНаУдаление И Выборка.ПометкаУдаления Тогда
|
||||
Продолжить;
|
||||
КонецЕсли;
|
||||
Если ЗначениеЗаполнено(ТекстПоиска)
|
||||
И СтрНайти(НРег(Выборка.Наименование), НРег(ТекстПоиска)) = 0 Тогда
|
||||
Продолжить;
|
||||
КонецЕсли;
|
||||
|
||||
ОбъектСвойства = Выборка.ПолучитьОбъект();
|
||||
СтрокаСвойства = Новый Структура;
|
||||
СтрокаСвойства.Вставить("ref", Строка(Выборка.Ссылка.УникальныйИдентификатор()));
|
||||
СтрокаСвойства.Вставить("description", Выборка.Наименование);
|
||||
СтрокаСвойства.Вставить("marked_for_deletion", Выборка.ПометкаУдаления);
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "Имя", "name");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ИдентификаторДляФормул", "identifier_for_formula");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "НаборСвойств", "property_set");
|
||||
ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ТипЗначения", "value_type");
|
||||
// Отдельно фиксируем наличие реквизита в объекте ПВХ. Это позволяет
|
||||
// SQL-разработке отличить пустое значение от отсутствующей семантики.
|
||||
СтрокаСвойства.Вставить("semantic_fields", Новый Структура(
|
||||
"name,identifier_for_formula,property_set,value_type",
|
||||
СтрокаСвойства.Свойство("name"),
|
||||
СтрокаСвойства.Свойство("identifier_for_formula"),
|
||||
СтрокаСвойства.Свойство("property_set"),
|
||||
СтрокаСвойства.Свойство("value_type")));
|
||||
Результат.Добавить(СтрокаСвойства);
|
||||
КонецЦикла;
|
||||
|
||||
Возврат Результат;
|
||||
КонецФункции
|
||||
|
||||
Функция МаршрутЗначенийДополнительногоРеквизита(Свойство, ВладелецМетаданных) Экспорт
|
||||
|
||||
Если ТипЗнч(Свойство) <> Тип("ПланВидовХарактеристикСсылка.ДополнительныеРеквизитыИСведения") Тогда
|
||||
ВызватьИсключение "Свойство должно быть ссылкой ПВХ ДополнительныеРеквизитыИСведения.";
|
||||
КонецЕсли;
|
||||
Если ВладелецМетаданных <> "Справочник.СтруктурныеЕдиницы" Тогда
|
||||
ВызватьИсключение "Маршрут подтвержден только для Справочник.СтруктурныеЕдиницы.";
|
||||
КонецЕсли;
|
||||
|
||||
// Текст предназначен для СКД и не исполняется сервисом. В этой
|
||||
// конфигурации значения подтверждённо находятся в табличной части владельца.
|
||||
Возврат Новый Структура(
|
||||
"source,object_field,property_field,value_field,query_join,parameter",
|
||||
"Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты",
|
||||
"Ссылка",
|
||||
"Свойство",
|
||||
"Значение",
|
||||
"ЛЕВОЕ СОЕДИНЕНИЕ Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты КАК ДополнительныеРеквизиты "
|
||||
+ "ПО ДополнительныеРеквизиты.Ссылка = СтруктурныеЕдиницы.Ссылка "
|
||||
+ "И ДополнительныеРеквизиты.Свойство = &Свойство",
|
||||
Строка(Свойство.УникальныйИдентификатор())
|
||||
);
|
||||
КонецФункции
|
||||
|
||||
Процедура ДобавитьСвойствоЕслиЕсть(Приемник, Источник, ИмяСвойства, ИмяПоля) Экспорт
|
||||
ЗначениеСвойства = Неопределено;
|
||||
Если Источник.Свойство(ИмяСвойства, ЗначениеСвойства) Тогда
|
||||
Приемник.Вставить(ИмяПоля, ПредставлениеДляJSON(ЗначениеСвойства));
|
||||
КонецЕсли;
|
||||
КонецПроцедуры
|
||||
|
||||
Функция ПредставлениеДляJSON(ЗначениеСвойства) Экспорт
|
||||
Если ЗначениеСвойства = Неопределено Тогда
|
||||
Возврат Неопределено;
|
||||
КонецЕсли;
|
||||
Если ТипЗнч(ЗначениеСвойства) = Тип("Структура") Или ТипЗнч(ЗначениеСвойства) = Тип("Массив") Тогда
|
||||
Возврат ЗначениеСвойства;
|
||||
КонецЕсли;
|
||||
Попытка
|
||||
УникальныйИдентификатор = ЗначениеСвойства.УникальныйИдентификатор();
|
||||
Возврат Новый Структура("ref,presentation", Строка(УникальныйИдентификатор), Строка(ЗначениеСвойства));
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
Возврат Строка(ЗначениеСвойства);
|
||||
КонецФункции
|
||||
@@ -33,8 +33,15 @@ ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
|
||||
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
|
||||
# Legacy JSON job store is read once for migration only.
|
||||
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
|
||||
# Repository requests, confirmations, sessions, and audit are stored in
|
||||
# ONEC_ADAPTER_STATE_DB. This legacy JSON is imported once and never updated.
|
||||
ONEC_REPOSITORY_STATE_FILE=/data/onec-repository-locks.json
|
||||
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
|
||||
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
|
||||
# Activation requests/events are stored in ONEC_ADAPTER_STATE_DB.
|
||||
# Legacy JSON is read once for migration only and is never updated afterwards.
|
||||
ONEC_CONFIGURATION_ACTIVATION_STATE_FILE=/data/onec-configuration-activation-requests.json
|
||||
ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS=1800
|
||||
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
|
||||
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
|
||||
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
|
||||
@@ -43,3 +50,7 @@ ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
|
||||
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
|
||||
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
|
||||
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
|
||||
# Stack traces are hidden from REST/MCP clients unless these test/debug flags
|
||||
# are explicitly enabled.
|
||||
ONEC_ADAPTER_DEBUG_DIAGNOSTICS=false
|
||||
ONEC_MCP_DEBUG_DIAGNOSTICS=false
|
||||
|
||||
@@ -3,7 +3,9 @@ FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir pymssql==2.3.2
|
||||
COPY connector/adapter_1c_server.py /app/adapter_1c_server.py
|
||||
COPY connector/analyze_audit.py /app/analyze_audit.py
|
||||
COPY connector/repository_control.py /app/repository_control.py
|
||||
COPY connector/write /app/write
|
||||
COPY connector/admin /app/admin
|
||||
COPY parser /app/parser
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ The connector is read-first and optimized for an operational coding loop where f
|
||||
|
||||
Preferred live architecture:
|
||||
|
||||
- read-only SQL connector for fast diagnostics and data samples;
|
||||
- lightweight 1C agent for metadata, forms, commands, and BSL modules;
|
||||
- SQL-only connector for diagnostics, metadata decoding, and controlled
|
||||
saved-state work in an explicitly authorised test base;
|
||||
- a human-operated Configurator for viewing and applying pending changes;
|
||||
- cached metadata/module snapshots with freshness checks;
|
||||
- change proposals as reviewable artifacts, not direct production writes.
|
||||
|
||||
@@ -17,7 +18,12 @@ The connector is responsible for:
|
||||
- BSL module search/read;
|
||||
- read-only query validation and execution;
|
||||
- metadata/module snapshots;
|
||||
- change proposals without direct apply.
|
||||
- change proposals and, only where a reverse codec is activation-proven,
|
||||
controlled `ConfigSave`/`ConfigCASSave` writes with rollback evidence.
|
||||
|
||||
The adapter never writes `Config`, `ConfigCAS`, or application data directly.
|
||||
It does not automate Configurator and must not invent unknown 1C structures.
|
||||
The protocol evidence base is [docs/1c-sql-protocol](../../../docs/1c-sql-protocol/README.md).
|
||||
|
||||
Contracts:
|
||||
|
||||
@@ -122,6 +128,57 @@ adapter-owned lock session is supplied. Structural add/delete/rename plans are
|
||||
kept blocked for confirmation because parent and reference objects can also be
|
||||
required.
|
||||
|
||||
## Configuration activation debug workflow
|
||||
|
||||
Activation is a separate boundary from saved-state writes and repository
|
||||
coordination. The current workflow is intentionally debug-only:
|
||||
|
||||
1. `configuration.activation.status`;
|
||||
2. `configuration.activation.plan`;
|
||||
3. `configuration.activation.request`;
|
||||
4. forward the returned request id to `configuration.activation.execute` with
|
||||
`mode=debug` and `confirm_activation=true`;
|
||||
5. inspect or cancel the request through
|
||||
`configuration.activation.request.status`,
|
||||
`configuration.activation.request.cancel`, and
|
||||
`configuration.activation.audit`.
|
||||
|
||||
The request is bound to a live-SQL fingerprint and is rejected when pending
|
||||
files change or the request expires. Requests and events are stored in the
|
||||
adapter-local SQLite selected by `ONEC_ADAPTER_STATE_DB`; they contain no
|
||||
payload bytes or credentials. `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is a
|
||||
one-time legacy JSON import source only. `configuration.activation.capabilities`
|
||||
reports runner readiness without returning paths, URLs, selectors, users,
|
||||
passwords, or tokens.
|
||||
`configuration.activation.bridge.probe` can then check the local runner or the
|
||||
authenticated HTTP runner endpoint `/configuration/activation/debug`. The
|
||||
probe verifies only Designer-file availability and infobase-selector presence;
|
||||
it never starts a process.
|
||||
Pass `bridge_debug=true` to `configuration.activation.execute` when the runner
|
||||
must also acknowledge the exact request id and live-SQL fingerprint. The runner
|
||||
returns an opaque SHA-256 debug receipt; mismatched or missing receipts block
|
||||
the request, while a valid receipt adds a `bridge_debug_accepted` audit event.
|
||||
After a manual F7, call `configuration.activation.verify` with the same request
|
||||
id. It reports `not_activated`, `changed_since_request`, or
|
||||
`verified_up_to_date` from a fresh SQL comparison. The last status proves
|
||||
saved/active alignment, not the historical fact that Designer performed the
|
||||
activation.
|
||||
|
||||
Real Designer execution remains disabled. `/UpdateDBCfg` is recorded only as
|
||||
the documented future base-configuration operation. Extension activation stays
|
||||
manual until a separately verified platform command and post-activation check
|
||||
are implemented.
|
||||
|
||||
Activation request mutations use SQLite `BEGIN IMMEDIATE` transactions, so
|
||||
concurrent adapter processes cannot overwrite each other's request/event
|
||||
updates. Saved-state backup retention is explicit:
|
||||
`storage.saved_state.backups.prune` defaults to a dry run, is scoped by
|
||||
`base_id`, preserves the newest requested count, and requires
|
||||
`confirm_delete=true` before deleting adapter-local backup files. Backups
|
||||
referenced by `metadata.write.history` are always protected; when write-history
|
||||
availability cannot be verified, affected backup files are protected
|
||||
fail-closed.
|
||||
|
||||
## Docker Run
|
||||
|
||||
Create a local `.env` from `.env.example`, keep real passwords outside git, and
|
||||
@@ -237,6 +294,8 @@ Current live methods:
|
||||
- `metadata.route.resolve`
|
||||
- `metadata.form.decode`
|
||||
- `metadata.object.attributes`
|
||||
- `metadata.relationship.verify`
|
||||
- `metadata.relationship.find`
|
||||
- `metadata.object.full`
|
||||
- `metadata.snapshot`
|
||||
- `codec.decode`
|
||||
@@ -287,6 +346,9 @@ Agent-facing code write rule:
|
||||
public path such as `<extension>.<form>.<routine>` plus full code text.
|
||||
- `code.write` automatically targets the saved-state layer and reports
|
||||
`write_mode.target=saved_state` with `activation_state=not_activated`.
|
||||
- Write plans for embedded form-container modules return a ready
|
||||
`code.write` hint; they do not incorrectly request a nonexistent
|
||||
`#stream:<index>`.
|
||||
- Use `code.read`/`code.search` with the default working state for current
|
||||
programming-time code; use `state=both` only when an explicit saved vs active
|
||||
comparison is needed.
|
||||
@@ -296,6 +358,10 @@ without physical SQL/storage traces by default. `metadata.object.decode` also
|
||||
returns a 1C-facing decoded object profile by default; pass
|
||||
`include_storage=true` only when adapter diagnostics need the underlying decoded
|
||||
payload metadata, record containers, or DBNames/storage routes.
|
||||
Exact extension objects use the same public `kind` + `name`/`ref` selectors as
|
||||
base objects. `metadata.object.modules` includes owned form modules and returns
|
||||
qualified names such as
|
||||
`test2.Форма.t_Форма.Модуль формы`; extension GUIDs and CAS keys remain internal.
|
||||
|
||||
`metadata.object.properties` is the unified property endpoint for every 1C
|
||||
metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
|
||||
@@ -348,6 +414,32 @@ inspection for user-facing answers. The object can be selected by `guid`, by
|
||||
`kind` + `name`, or by 1-based `ordinal` within `metadata.objects.list` for that
|
||||
kind.
|
||||
|
||||
For a safe answer to "are these objects linked?", do not infer a link from a
|
||||
similar field name, BSL mention, or a runtime value. Use
|
||||
`metadata.relationship.verify` with an exact source `member` and optional
|
||||
`target_ref`. It returns `confirmed` only when that member's declared 1C type
|
||||
explicitly names the target object; otherwise it returns `not_confirmed` or an
|
||||
explicitly ambiguous result. To discover a direct typed field without knowing
|
||||
its name, call `metadata.relationship.find` with public refs only:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "metadata.relationship.find",
|
||||
"payload": {
|
||||
"base_id": "upo_test",
|
||||
"ref": "Document.СписаниеЗапасов",
|
||||
"target_ref": "Document.РасходнаяНакладная",
|
||||
"direction": "either",
|
||||
"execution_mode": "job"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`direction=either` checks both objects for explicitly declared references and
|
||||
returns the direction of every confirmed edge. A `not_found` result means that
|
||||
no direct declared metadata reference was found; it does not prove that an
|
||||
indirect BSL, query, form, or business-process relationship is absent.
|
||||
|
||||
`metadata.object.full` is the preferred high-level method for agent answers like
|
||||
"show everything about this document". It combines the live object card,
|
||||
semantic sections, decoded forms, BSL module profiles, and counts in one
|
||||
@@ -488,7 +580,33 @@ unless `include_storage=true`.
|
||||
|
||||
`code.search` is the agent-facing wrapper over module search. Its items include
|
||||
`read_selector.method: "code.read"` and preserve `module_ref` when that is the
|
||||
best available safe handle. `code.read` can consume that selector directly.
|
||||
best available safe handle. `code.read` can consume that selector directly;
|
||||
the selector pins the configuration view that produced the hit. A storage
|
||||
stream whose Configurator-tree role is not independently decoded is returned
|
||||
as `bsl_module` with `role_status=unconfirmed` and must not be treated as a
|
||||
command, manager, or a tree path.
|
||||
|
||||
`metadata.object.commands` resolves an `extension` name to the active
|
||||
extension internally before it reads the selected object. A caller provides
|
||||
only the public object and extension selectors; it must not replace them with
|
||||
a base-configuration route or infer a command from a BSL stream suffix. A
|
||||
successful empty command list is the only evidence currently returned for “no
|
||||
decoded commands”; an unresolved object route is reported separately.
|
||||
For object-owned extension forms, `modules.search` and `code.search` resolve
|
||||
the form module from the public owner reference. In the default
|
||||
`state=working` view they inspect the saved counterpart first and fall back to
|
||||
the active module only when needed; `state=active` never returns saved-only
|
||||
text. Saved matches carry `activation_state=saved_state` and
|
||||
`current_state.activation_state=not_activated`.
|
||||
For an active extension form selector, `code.read state=both` resolves the
|
||||
saved form by logical owner/form identity, even when active and saved CAS file
|
||||
names differ, and reports live text SHA1 comparison evidence.
|
||||
|
||||
`metadata.resolve_overrides` uses the same name-first form ownership and
|
||||
saved-first working-state rules. A public selector such as `Catalog.test2`
|
||||
therefore resolves routines located in forms owned by that extension object;
|
||||
the returned chain identifies the form and activation state without exposing
|
||||
the object's physical SQL route.
|
||||
|
||||
`metadata.definition.find` accepts public object references such as
|
||||
`Обработка.<Name>` or `Document.<Name>` in `query` and the common object
|
||||
@@ -553,3 +671,49 @@ be proven, the adapter must re-read live SQL or return an explicit stale-cache
|
||||
error.
|
||||
|
||||
Operational runbook: `docs/runbooks/1c-operational-coding.md`.
|
||||
|
||||
## Development audit telemetry
|
||||
|
||||
Every REST `/rpc` call produces a privacy-safe JSONL event in
|
||||
`/data/adapter-audit.jsonl`. It contains the UTC time, correlation id, public
|
||||
method and selector summary, result status/error, duration, public route and
|
||||
resolver timings/counts (when a write route is involved), and exception type
|
||||
when the request itself fails. A `public_write_route_unresolved` event retains
|
||||
the safe resolver status/error/candidate count so it can be diagnosed without
|
||||
asking a caller for a module handle. It deliberately excludes BSL text, SQL
|
||||
payloads, physical file names, stream indexes, credentials, and SQL connection
|
||||
details. The MCP proxy forwards its generated
|
||||
request id in `X-Request-ID`, so an agent response can be correlated with the
|
||||
REST record. The log is shared by all configured
|
||||
`base_id` values so cross-base failures and slow calls can be compared.
|
||||
|
||||
For development, the default retention is deliberately generous: 50 MiB per
|
||||
file and ten retained files. Configure `ONEC_ADAPTER_AUDIT_MAX_BYTES` and
|
||||
`ONEC_ADAPTER_AUDIT_KEEP_FILES` to change it. Rotation is best-effort and can
|
||||
never fail an adapter request. A caller may supply an `X-Request-ID` header to
|
||||
correlate a client event with the REST record.
|
||||
|
||||
The `adapter-1c-audit` Compose service writes an aggregate report every 15
|
||||
minutes to `/data/adapter-audit-reports/latest.json`; set
|
||||
`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS` to alter the interval. It reports base
|
||||
distribution, failures, slow operations, malformed rows, and recent failures.
|
||||
For an immediate manual report, run `python scripts/analyze_1c_adapter_audit.py`
|
||||
against a copied log or `python /app/analyze_audit.py` inside the REST image.
|
||||
The MCP proxy has its own persistent `/data/mcp-audit.jsonl` and periodic
|
||||
summary: it records failures that happen before a request reaches REST.
|
||||
|
||||
For an extension-wide `code.search` without a concrete object selector,
|
||||
`timeout_seconds` is a total search budget. If owner-route discovery consumes
|
||||
that budget, the adapter returns `status=partial` with
|
||||
`diagnostics.code=time_budget_exhausted`; it does not continue serial owner
|
||||
probes in the background. Narrow routine work with `ref` or `kind`/`name`.
|
||||
|
||||
REST deployments use a five-minute Docker stop grace period. On `SIGTERM` the
|
||||
adapter stops accepting new work and waits for already-running request threads,
|
||||
including verified saved-state writes, to complete. Do not deploy the REST
|
||||
service while an operator is intentionally running a production-base write;
|
||||
the deployment prevents a half-response, but the client should still retry only
|
||||
after it receives a structured result.
|
||||
The deployment script also waits for `health.runtime.active_rpc_count=0` before
|
||||
recreating REST. `-SkipDrainCheck` is an emergency-only override and must not
|
||||
be used while a write is in progress.
|
||||
|
||||
+16271
-759
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
"""Summarize privacy-safe adapter JSONL telemetry inside the REST image."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--log", default="/data/adapter-audit.jsonl")
|
||||
parser.add_argument("--slow-ms", type=int, default=5_000)
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
path = Path(args.log)
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if item.get("event") == "adapter_rpc":
|
||||
rows.append(item)
|
||||
by_base = Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)
|
||||
exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"]
|
||||
rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}]
|
||||
slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True)
|
||||
print(json.dumps({
|
||||
"schema": "onec_adapter_audit_summary.v1",
|
||||
"status": "ok" if path.exists() else "log_not_found",
|
||||
"events": len(rows), "malformed_rows": malformed_rows,
|
||||
"time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None},
|
||||
"bases": dict(by_base), "adapter_exceptions": len(exceptions),
|
||||
"expected_rejections": len(rejected),
|
||||
"exception_methods": dict(Counter(str(row.get("method") or "<none>") for row in exceptions).most_common(args.limit)),
|
||||
"slow_threshold_ms": args.slow_ms,
|
||||
"slow": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in slow[:args.limit]],
|
||||
"recent_exceptions": [{"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} for row in exceptions[-args.limit:]],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []),
|
||||
*([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -54,6 +54,140 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
/configuration/activation/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationStatus
|
||||
summary: Read the live saved-state to active boundary
|
||||
description: Compares saved-state and active configuration layers without cache, vector search, Designer execution, or configuration mutation.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/plan:
|
||||
post:
|
||||
operationId: planConfigurationActivation
|
||||
summary: Build a read-only activation handoff plan
|
||||
description: Returns review and verification calls plus a manual Designer action when activation is required. It never starts Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequest"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request:
|
||||
post:
|
||||
operationId: createConfigurationActivationRequest
|
||||
summary: Create a fingerprinted activation request
|
||||
description: Persists an expiring adapter-local request bound to the exact live-SQL saved-state fingerprint. It does not start Designer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCreate"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/status:
|
||||
post:
|
||||
operationId: getConfigurationActivationRequest
|
||||
summary: Read activation request state
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestStatus"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/request/cancel:
|
||||
post:
|
||||
operationId: cancelConfigurationActivationRequest
|
||||
summary: Cancel one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationRequestCancel"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/audit:
|
||||
post:
|
||||
operationId: auditConfigurationActivationRequests
|
||||
summary: List activation request lifecycle events for one base
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationAudit"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/capabilities:
|
||||
post:
|
||||
operationId: getConfigurationActivationCapabilities
|
||||
summary: Read safe Designer bridge readiness
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationCapabilities"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/bridge/probe:
|
||||
post:
|
||||
operationId: probeConfigurationActivationBridge
|
||||
summary: Probe local or HTTP Designer runner readiness without execution
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationBridgeProbe"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/execute:
|
||||
post:
|
||||
operationId: debugConfigurationActivation
|
||||
summary: Accept a fingerprinted activation request in debug mode
|
||||
description: Revalidates the exact live-SQL fingerprint and records debug acceptance. Real Designer execution is unavailable.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationExecute"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/configuration/activation/verify:
|
||||
post:
|
||||
operationId: verifyConfigurationActivation
|
||||
summary: Verify saved/active alignment for one activation request
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConfigurationActivationVerify"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "#/components/responses/AdapterMethodResponse"
|
||||
/extensions:
|
||||
get:
|
||||
operationId: listExtensions
|
||||
@@ -258,7 +392,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -451,7 +585,7 @@ paths:
|
||||
type: string
|
||||
enum: [Params, Config, ConfigSave, ConfigCAS, ConfigCASSave]
|
||||
default: Config
|
||||
description: Storage table to read from. Use Config for active config, ConfigSave for saved config.
|
||||
description: Storage table to read from. Exact public extension selectors resolve to ConfigCAS internally; physical routes remain hidden unless include_storage=true.
|
||||
guid:
|
||||
type: string
|
||||
description: Config object GUID. If omitted, kind and name are used.
|
||||
@@ -461,7 +595,7 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Live BSL module stream ids for a metadata object.
|
||||
description: Public BSL modules owned by the selected object, including owned form modules. Exact extension objects are resolved from public kind/name or ref.
|
||||
/metadata/object/related:
|
||||
post:
|
||||
operationId: listMetadataObjectRelated
|
||||
@@ -958,6 +1092,11 @@ paths:
|
||||
ref:
|
||||
type: string
|
||||
description: Public object reference such as Справочник.Номенклатура.
|
||||
state:
|
||||
type: string
|
||||
enum: [working, active, save, both]
|
||||
default: working
|
||||
description: Working is saved-first with active fallback, including form modules owned by extension objects resolved from the public selector.
|
||||
responses:
|
||||
"200":
|
||||
description: Read-only routine override/action chain across base and extension modules.
|
||||
@@ -983,6 +1122,9 @@ paths:
|
||||
source:
|
||||
type: string
|
||||
enum: [configuration, extension]
|
||||
activation_state:
|
||||
type: string
|
||||
enum: [active, saved_state]
|
||||
method:
|
||||
type: string
|
||||
line_start:
|
||||
@@ -998,7 +1140,7 @@ paths:
|
||||
enum: [ok, unknown]
|
||||
operation_class:
|
||||
type: string
|
||||
description: base_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
description: base_definition, extension_definition, insert_before, insert_after, replace, replace_with_control, or unknown_extension_action.
|
||||
requires_control_fragment:
|
||||
type: boolean
|
||||
extension_actions:
|
||||
@@ -1208,6 +1350,20 @@ paths:
|
||||
module_ref:
|
||||
type: string
|
||||
description: Opaque module_ref from code.search/modules.search read_selector when already known.
|
||||
expected_sha1:
|
||||
type: string
|
||||
description: Optional container SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
expected_text_sha1:
|
||||
type: string
|
||||
description: Optional BSL text SHA1 precondition, normally forwarded from metadata.write.preflight write_context.
|
||||
repository_lock:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable repository.lock.confirm write_context.
|
||||
write_context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Forwardable context returned by metadata.write.preflight.
|
||||
routine_name:
|
||||
type: string
|
||||
routine_text:
|
||||
@@ -1483,6 +1639,26 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: routine_name
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Select one BSL procedure/function. Its text is compact by default.
|
||||
- name: include_routines
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the routine catalogue when a routine is selected.
|
||||
- name: include_summary
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Include the module summary when a routine is selected.
|
||||
responses:
|
||||
"200":
|
||||
description: BSL module content.
|
||||
@@ -1725,6 +1901,51 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: Saved-state apply backups list with source metadata and sha1/byte counts. Payload hex is not returned.
|
||||
/storage/saved-state/backups/prune:
|
||||
post:
|
||||
operationId: pruneSavedStateBackups
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [base_id]
|
||||
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 called through the generic MCP diagnostic policy.
|
||||
responses:
|
||||
"200":
|
||||
description: Dry-run selection or confirmed deletion of adapter-local saved-state backup files. Backups referenced by write history are always protected, with fail-closed protection when history cannot be verified.
|
||||
/access/graph:
|
||||
post:
|
||||
operationId: buildAccessGraph
|
||||
@@ -2246,6 +2467,156 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
schemas:
|
||||
ConfigurationActivationRequest:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
include_files:
|
||||
type: boolean
|
||||
default: false
|
||||
include_storage:
|
||||
type: boolean
|
||||
default: false
|
||||
ConfigurationActivationRequestCreate:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 86400
|
||||
default: 1800
|
||||
ConfigurationActivationRequestStatus:
|
||||
type: object
|
||||
required: [request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
ConfigurationActivationRequestCancel:
|
||||
type: object
|
||||
required: [request_id, confirm_cancel]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
confirm_cancel:
|
||||
type: boolean
|
||||
const: true
|
||||
ConfigurationActivationAudit:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
status:
|
||||
type: string
|
||||
ConfigurationActivationCapabilities:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
ConfigurationActivationBridgeProbe:
|
||||
type: object
|
||||
required: [base_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
layer:
|
||||
type: string
|
||||
enum: [all, base_saved_state, extension_saved_state]
|
||||
default: all
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 60
|
||||
default: 10
|
||||
ConfigurationActivationExecute:
|
||||
type: object
|
||||
required: [base_id, request_id, confirm_activation]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
const: debug
|
||||
default: debug
|
||||
confirm_activation:
|
||||
type: boolean
|
||||
const: true
|
||||
bridge_debug:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require an end-to-end local/HTTP runner debug receipt without starting Designer.
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
ConfigurationActivationVerify:
|
||||
type: object
|
||||
required: [base_id, request_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
base_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
timeout_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 30
|
||||
AdapterRpcRequest:
|
||||
type: object
|
||||
required: [method]
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Исследования SQL: активность расширений
|
||||
|
||||
Статус: `in_progress`. Этот журнал отделяет наблюдения от гипотез. Ничего из
|
||||
раздела «гипотеза» не используется адаптером как runtime-правило.
|
||||
|
||||
## 2026-08-02 — baseline `upo_test`
|
||||
|
||||
Цель: установить доказанный SQL-признак флажка «Активно» расширения в
|
||||
Конфигураторе.
|
||||
|
||||
Снимок выполнен только чтением из `dbo._ExtensionsInfo` для имён
|
||||
`фс_Отчеты` и `фс_Отчеты1`. Зафиксированы все известные скалярные поля строки
|
||||
и SHA1 `_ExtensionZippedInfo`; бинарные данные и учётные сведения не сохранены.
|
||||
|
||||
| name | `_IDRRef` (hex) | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0x8287005056B0D48311F13D089B11F844` | 21 | 4026-05-07 23:45:25 | 2 | 1 | 188 | `0D07CB6CE05AADB301A002CAB7312AD6EA64A344` |
|
||||
| `фс_Отчеты1` | *строка отсутствует* | — | — | — | — | — | — |
|
||||
|
||||
### Подтверждено
|
||||
|
||||
- Наличие строки в `_ExtensionsInfo` доказывает регистрацию расширения в
|
||||
данном SQL-снимке, но **не** доказывает флажок «Активно» в Конфигураторе.
|
||||
- Поэтому поле `active` адаптера для такого источника возвращается как
|
||||
`null`; прежнее значение `true` было неподтверждённым и удалено.
|
||||
|
||||
### Гипотеза, требующая проверки
|
||||
|
||||
После переключения флажков в Конфигураторе изменится одна или несколько
|
||||
наблюдаемых SQL-структур: строка `_ExtensionsInfo`, поля строки, DBNames-Ext,
|
||||
ConfigCAS/ConfigCASSave или иной live SQL-маркер.
|
||||
|
||||
### Следующий контролируемый опыт
|
||||
|
||||
1. Пользователь активирует `фс_Отчеты` и выключает `фс_Отчеты1` (либо наоборот)
|
||||
в Конфигураторе и сообщает, когда действие сохранено.
|
||||
2. Адаптер снимает тот же снимок `_ExtensionsInfo` и дополнительно сравнивает
|
||||
только подтверждённые live SQL-маркеры.
|
||||
3. Правило будет добавлено в runtime лишь если различие воспроизводится при
|
||||
обратном переключении и однозначно связано с активной композицией.
|
||||
|
||||
### Запрещённый вывод до опыта
|
||||
|
||||
Нельзя отбрасывать расширение из поиска только по факту его наличия или
|
||||
отсутствия в `_ExtensionsInfo`, по совпадающему GUID объекта либо по догадке
|
||||
из названия/порядка расширения.
|
||||
|
||||
## 2026-08-02 — baseline `upo` (текущий опыт)
|
||||
|
||||
Этот снимок является исходной точкой для переключения, которое пользователь
|
||||
будет выполнять в `upo`. Он не смешивается с наблюдением `upo_test` выше.
|
||||
|
||||
| name | `_IDRRef` (hex) | GUID из `_IDRRef` | `_ExtensionOrder` | `_UpdateTime` | `_ExtensionUsePurpose` | `_ExtensionScope` | zipped bytes | zipped SHA1 |
|
||||
| --- | --- | --- | ---: | --- | ---: | ---: | ---: | --- |
|
||||
| `фс_Отчеты` | `0xA0CF005056B59ABC11F13D592F0651F1` | `2f0651f1-3d59-11f1-a0cf-005056b59abc` | 20 | 2001-01-01 00:00:00 | 2 | 1 | 188 | `2718A3CC564F593799EFCF1E716305358AF804E8` |
|
||||
| `фс_Отчеты1` | `0x8294005056B0D48311F18A348E02ACCD` | `8e02accd-8a34-11f1-8294-005056b0d483` | 22 | 4026-08-01 18:51:55 | 2 | 1 | 188 | `A9A4AF42BBA2084141A122F9D7C39D7E1428CB21` |
|
||||
|
||||
На baseline присутствуют обе строки. Значит наличие в `_ExtensionsInfo` не
|
||||
может быть критерием активности: оно не отличает выключенное `фс_Отчеты` от
|
||||
включенного `фс_Отчеты1` на скриншоте пользователя.
|
||||
|
||||
## 2026-08-02 — подтверждённый декодер активности
|
||||
|
||||
Три независимых переключения флажка в Конфигураторе, сохранённые пользователем
|
||||
в `upo`, дали один и тот же результат. Не весь SHA1, а **третий байт с конца**
|
||||
`_ExtensionZippedInfo` меняется вместе с флажком:
|
||||
|
||||
| расширение | длина контейнера | состояние в UI | третий байт с конца |
|
||||
| --- | ---: | --- | --- |
|
||||
| `фс_Отчеты` | 188 | выключено → включено | `81` → `82` |
|
||||
| `фс_Отчеты1` | 188 | включено → выключено | `82` → `81` |
|
||||
| `ЭкстракторДанных1СВBI` | 215 | включено → выключено | `82` → `81` |
|
||||
|
||||
Другие изменения контейнера не являются маркером: например, байт около начала
|
||||
контейнера и весь SHA1 меняются при сохранении Конфигуратором.
|
||||
|
||||
### Runtime-правило (подтверждено для наблюдаемой версии)
|
||||
|
||||
`SUBSTRING(_ExtensionZippedInfo, DATALENGTH(_ExtensionZippedInfo) - 2, 1)`:
|
||||
|
||||
- `0x82` — расширение активно;
|
||||
- `0x81` — расширение выключено;
|
||||
- любое иное значение — `active: null`, `unresolved`.
|
||||
|
||||
Правило декодирует только активность и не интерпретирует остальные байты
|
||||
контейнера. Перед использованием для фильтрации глобального поиска требуется
|
||||
отдельный regression-тест, что выключенная extension route не попадает в
|
||||
`effective_working` code search/read.
|
||||
|
||||
## 2026-08-02 — расширенная матрица флажков
|
||||
|
||||
В Конфигураторе была показана полная таблица расширений с дополнительными
|
||||
флажками: безопасный режим, защита от опасных действий, использование в
|
||||
распределённой ИБ и «использовать основной режим». Их комбинации различаются
|
||||
между активными расширениями. Повторный read-only снимок `upo` дал:
|
||||
|
||||
- 14 расширений с UI-флажком «Активно» получили завершающий байт `82`;
|
||||
- `ЭкстракторДанных1СВBI` и `фс_Отчеты1` с выключенным «Активно» получили
|
||||
`81`;
|
||||
- среди активных строк есть разные состояния каждого показанного
|
||||
дополнительного флажка, но их завершающий байт всё равно `82`.
|
||||
|
||||
### Уточнённый вывод
|
||||
|
||||
`81` и `82` надо рассматривать как два **наблюдаемых кода состояния
|
||||
активности** в третьем байте с конца, а не как полную структуру битовых
|
||||
флажков расширения. Технически это может быть битовое поле, перечисление или
|
||||
маркер внутри более крупного протокола — формат этого байта пока не доказан.
|
||||
Для runtime достаточно точного соответствия `81`/`82`; никаких выводов о
|
||||
других флажках из него делать нельзя.
|
||||
|
||||
### Принятое правило адаптера
|
||||
|
||||
Рабочая композиция адаптера содержит только строки с `active: true` (`82`).
|
||||
По умолчанию неактивные и нераспознанные расширения:
|
||||
|
||||
- не выдаются методом `extensions.list`;
|
||||
- не участвуют в DBNames-Ext, ConfigCAS, manifest и cache-маршрутах;
|
||||
- не участвуют в глобальном поиске модулей и объектов;
|
||||
- не могут стать целью чтения или записи.
|
||||
|
||||
Явная попытка обратиться к известному выключенному расширению завершается
|
||||
`status: unavailable`, `error: extension_inactive`; адаптер не читает и не
|
||||
строит маршрут к его объектам. Это исключает неоднозначность одинакового GUID
|
||||
объекта в активном и выключенном расширениях.
|
||||
|
||||
Это правило распространяется и на технические селекторы: верхнеуровневый
|
||||
`extension_guid`, а также публичный `ConfigCASSave` file route с префиксом
|
||||
GUID. Публичное чтение `ConfigCAS` разрешено только для ключа, который
|
||||
подтверждён манифестом активного расширения; непринадлежащий активной
|
||||
композиции файл получает `extension_route_not_active`. Внутренние SQL-вызовы
|
||||
адаптера отделены от этого публичного барьера, чтобы он мог доказуемо
|
||||
построить маршрут, но не раскрывает эти строки агенту.
|
||||
|
||||
### Неподтверждённая гипотеза
|
||||
|
||||
Остальные флажки записаны в других позициях `_ExtensionZippedInfo` либо в
|
||||
другой SQL-структуре. Это не используется адаптером.
|
||||
|
||||
### Следующий опыт для декодирования остальных флажков (только по необходимости)
|
||||
|
||||
На одном выбранном расширении оставить «Активно» неизменным и переключить
|
||||
ровно один другой флажок, сохранить, затем снять бинарный diff. Повторить
|
||||
обратное переключение. До двухстороннего воспроизведения позиция и смысл
|
||||
изменившихся байтов остаются гипотезой.
|
||||
|
||||
## Неподтверждённое направление — opaque `module_ref`
|
||||
|
||||
Нельзя отбрасывать любой `ConfigCAS`/`ConfigCASSave` `module_ref` только по
|
||||
имени физического файла: у части подтверждённых активных модулей GUID
|
||||
расширения отсутствует в имени и восстанавливается только из доказанного
|
||||
контекста владельца. Ранняя фильтрация такого `module_ref` была проверена и
|
||||
отменена, так как блокировала активные маршруты. Дальнейшее усиление возможно
|
||||
только после доказанного owner-resolution до чтения модуля; до этого нельзя
|
||||
объявлять opaque module_ref маршрутом выключенного расширения или менять его
|
||||
семантику догадкой.
|
||||
|
||||
### Подтверждённое частное правило для `module_ref`
|
||||
|
||||
Если physical `ConfigCASSave module_ref` содержит стандартный префикс
|
||||
`<extension-guid>__`, GUID слоя доказуем до чтения контейнера. Адаптер
|
||||
проверяет этот GUID по активной композиции и возвращает `extension_inactive`
|
||||
для выключенного расширения. Это правило не распространяется на непрозрачные
|
||||
имена файлов без GUID: для них по-прежнему требуется доказательство владельца.
|
||||
@@ -1,6 +1,6 @@
|
||||
id: 1c-designer-sql-decoding-policy
|
||||
status: active
|
||||
summary: "Controlled changes in a disposable 1C base may be made only through 1C clients; the adapter observes and decodes SQL without writing application data."
|
||||
summary: "The adapter is a SQL codec only: it decodes and encodes strictly by the live-SQL-derived, versioned configuration-storage specification. In an explicitly authorised test base it may write only verified configuration saved-state overlays. It never invents 1C structure, BSL, or integrity atoms, and never writes active configuration or application data."
|
||||
|
||||
scope:
|
||||
default_base_id: upo_test
|
||||
@@ -8,8 +8,10 @@ scope:
|
||||
forbidden_base_class: [production, unclassified]
|
||||
platform_mutation_authority:
|
||||
application_data: 1c_enterprise_client
|
||||
metadata_working_state: 1c_designer
|
||||
adapter_role: sql_observer_and_decoder
|
||||
active_configuration: 1c_designer
|
||||
metadata_saved_state: adapter_sql_only_with_verified_codec
|
||||
adapter_role: specification_bound_sql_decoder_and_controlled_saved_state_writer
|
||||
fundamental_rule: "Unknown, incomplete, or ambiguous structure returns explicit evidence and unsupported/partial/ambiguous; no guessed decoding or encoding is permitted."
|
||||
|
||||
credentials:
|
||||
persistence: forbidden_in_repository
|
||||
@@ -35,7 +37,7 @@ experiment:
|
||||
forbidden_selectors_for_callers: [sql_number, physical_table, internal_guid_only]
|
||||
|
||||
sql_observation:
|
||||
adapter_access: read_only
|
||||
adapter_access: sql_only
|
||||
allowed: [SELECT, metadata_schema_inspection, ConfigSave_read, ConfigCASSave_read, application_table_read]
|
||||
forbidden:
|
||||
- direct_application_data_write
|
||||
@@ -43,7 +45,20 @@ sql_observation:
|
||||
- direct_ConfigCAS_write
|
||||
- sql_identity_or_permission_change
|
||||
- trigger_or_profiler_installation
|
||||
rule: "All experimental mutations happen through 1C; SQL is evidence, not the mutation transport."
|
||||
rule: "For production and unclassified bases SQL is evidence only. In the explicitly authorised disposable base, SQL writes are limited to ConfigSave/ConfigCASSave after a proven lossless codec, exact preconditions, atomic paired-file update, backup, and readback verification."
|
||||
|
||||
saved_state_write:
|
||||
allowed_base_id: upo_test
|
||||
allowed_tables: [ConfigSave, ConfigCASSave]
|
||||
forbidden_tables: [Config, ConfigCAS]
|
||||
required:
|
||||
- "Resolve the target by live public-name evidence; do not require callers to supply a physical selector."
|
||||
- "Read and hash every target byte stream before writing."
|
||||
- "Use an exact, unique edit anchor or a proven offset/path selector; otherwise return an ambiguity error."
|
||||
- "For extension saved-state, update the changed payload and the matching __configinfo file-SHA1 reference atomically."
|
||||
- "Preserve unproven service atoms byte-for-byte; never generate a value by guesswork or randomness."
|
||||
- "Create rollback evidence and verify SQL readback after commit."
|
||||
- "Return Configurator refresh guidance based on whether the object existed in saved-state before the write."
|
||||
|
||||
metadata_layers:
|
||||
designer_save:
|
||||
@@ -66,4 +81,3 @@ promotion_gates:
|
||||
- "The rule is reproduced with a second value or a second object of the same shape."
|
||||
- "A regression fixture and decoder test are added."
|
||||
- "Rollback through 1C restores the SQL evidence or the experiment documents an irreversible schema migration."
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -10,6 +12,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -43,6 +46,7 @@ SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
|
||||
_BASE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_BASE_LOCKS_GUARD = threading.Lock()
|
||||
_STATE_LOCK = threading.RLock()
|
||||
REPOSITORY_STATE_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def external_1c_enabled() -> bool:
|
||||
@@ -124,7 +128,20 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
else:
|
||||
item = None
|
||||
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
|
||||
return None, layer_error
|
||||
# A disposable base can explicitly declare that its base layer has
|
||||
# no repository at all. Extensions in such a base are not new
|
||||
# repository layers merely because the adapter has discovered them
|
||||
# after the configuration file was written. Inherit only this
|
||||
# unambiguous no-repository fact; never inherit a manual/automatic
|
||||
# repository policy to an extension.
|
||||
base_layer = base_item["development_layers"].get("base")
|
||||
base_repository = base_layer.get("repository") if isinstance(base_layer, dict) and isinstance(base_layer.get("repository"), dict) else None
|
||||
base_mode = str((base_repository or {}).get("mode") or (base_repository or {}).get("lock_mode") or "").strip().casefold()
|
||||
base_connection = str((base_repository or {}).get("connection_state") or "").strip().casefold()
|
||||
if layer_id != "base" and (base_mode == "none" or base_connection == "not_configured"):
|
||||
item = {"mode": "none", "connection_state": "not_configured", "inherited_from_layer": "base"}
|
||||
else:
|
||||
return None, layer_error
|
||||
# Legacy repository-only configuration remains readable for the base layer.
|
||||
if item is None and layer_id == "base" and isinstance(base_item, dict):
|
||||
item = base_item.get("repository")
|
||||
@@ -148,7 +165,11 @@ def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, A
|
||||
if configured["mode"] == "unknown":
|
||||
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
|
||||
if configured["mode"] == "none":
|
||||
return {"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
|
||||
return {
|
||||
"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"],
|
||||
"layer_id": layer_id, "layer": layer_id,
|
||||
**({"inherited_from_layer": configured["inherited_from_layer"]} if configured.get("inherited_from_layer") else {}),
|
||||
}, None
|
||||
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
|
||||
configured["layer_id"] = layer_id
|
||||
configured["layer"] = layer_id
|
||||
@@ -207,6 +228,7 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"layer": config.get("layer"),
|
||||
"lock_mode": config.get("lock_mode"),
|
||||
"connection_state": config.get("connection_state"),
|
||||
"inherited_from_layer": config.get("inherited_from_layer"),
|
||||
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
|
||||
"automatic_repository_operations_available": external_1c_enabled(),
|
||||
"endpoint": config.get("endpoint"),
|
||||
@@ -317,6 +339,119 @@ def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds:
|
||||
}
|
||||
|
||||
|
||||
def activation_debug_probe(
|
||||
base_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
layer: str,
|
||||
timeout_seconds: int,
|
||||
request_id: str = "",
|
||||
fingerprint: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Probe runner readiness without starting Designer or reading configured credentials."""
|
||||
|
||||
runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"}
|
||||
runner_kind = str(runner.get("kind") or "local").strip().casefold()
|
||||
if runner_kind == "http":
|
||||
url = str(runner.get("url") or "").rstrip("/") + "/configuration/activation/debug"
|
||||
request_body = {
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"mode": "debug",
|
||||
}
|
||||
if request_id and fingerprint:
|
||||
request_body["request_id"] = request_id
|
||||
request_body["fingerprint"] = fingerprint
|
||||
body = json.dumps(
|
||||
request_body,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
token_env = str(runner.get("token_env") or "").strip()
|
||||
token = os.environ.get(token_env, "") if token_env else ""
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=body, headers=headers, method="POST"),
|
||||
timeout=timeout_seconds,
|
||||
) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
result = json.loads(exc.read().decode("utf-8"))
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
result = {
|
||||
"status": "runner_error",
|
||||
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
||||
}
|
||||
return result if isinstance(result, dict) else {
|
||||
"status": "runner_error",
|
||||
"message": f"Activation debug runner returned HTTP {exc.code}.",
|
||||
}
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
return {"status": "runner_error", "message": str(exc)}
|
||||
return result if isinstance(result, dict) else {
|
||||
"status": "runner_error",
|
||||
"message": "Activation debug runner returned a non-object response.",
|
||||
}
|
||||
infobase = config.get("infobase") if isinstance(config.get("infobase"), dict) else {}
|
||||
selector_configured = (
|
||||
sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1
|
||||
)
|
||||
designer_path = str(config.get("designer_path") or "").strip()
|
||||
try:
|
||||
designer_available = bool(designer_path and Path(designer_path).is_file())
|
||||
except OSError:
|
||||
designer_available = False
|
||||
ready = bool(selector_configured and designer_available)
|
||||
debug_acceptance = None
|
||||
if request_id and fingerprint:
|
||||
receipt_source = json.dumps(
|
||||
{
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"request_id": request_id,
|
||||
"fingerprint": fingerprint,
|
||||
"mode": "debug",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
debug_acceptance = {
|
||||
"accepted": ready,
|
||||
"request_id": request_id,
|
||||
"fingerprint": fingerprint,
|
||||
"receipt": hashlib.sha256(receipt_source).hexdigest() if ready else None,
|
||||
}
|
||||
return {
|
||||
"schema": "onec_configuration_activation_runner_probe.v1",
|
||||
"status": "ready" if ready else "not_ready",
|
||||
"base_id": base_id,
|
||||
"layer": layer,
|
||||
"runner": {
|
||||
"kind": "local",
|
||||
"reachable": True,
|
||||
"designer_path_configured": bool(designer_path),
|
||||
"designer_available": designer_available,
|
||||
"infobase_selector_configured": selector_configured,
|
||||
},
|
||||
"operation": {
|
||||
"kind": "/UpdateDBCfg" if layer == "base_saved_state" else None,
|
||||
"execution_supported": False,
|
||||
"extension_manual_only": layer in {"all", "extension_saved_state"},
|
||||
},
|
||||
"debug_acceptance": debug_acceptance,
|
||||
"execution": {
|
||||
"mode": "debug",
|
||||
"performed": False,
|
||||
"designer_started": False,
|
||||
"active_configuration_changed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _execute_repository(
|
||||
base_id: str,
|
||||
config: dict[str, Any],
|
||||
@@ -483,8 +618,7 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
repository_user = requested_repository_user or configured_repository_user
|
||||
request_id = "rreq-" + uuid.uuid4().hex
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
state.setdefault("requests", {})[request_id] = {
|
||||
"base_id": base_id,
|
||||
"layer": layer_id,
|
||||
@@ -499,7 +633,6 @@ def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
|
||||
}
|
||||
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
|
||||
_write_state(state)
|
||||
return {
|
||||
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
|
||||
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
|
||||
@@ -521,7 +654,21 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = (_read_state().get("requests") or {}).get(request_id)
|
||||
if not isinstance(request, dict):
|
||||
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
|
||||
result = {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
|
||||
result = {
|
||||
"schema": "onec_repository_lock_request_status.v1",
|
||||
"method": METHOD_LOCK_REQUEST_STATUS,
|
||||
"status": request.get("status"),
|
||||
"request_id": request_id,
|
||||
# Surface the manual-confirmation scope at top level. Requiring
|
||||
# callers to inspect an opaque persisted request made a pending lock
|
||||
# look context-free and encouraged unsafe confirmation guesses.
|
||||
"base_id": request.get("base_id"),
|
||||
"layer_id": request.get("layer_id") or request.get("layer"),
|
||||
"objects": list(request.get("objects") or []),
|
||||
"repository_user": request.get("repository_user") or None,
|
||||
"native_lock_state": "unknown",
|
||||
"request": request,
|
||||
}
|
||||
if request.get("status") == "pending_user_lock":
|
||||
result["next_method"] = METHOD_CONFIRM
|
||||
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
|
||||
@@ -532,8 +679,7 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request_id = str(payload.get("request_id") or "").strip()
|
||||
if payload.get("confirm_cancel") is not True:
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id}
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
request = (state.get("requests") or {}).get(request_id)
|
||||
if not isinstance(request, dict):
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id}
|
||||
@@ -542,20 +688,284 @@ def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request["status"] = "cancelled"
|
||||
request["cancelled_at"] = time.time()
|
||||
_audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects"))
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
"""Legacy JSON path used only for one-time migration to local SQLite."""
|
||||
|
||||
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any]:
|
||||
def _state_db_path() -> Path:
|
||||
"""Adapter-local state database; never points at a configured 1C database."""
|
||||
|
||||
configured = os.environ.get("ONEC_ADAPTER_STATE_DB") or os.environ.get("ONEC_ADAPTER_CACHE_DB")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
legacy_override = os.environ.get("ONEC_REPOSITORY_STATE_FILE")
|
||||
if legacy_override:
|
||||
return Path(legacy_override).with_suffix(".sqlite")
|
||||
return Path("/data/adapter-cache.sqlite")
|
||||
|
||||
|
||||
def _empty_state() -> dict[str, Any]:
|
||||
return {"sessions": {}, "requests": {}, "audit": []}
|
||||
|
||||
|
||||
def _read_legacy_state() -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
|
||||
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
|
||||
return value if isinstance(value, dict) else _empty_state()
|
||||
except (OSError, json.JSONDecodeError):
|
||||
state = {"sessions": {}, "requests": {}, "audit": []}
|
||||
return _empty_state()
|
||||
|
||||
|
||||
def _state_connection() -> sqlite3.Connection:
|
||||
path = _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("PRAGMA busy_timeout=30000")
|
||||
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 repository_lock_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
base_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repository_lock_sessions (
|
||||
lock_session_id TEXT PRIMARY KEY,
|
||||
base_id TEXT NOT NULL,
|
||||
layer_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
payload_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS repository_lock_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL,
|
||||
base_id TEXT,
|
||||
request_id TEXT,
|
||||
lock_session_id TEXT,
|
||||
details_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_requests_base_status "
|
||||
"ON repository_lock_requests(base_id, status, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_sessions_base_status "
|
||||
"ON repository_lock_sessions(base_id, status, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_repository_lock_events_base_time "
|
||||
"ON repository_lock_events(base_id, occurred_at)"
|
||||
)
|
||||
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(REPOSITORY_STATE_SCHEMA_VERSION), time.time()),
|
||||
)
|
||||
migration = conn.execute(
|
||||
"SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'"
|
||||
).fetchone()
|
||||
if not migration:
|
||||
legacy = _read_legacy_state()
|
||||
_sync_state_to_connection(conn, legacy)
|
||||
conn.execute(
|
||||
"INSERT INTO adapter_state_meta(key, value, updated_at) VALUES(?, ?, ?)",
|
||||
("legacy_repository_state_imported", "1", time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _event_id(row: dict[str, Any], index: int) -> str:
|
||||
explicit = str(row.get("event_id") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
source = json.dumps(
|
||||
{
|
||||
"index": index,
|
||||
"event": row.get("event"),
|
||||
"time": row.get("time"),
|
||||
"request_id": row.get("request_id"),
|
||||
"lock_session_id": row.get("lock_session_id"),
|
||||
"base_id": row.get("base_id"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return "legacy-" + hashlib.sha1(source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _load_state_from_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
state = _empty_state()
|
||||
for row in conn.execute("SELECT request_id, payload_json FROM repository_lock_requests"):
|
||||
try:
|
||||
payload = json.loads(row["payload_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
state["requests"][str(row["request_id"])] = payload
|
||||
for row in conn.execute("SELECT lock_session_id, payload_json FROM repository_lock_sessions"):
|
||||
try:
|
||||
payload = json.loads(row["payload_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
state["sessions"][str(row["lock_session_id"])] = payload
|
||||
for row in conn.execute(
|
||||
"SELECT event_id, event, occurred_at, details_json FROM repository_lock_events "
|
||||
"ORDER BY occurred_at, event_id"
|
||||
):
|
||||
try:
|
||||
payload = json.loads(row["details_json"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
payload.setdefault("event_id", str(row["event_id"]))
|
||||
payload.setdefault("event", str(row["event"]))
|
||||
payload.setdefault("time", float(row["occurred_at"]))
|
||||
state["audit"].append(payload)
|
||||
return state
|
||||
|
||||
|
||||
def _sync_state_to_connection(conn: sqlite3.Connection, value: dict[str, Any]) -> None:
|
||||
now = time.time()
|
||||
for request_id, raw in (value.get("requests") or {}).items():
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
request = dict(raw)
|
||||
created_at = float(request.get("created_at") or now)
|
||||
updated_at = float(
|
||||
request.get("cancelled_at")
|
||||
or request.get("confirmed_at")
|
||||
or request.get("closed_at")
|
||||
or request.get("expired_at")
|
||||
or created_at
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO repository_lock_requests(
|
||||
request_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
base_id=excluded.base_id,
|
||||
layer_id=excluded.layer_id,
|
||||
status=excluded.status,
|
||||
updated_at=excluded.updated_at,
|
||||
payload_json=excluded.payload_json
|
||||
""",
|
||||
(
|
||||
str(request_id),
|
||||
str(request.get("base_id") or ""),
|
||||
str(request.get("layer_id") or request.get("layer") or "base"),
|
||||
str(request.get("status") or "unknown"),
|
||||
created_at,
|
||||
updated_at,
|
||||
json.dumps(request, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
for session_id, raw in (value.get("sessions") or {}).items():
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
session = dict(raw)
|
||||
created_at = float(session.get("created_at") or now)
|
||||
updated_at = float(
|
||||
session.get("committed_at")
|
||||
or session.get("released_at")
|
||||
or session.get("closed_at")
|
||||
or session.get("expired_at")
|
||||
or created_at
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO repository_lock_sessions(
|
||||
lock_session_id, base_id, layer_id, status, created_at, updated_at, payload_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(lock_session_id) DO UPDATE SET
|
||||
base_id=excluded.base_id,
|
||||
layer_id=excluded.layer_id,
|
||||
status=excluded.status,
|
||||
updated_at=excluded.updated_at,
|
||||
payload_json=excluded.payload_json
|
||||
""",
|
||||
(
|
||||
str(session_id),
|
||||
str(session.get("base_id") or ""),
|
||||
str(session.get("layer_id") or session.get("layer") or "base"),
|
||||
str(session.get("status") or "unknown"),
|
||||
created_at,
|
||||
updated_at,
|
||||
json.dumps(session, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
audit = [row for row in (value.get("audit") or []) if isinstance(row, dict)][-5000:]
|
||||
for index, raw in enumerate(audit):
|
||||
event = dict(raw)
|
||||
event_id = _event_id(event, index)
|
||||
event["event_id"] = event_id
|
||||
occurred_at = float(event.get("time") or now)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO repository_lock_events(
|
||||
event_id, event, occurred_at, base_id, request_id, lock_session_id, details_json
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_id,
|
||||
str(event.get("event") or "unknown"),
|
||||
occurred_at,
|
||||
str(event.get("base_id") or "") or None,
|
||||
str(event.get("request_id") or "") or None,
|
||||
str(event.get("lock_session_id") or "") or None,
|
||||
json.dumps(event, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _expire_state(state: dict[str, Any]) -> bool:
|
||||
changed = False
|
||||
now = time.time()
|
||||
request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400))
|
||||
session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200))
|
||||
@@ -563,24 +973,57 @@ def _read_state() -> dict[str, Any]:
|
||||
if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl:
|
||||
request["status"] = "expired"
|
||||
request["expired_at"] = now
|
||||
changed = True
|
||||
for session in (state.get("sessions") or {}).values():
|
||||
if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl:
|
||||
session["status"] = "expired"
|
||||
session["expired_at"] = now
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _read_state() -> dict[str, Any]:
|
||||
with _STATE_LOCK:
|
||||
with _state_connection() as conn:
|
||||
state = _load_state_from_connection(conn)
|
||||
if _expire_state(state):
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_sync_state_to_connection(conn, state)
|
||||
conn.commit()
|
||||
return state
|
||||
|
||||
|
||||
def _write_state(value: dict[str, Any]) -> None:
|
||||
path = _state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
with _STATE_LOCK:
|
||||
with _state_connection() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_sync_state_to_connection(conn, value)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _state_transaction() -> Any:
|
||||
"""Serialize a repository state mutation across adapter processes."""
|
||||
|
||||
with _STATE_LOCK:
|
||||
conn = _state_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
state = _load_state_from_connection(conn)
|
||||
_expire_state(state)
|
||||
yield state
|
||||
_sync_state_to_connection(conn, state)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
|
||||
rows = state.setdefault("audit", [])
|
||||
rows.append({"event": event, "time": time.time(), **details})
|
||||
rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details})
|
||||
if len(rows) > 5000:
|
||||
del rows[:-5000]
|
||||
|
||||
@@ -659,10 +1102,9 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed}
|
||||
session_id = "rlock-" + uuid.uuid4().hex
|
||||
state = _read_state()
|
||||
sessions = state.setdefault("sessions", {})
|
||||
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
||||
_write_state(state)
|
||||
with _state_transaction() as state:
|
||||
sessions = state.setdefault("sessions", {})
|
||||
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
|
||||
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
|
||||
|
||||
|
||||
@@ -724,19 +1166,33 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
|
||||
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
|
||||
session_id = "rlock-" + uuid.uuid4().hex
|
||||
state.setdefault("sessions", {})[session_id] = {
|
||||
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
|
||||
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
||||
"verification": "user_confirmation_only", "automatically_verified": False,
|
||||
"repository_user": confirmed_repository_user,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
if isinstance(request, dict):
|
||||
request["status"] = "confirmed_by_user"
|
||||
request["confirmed_at"] = time.time()
|
||||
request["lock_session_id"] = session_id
|
||||
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
|
||||
_write_state(state)
|
||||
with _state_transaction() as current_state:
|
||||
current_request = (current_state.get("requests") or {}).get(request_id) if request_id else None
|
||||
if request_id and (
|
||||
not isinstance(current_request, dict)
|
||||
or current_request.get("base_id") != base_id
|
||||
or current_request.get("status") != "pending_user_lock"
|
||||
):
|
||||
return {
|
||||
"schema": "onec_repository_manual_lock.v1",
|
||||
"method": METHOD_CONFIRM,
|
||||
"base_id": base_id,
|
||||
"status": "blocked",
|
||||
"error": "lock_request_not_pending",
|
||||
"request_id": request_id,
|
||||
}
|
||||
current_state.setdefault("sessions", {})[session_id] = {
|
||||
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
|
||||
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
|
||||
"verification": "user_confirmation_only", "automatically_verified": False,
|
||||
"repository_user": confirmed_repository_user,
|
||||
**({"request_id": request_id} if request_id else {}),
|
||||
}
|
||||
if isinstance(current_request, dict):
|
||||
current_request["status"] = "confirmed_by_user"
|
||||
current_request["confirmed_at"] = time.time()
|
||||
current_request["lock_session_id"] = session_id
|
||||
_audit(current_state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
|
||||
return {
|
||||
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
|
||||
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
|
||||
@@ -769,8 +1225,7 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session_id = str(payload.get("lock_session_id") or "").strip()
|
||||
if payload.get("user_confirmed_released") is not True:
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id}
|
||||
with _STATE_LOCK:
|
||||
state = _read_state()
|
||||
with _state_transaction() as state:
|
||||
session = (state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(session, dict):
|
||||
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id}
|
||||
@@ -797,7 +1252,6 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request["closed_at"] = closed_at
|
||||
if not already_closed:
|
||||
_audit(state, "manual_lock_closed", request_id=request_id or None, lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
|
||||
_write_state(state)
|
||||
return {
|
||||
"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed",
|
||||
"lock_session_id": session_id, "request_id": request_id or None,
|
||||
@@ -882,6 +1336,18 @@ def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
|
||||
layer = layers.get(layer_id)
|
||||
if not isinstance(layer, dict):
|
||||
# See repository_config(): an explicit repository-less base is the
|
||||
# disposable-test profile. It applies to newly discovered extensions
|
||||
# as well, so a missing per-extension policy cannot turn a permitted
|
||||
# test write into a false "unknown support" block.
|
||||
repository, repository_error = repository_config(base_id, layer_id)
|
||||
if repository_error is None and isinstance(repository, dict) and repository.get("mode") == "none":
|
||||
return {
|
||||
"required": False, "allowed": True,
|
||||
"status": "not_on_support_inherited_no_repository",
|
||||
"layer_id": layer_id,
|
||||
"inherited_from_layer": repository.get("inherited_from_layer"),
|
||||
}
|
||||
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
|
||||
support = layer.get("support")
|
||||
if not isinstance(support, dict):
|
||||
@@ -952,11 +1418,16 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True)
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed}
|
||||
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
||||
session["committed_at"] = time.time()
|
||||
session["commit_comment"] = str(plan["comment"])
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("objects"), "keep_locked": payload.get("keep_locked") is True, "execution": executed}
|
||||
with _state_transaction() as current_state:
|
||||
current_session = (current_state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
||||
current_session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
|
||||
current_session["committed_at"] = time.time()
|
||||
current_session["commit_comment"] = str(plan["comment"])
|
||||
final_status = str(current_session["status"])
|
||||
committed_objects = current_session.get("objects")
|
||||
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": final_status, "lock_session_id": session_id, "committed": committed_objects, "keep_locked": payload.get("keep_locked") is True, "execution": executed}
|
||||
|
||||
|
||||
def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -977,10 +1448,14 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []])
|
||||
if executed.get("status") != "ok":
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed}
|
||||
session["status"] = "released"
|
||||
session["released_at"] = time.time()
|
||||
_write_state(state)
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("objects"), "execution": executed}
|
||||
with _state_transaction() as current_state:
|
||||
current_session = (current_state.get("sessions") or {}).get(session_id)
|
||||
if not isinstance(current_session, dict) or current_session.get("status") != "acquired":
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id}
|
||||
current_session["status"] = "released"
|
||||
current_session["released_at"] = time.time()
|
||||
released_objects = current_session.get("objects")
|
||||
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": released_objects, "execution": executed}
|
||||
|
||||
|
||||
def call(method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed write-dispatch contracts for the SQL-only 1C adapter."""
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Explicit adapter services available to typed write handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterWriteContext:
|
||||
"""Migration boundary: handlers receive services, never server globals.
|
||||
|
||||
`legacy_scheduled_job_writer` is temporary while the existing proven
|
||||
implementation is characterized. It prevents the dispatcher from keeping
|
||||
a direct dependency on that writer and is replaced by granular services
|
||||
when the implementation body moves into the handler.
|
||||
"""
|
||||
|
||||
legacy_scheduled_job_writer: Callable[[dict[str, Any]], dict[str, Any]]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Stable contracts shared by the universal dispatcher and typed handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriteHandler:
|
||||
"""A supported public write surface, not an SQL implementation detail."""
|
||||
|
||||
key: str
|
||||
public_target_kind: str
|
||||
operation: str | None = None
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Typed write-handler declarations.
|
||||
|
||||
Implementations are migrated here one at a time after their existing adapter
|
||||
tests become handler-level characterization tests.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
# Element, command/button, and embedded-module routing needs decoded form
|
||||
# evidence, so it remains a sub-dispatch inside this public form surface.
|
||||
HANDLER = WriteHandler("form", "form")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("module", "module")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_member", "object", "add_attribute")
|
||||
@@ -0,0 +1,3 @@
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("object_property", "object")
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Any
|
||||
|
||||
from write.context import AdapterWriteContext
|
||||
from write.contracts import WriteHandler
|
||||
|
||||
HANDLER = WriteHandler("scheduled_job_schedule", "schedule")
|
||||
|
||||
|
||||
def execute(payload: dict[str, Any], context: AdapterWriteContext) -> dict[str, Any]:
|
||||
"""Run the current proven scheduled-job writer through the handler seam."""
|
||||
return context.legacy_scheduled_job_writer(payload)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pure, storage-free selection of a typed configuration write handler.
|
||||
|
||||
The registry deliberately contains no SQL, payload, or 1C metadata decoding.
|
||||
It is the first migration seam out of the monolithic adapter server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from write.contracts import WriteHandler
|
||||
from write.handlers.form import HANDLER as FORM_HANDLER
|
||||
from write.handlers.module import HANDLER as MODULE_HANDLER
|
||||
from write.handlers.object_member import HANDLER as OBJECT_MEMBER_HANDLER
|
||||
from write.handlers.object_property import HANDLER as OBJECT_PROPERTY_HANDLER
|
||||
from write.handlers.scheduled_job import HANDLER as SCHEDULE_HANDLER
|
||||
|
||||
|
||||
def select_write_handler(*, target_kind: str, operation: str = "", is_schedule: bool = False) -> WriteHandler | None:
|
||||
"""Return one supported typed handler or ``None`` for a forbidden target.
|
||||
|
||||
Detailed form sub-routing (element, command, embedded module) remains in
|
||||
the form handler. It needs decoded target evidence that is unavailable at
|
||||
this pure public-intent stage.
|
||||
"""
|
||||
if is_schedule:
|
||||
return SCHEDULE_HANDLER
|
||||
normalized_kind = str(target_kind or "").strip().casefold()
|
||||
normalized_operation = str(operation or "").strip().casefold()
|
||||
if normalized_kind in {"object", "объект", "metadata", "метаданные"}:
|
||||
if normalized_operation in {"add_attribute", "attribute_add", "добавить_реквизит", "добавитьреквизит"}:
|
||||
return OBJECT_MEMBER_HANDLER
|
||||
return OBJECT_PROPERTY_HANDLER
|
||||
if normalized_kind in {"module", "модуль", "bsl"}:
|
||||
return MODULE_HANDLER
|
||||
if normalized_kind in {"form", "форма"}:
|
||||
return FORM_HANDLER
|
||||
return None
|
||||
|
||||
|
||||
def registered_handlers() -> list[dict[str, str | None]]:
|
||||
"""Public-safe registry summary; contains no SQL implementation details."""
|
||||
handlers = [MODULE_HANDLER, FORM_HANDLER, OBJECT_PROPERTY_HANDLER, OBJECT_MEMBER_HANDLER, SCHEDULE_HANDLER]
|
||||
return [
|
||||
{"key": handler.key, "target_kind": handler.public_target_kind, "operation": handler.operation}
|
||||
for handler in handlers
|
||||
]
|
||||
@@ -6,6 +6,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
COPY adapter_1c_mcp.py /app/adapter_1c_mcp.py
|
||||
COPY analyze_audit.py /app/analyze_audit.py
|
||||
|
||||
EXPOSE 8021
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import datetime
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -24,7 +26,7 @@ ROOT_DIR = THIS_FILE.parents[3] if len(THIS_FILE.parents) > 3 else THIS_FILE.par
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011"
|
||||
DEFAULT_ACCESS_REPORT_ROOT = ROOT_DIR / "reports" / "1c-access"
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
@@ -32,8 +34,12 @@ MCP_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
SESSIONS: dict[str, "queue.Queue[dict[str, Any] | None]"] = {}
|
||||
SESSION_LOCK = threading.Lock()
|
||||
JOB_LOCK = threading.Lock()
|
||||
MCP_AUDIT_LOCK = threading.Lock()
|
||||
SELECTOR_TOKEN_LOCK = threading.Lock()
|
||||
JOBS: dict[str, dict[str, Any]] = {}
|
||||
NEW_METHOD_CACHE: dict[str, dict[str, Any]] = {}
|
||||
SELECTOR_TOKENS: dict[str, dict[str, Any]] = {}
|
||||
SELECTOR_TOKEN_TTL_SECONDS = 600
|
||||
LONG_METHODS = {
|
||||
"metadata.object.attributes",
|
||||
"metadata.object.full",
|
||||
@@ -122,6 +128,10 @@ REST_STATE_BY_SOURCE_STATE = {
|
||||
"all": "both",
|
||||
}
|
||||
REST_STATE_METHODS = {
|
||||
"metadata.object.forms",
|
||||
"metadata.object.form.details",
|
||||
"metadata.form.decode",
|
||||
"metadata.object.full",
|
||||
"metadata.resolve_overrides",
|
||||
"modules.search",
|
||||
"code.search",
|
||||
@@ -150,6 +160,11 @@ GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]
|
||||
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"
|
||||
TECHNICAL_AGENT_FIELDS = {
|
||||
"table", "file_name", "file_names", "module_ref", "module_id",
|
||||
"stream_index", "bsl_offset", "cas_key", "storage_key",
|
||||
"include_storage", "guid", "object_guid", "form_guid", "extension_guid",
|
||||
}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
@@ -186,17 +201,19 @@ TOOLS = [
|
||||
"description": (
|
||||
"Generic 1C adapter request. For live metadata/modules/code/templates/extensions/query methods, "
|
||||
"payload.base_id is required; get it from user/project context or check a concrete base with onec_health first. "
|
||||
"If you already have module_ref/read_selector, prefer direct read methods before global search. "
|
||||
"Search results include read_selector.method; reuse that selector directly for the next read call. "
|
||||
"Use complete public 1C names first: extension + object ref + child name where applicable. "
|
||||
"Search results declare read_selector.method and include read_selector.selector_token; reuse that token with its declared method for the next read call. "
|
||||
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
|
||||
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
|
||||
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the object selector "
|
||||
"(ref, kind/name/guid, or object_type/object_name/object_guid) or owner_scan_limit. "
|
||||
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the public object selector "
|
||||
"(ref, kind/name, or object_type/object_name). Global code/vector searches resolve "
|
||||
"base module owners lazily from current metadata; use metadata.module_owner_cache.backfill for bounded "
|
||||
"background warming instead of increasing owner_scan_limit on interactive searches. "
|
||||
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
|
||||
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
|
||||
"Do not select Config/ConfigSave tables in ordinary programming calls. "
|
||||
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name; "
|
||||
"use table/file_name/module_ref only when continuing an explicit include_storage diagnostic result. "
|
||||
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name. "
|
||||
"Do not send GUIDs, table/file_name/module_ref, stream indexes, CAS keys, or include_storage in ordinary agent requests. "
|
||||
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
|
||||
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
|
||||
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
|
||||
@@ -310,8 +327,7 @@ TOOLS = [
|
||||
{
|
||||
"method": "code.read",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"module_ref": "<module_ref-from-code-search-read-selector>",
|
||||
"selector_token": "<selector-token-from-code-search>",
|
||||
"include_line_numbers": True,
|
||||
"max_chars": 20000,
|
||||
},
|
||||
@@ -355,8 +371,7 @@ TOOLS = [
|
||||
{
|
||||
"method": "modules.read",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"module_ref": "<module_ref-from-prior-result>",
|
||||
"selector_token": "<selector-token-from-modules-search>",
|
||||
"include_line_numbers": True,
|
||||
"include_text": True,
|
||||
},
|
||||
@@ -510,11 +525,72 @@ TOOLS = [
|
||||
"context_limit": 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.status",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.plan",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.request",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.execute",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<request-id-from-configuration.activation.request>",
|
||||
"mode": "debug",
|
||||
"confirm_activation": True,
|
||||
"bridge_debug": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.request.cancel",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<activation-request-id>",
|
||||
"confirm_cancel": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.capabilities",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.bridge.probe",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"layer": "extension_saved_state",
|
||||
"timeout_seconds": 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
"method": "configuration.activation.verify",
|
||||
"payload": {
|
||||
"base_id": "<base_id-from-project-context>",
|
||||
"request_id": "<activation-request-id>",
|
||||
},
|
||||
},
|
||||
],
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
|
||||
"description": "Adapter method, for example metadata.objects.list, metadata.object.full, metadata.definition.find, metadata.route.resolve, extension.objects.find, templates.read, templates.analyze, templates.map, metadata.module_owner_cache.backfill, metadata.saved_state.ensure, metadata.saved_state.ensure.rollback, metadata.saved_state.prepare, metadata.saved_state.status, metadata.saved_state.diff, metadata.saved_state.changes.list, 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, metadata.saved_state.forms.search, metadata.saved_state.modules.search, metadata.form.write_target.resolve, metadata.form.write_target.verify, metadata.module.write_apply, metadata.write.plan, metadata.write.preflight, metadata.write.capabilities, metadata.write, metadata.write.history, metadata.write.rollback, metadata.form.command_button.write, metadata.form.command_button.verify, code.write, metadata.form.element.write_apply, metadata.write_learning.capture_before, metadata.write_learning.capture_after, metadata.write_learning.diff, metadata.write_learning.infer_rule, modules.search, modules.read, code.search, code.read, code.symbol.resolve, templates.bindings, diagnostics.call_chain, bulk.execute, changes.propose, storage.saved_state.apply_proposal, storage.saved_state.rollback, or mcp.job.get. Live database methods require payload.base_id; placeholders in examples must be replaced from project/user context.",
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
@@ -641,6 +717,7 @@ TOOLS = [
|
||||
"path": {"type": "string"},
|
||||
"canonical_path": {"type": "string"},
|
||||
"extension": {"type": "string"},
|
||||
"module_ref": {"type": "string"},
|
||||
"routine_name": {"type": "string"},
|
||||
"routine_text": {"type": "string"},
|
||||
"module_text": {"type": "string"},
|
||||
@@ -648,6 +725,10 @@ TOOLS = [
|
||||
"code": {"type": "string"},
|
||||
"old": {"type": "string"},
|
||||
"new": {"type": "string"},
|
||||
"expected_sha1": {"type": "string"},
|
||||
"expected_text_sha1": {"type": "string"},
|
||||
"repository_lock": {"type": "object", "additionalProperties": True},
|
||||
"write_context": {"type": "object", "additionalProperties": True},
|
||||
"mode": {"type": "string", "enum": ["plan", "apply"]},
|
||||
"include_storage": {"type": "boolean"},
|
||||
},
|
||||
@@ -2180,7 +2261,10 @@ def _run_bulk_execute(payload: dict[str, Any], request_start: float, request_id:
|
||||
except AdapterError as exc:
|
||||
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "diagnostics": adapter_error_result(submethod or "unknown", exc)})
|
||||
except Exception as exc:
|
||||
results.append({"index": index, "method": submethod, "status": "error", "error": str(exc), "traceback": traceback.format_exc(limit=5)})
|
||||
item = {"index": index, "method": submethod, "status": "error", "error": str(exc)}
|
||||
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
item["traceback"] = traceback.format_exc(limit=5)
|
||||
results.append(item)
|
||||
|
||||
requested_count = len(_as_list(payload.get("requests")))
|
||||
failed_count = len([item for item in results if (item.get("status") in {"error", "invalid_argument"})])
|
||||
@@ -2269,6 +2353,9 @@ def enrich_result_with_freshness(payload: dict[str, Any], method: str, result: A
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
context = build_freshness_context(payload)
|
||||
if method.startswith("storage."):
|
||||
context["cache_policy"] = "none"
|
||||
context["force_refresh"] = True
|
||||
context["request_id"] = str(payload.get("_mcp_request_id") or uuid.uuid4().hex)
|
||||
context["method"] = method
|
||||
context["base_id"] = payload.get("base_id")
|
||||
@@ -2310,6 +2397,12 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
|
||||
source_state = "working"
|
||||
cache_policy = "none"
|
||||
force_refresh = truthy(payload.get("force_refresh"))
|
||||
if method.startswith("storage."):
|
||||
# Storage methods always call the live SQL layer or adapter-local
|
||||
# backup store directly; their result is never served from the
|
||||
# metadata/vector cache.
|
||||
cache_policy = "none"
|
||||
force_refresh = True
|
||||
transformed = dict(payload)
|
||||
transformed["source_mode"] = source_mode
|
||||
transformed["source_state"] = source_state
|
||||
@@ -2343,7 +2436,39 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
|
||||
return transformed
|
||||
|
||||
|
||||
def http_json(method: str, path: str, payload: dict[str, Any] | None = None, timeout: float | None = None) -> Any:
|
||||
def mcp_audit_event(event: dict[str, Any]) -> None:
|
||||
"""Persist proxy telemetry without BSL text, payload bytes, or credentials."""
|
||||
try:
|
||||
path = Path(os.environ.get("ONEC_MCP_AUDIT_LOG_PATH") or "/data/mcp-audit.jsonl")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with MCP_AUDIT_LOCK:
|
||||
max_bytes = max(1_048_576, int(os.environ.get("ONEC_MCP_AUDIT_MAX_BYTES") or 52_428_800))
|
||||
keep_files = max(1, min(20, int(os.environ.get("ONEC_MCP_AUDIT_KEEP_FILES") or 10)))
|
||||
if path.exists() and path.stat().st_size >= max_bytes:
|
||||
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"))
|
||||
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, ValueError):
|
||||
return
|
||||
|
||||
|
||||
def mcp_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")
|
||||
return {key: payload.get(key) for key in keys if payload.get(key) not in {None, ""}}
|
||||
|
||||
|
||||
def http_json(
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
timeout: float | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> Any:
|
||||
url = f"{adapter_url()}{path}"
|
||||
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {"Accept": "application/json"}
|
||||
@@ -2351,6 +2476,8 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
if adapter_token():
|
||||
headers["Authorization"] = f"Bearer {adapter_token()}"
|
||||
if request_id and re.fullmatch(r"[A-Za-z0-9_.-]{8,128}", request_id):
|
||||
headers["X-Request-ID"] = request_id
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
effective_timeout = adapter_timeout() if timeout is None else timeout
|
||||
try:
|
||||
@@ -2362,33 +2489,69 @@ def http_json(method: str, path: str, payload: dict[str, Any] | None = None, tim
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise AdapterError(f"REST adapter returned HTTP {exc.code}", status=exc.code, body=body) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise AdapterError(f"REST adapter is unavailable: {exc.reason}") from exc
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
||||
reason = getattr(exc, "reason", None) or str(exc) or type(exc).__name__
|
||||
raise AdapterError(f"REST adapter is unavailable: {reason}") from exc
|
||||
|
||||
|
||||
def call_adapter_method(method: str, payload: dict[str, Any], *, timeout: float | None = None) -> Any:
|
||||
if method == "health":
|
||||
query = ""
|
||||
if payload.get("base_id"):
|
||||
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
|
||||
return http_json("GET", f"/health{query}", timeout=timeout)
|
||||
if method == "help.methods":
|
||||
try:
|
||||
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
|
||||
except AdapterError as exc:
|
||||
if exc.status not in {404, 405}:
|
||||
raise
|
||||
return http_json("GET", "/methods", timeout=timeout)
|
||||
return http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout)
|
||||
request_id = str(payload.get("_mcp_request_id") or "").strip()
|
||||
started = now_ts()
|
||||
try:
|
||||
if method == "health":
|
||||
query = ""
|
||||
if payload.get("base_id"):
|
||||
query = "?" + urllib.parse.urlencode({"base_id": str(payload.get("base_id"))})
|
||||
result = http_json("GET", f"/health{query}", timeout=timeout, request_id=request_id)
|
||||
elif method == "help.methods":
|
||||
try:
|
||||
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
|
||||
except AdapterError as exc:
|
||||
if exc.status not in {404, 405}:
|
||||
raise
|
||||
result = http_json("GET", "/methods", timeout=timeout, request_id=request_id)
|
||||
else:
|
||||
result = http_json("POST", "/rpc", {"method": method, "payload": payload}, timeout=timeout, request_id=request_id)
|
||||
except Exception as exc:
|
||||
mcp_audit_event({
|
||||
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
|
||||
"status": "exception", "error": "adapter_unavailable" if isinstance(exc, AdapterError) else "mcp_request_exception",
|
||||
"exception_type": type(exc).__name__, "duration_ms": int((now_ts() - started) * 1000),
|
||||
})
|
||||
raise
|
||||
mcp_audit_event({
|
||||
"event": "mcp_adapter_call", "time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"request_id": request_id or None, "method": method, "request": mcp_audit_request_summary(payload),
|
||||
"status": result.get("status") if isinstance(result, dict) else None,
|
||||
"error": result.get("error") if isinstance(result, dict) else None,
|
||||
"duration_ms": int((now_ts() - started) * 1000),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def public_error(method: str, error: str, diagnostics: Any | None = None, *, schema: str = "adapter_1c_mcp_error.v1") -> dict[str, Any]:
|
||||
safe_diagnostics = diagnostics if diagnostics is not None else {"message": error}
|
||||
if not truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
safe_diagnostics = strip_private_error_diagnostics(safe_diagnostics)
|
||||
return {
|
||||
"schema": schema,
|
||||
"status": "error",
|
||||
"method": method,
|
||||
"error": error,
|
||||
"diagnostics": diagnostics if diagnostics is not None else {"message": 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"}
|
||||
}
|
||||
|
||||
|
||||
@@ -2934,15 +3097,36 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
|
||||
has_code_edit = any(payload.get(field) is not None for field in code_fields)
|
||||
if not has_code_edit or target_kind not in {"module", "bsl_module", "bsl"}:
|
||||
return None
|
||||
owner_object_type = (
|
||||
payload.get("object_type")
|
||||
or target.get("object_type")
|
||||
or payload.get("owner_kind")
|
||||
or target.get("owner_kind")
|
||||
)
|
||||
if str(owner_object_type or "").strip().casefold() in {"module", "bsl_module", "bsl", "модуль"}:
|
||||
owner_object_type = None
|
||||
owner_object_name = (
|
||||
payload.get("object_name")
|
||||
or target.get("object_name")
|
||||
or payload.get("owner_name")
|
||||
or target.get("owner_name")
|
||||
)
|
||||
owner_object_guid = (
|
||||
payload.get("object_guid")
|
||||
or target.get("object_guid")
|
||||
or payload.get("owner_guid")
|
||||
or target.get("owner_guid")
|
||||
)
|
||||
suggested_payload = {
|
||||
"base_id": payload.get("base_id"),
|
||||
**{
|
||||
key: value
|
||||
for key, value in {
|
||||
"ref": payload.get("ref") or target.get("ref"),
|
||||
"object_type": payload.get("object_type") or target.get("object_type") or target.get("kind"),
|
||||
"object_name": payload.get("object_name") or target.get("object_name") or target.get("name"),
|
||||
"object_guid": payload.get("object_guid") or target.get("object_guid") or target.get("guid"),
|
||||
"module_ref": payload.get("module_ref") or target.get("module_ref"),
|
||||
"object_type": owner_object_type,
|
||||
"object_name": owner_object_name,
|
||||
"object_guid": owner_object_guid,
|
||||
"routine_name": payload.get("routine_name") or target.get("routine_name"),
|
||||
"routine_text": payload.get("routine_text"),
|
||||
"module_text": payload.get("module_text"),
|
||||
@@ -2968,6 +3152,125 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
|
||||
}
|
||||
|
||||
|
||||
def purge_expired_selector_tokens() -> None:
|
||||
cutoff = now_ts() - SELECTOR_TOKEN_TTL_SECONDS
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
expired = [token for token, entry in SELECTOR_TOKENS.items() if float(entry.get("created_at") or 0) < cutoff]
|
||||
for token in expired:
|
||||
SELECTOR_TOKENS.pop(token, None)
|
||||
|
||||
|
||||
def issue_selector_token(selector: dict[str, Any]) -> str:
|
||||
purge_expired_selector_tokens()
|
||||
token = f"onecsel_{uuid.uuid4().hex}"
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
SELECTOR_TOKENS[token] = {"created_at": now_ts(), "selector": dict(selector)}
|
||||
return token
|
||||
|
||||
|
||||
def diagnostic_mode_authorized(payload: dict[str, Any]) -> bool:
|
||||
"""Developer diagnostics are opt-in at deployment level, not an agent choice."""
|
||||
return (
|
||||
(truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic")))
|
||||
and truthy(os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC"))
|
||||
)
|
||||
|
||||
|
||||
def publicize_read_selectors(value: Any) -> Any:
|
||||
"""Replace adapter-issued technical continuations with short-lived opaque tokens."""
|
||||
if isinstance(value, list):
|
||||
return [publicize_read_selectors(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 TECHNICAL_AGENT_FIELDS:
|
||||
continue
|
||||
if key == "read_selector" and isinstance(item, dict) and str(item.get("method") or "").strip():
|
||||
public[key] = {"method": str(item["method"]), "selector_token": issue_selector_token(item)}
|
||||
elif key == "read_selectors" and isinstance(item, dict):
|
||||
public[key] = {
|
||||
name: (
|
||||
{"method": str(selector["method"]), "selector_token": issue_selector_token(selector)}
|
||||
if isinstance(selector, dict) and str(selector.get("method") or "").strip()
|
||||
else publicize_read_selectors(selector)
|
||||
)
|
||||
for name, selector in item.items()
|
||||
}
|
||||
else:
|
||||
public[key] = publicize_read_selectors(item)
|
||||
return public
|
||||
|
||||
|
||||
def resolve_selector_token(method: str, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
token = str(payload.get("selector_token") or "").strip()
|
||||
if not token:
|
||||
return payload, None
|
||||
purge_expired_selector_tokens()
|
||||
with SELECTOR_TOKEN_LOCK:
|
||||
entry = SELECTOR_TOKENS.get(token)
|
||||
selector = entry.get("selector") if isinstance(entry, dict) and isinstance(entry.get("selector"), dict) else None
|
||||
if not selector:
|
||||
return None, public_error(method, "selector_token_invalid", {"message": "selector_token is unknown or expired; repeat the public discovery call."})
|
||||
selector_method = str(selector.get("method") or "").strip()
|
||||
if selector_method != method:
|
||||
return None, public_error(method, "selector_token_method_mismatch", {"message": f"selector_token is valid only for `{selector_method}`."})
|
||||
explicit = {key: value for key, value in payload.items() if key != "selector_token"}
|
||||
resolved = {**selector, **explicit, "_selector_token_resolved": True}
|
||||
return resolved, None
|
||||
|
||||
|
||||
def technical_selector_fields(payload: Any) -> list[str]:
|
||||
"""Find technical selector keys at every JSON level supplied by an agent."""
|
||||
found: set[str] = set()
|
||||
if isinstance(payload, dict):
|
||||
for key, value in payload.items():
|
||||
if key in TECHNICAL_AGENT_FIELDS:
|
||||
found.add(key)
|
||||
found.update(technical_selector_fields(value))
|
||||
elif isinstance(payload, list):
|
||||
for value in payload:
|
||||
found.update(technical_selector_fields(value))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def normal_agent_technical_field_guardrail(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if diagnostic_mode_authorized(payload) or truthy(payload.get("_selector_token_resolved")):
|
||||
return None
|
||||
prohibited = technical_selector_fields(payload)
|
||||
if not prohibited:
|
||||
return None
|
||||
return {
|
||||
"schema": "adapter_1c_mcp_policy.v1",
|
||||
"status": "blocked",
|
||||
"method": method,
|
||||
"reason": "technical_selector_forbidden",
|
||||
"diagnostics": {
|
||||
"fields": prohibited,
|
||||
"message": "Use complete public 1C names (extension + ref + child name) or an adapter-issued selector_token. SQL/storage coordinates are developer diagnostics only.",
|
||||
"suggested_request": {
|
||||
"method": "metadata.object.full",
|
||||
"payload": {"base_id": payload.get("base_id"), "ref": payload.get("ref"), "configuration_view": "effective_working"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def runtime_form_inspection_unsupported(method: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if method not in {"runtime.form.elements.inspect", "runtime.form.inspect"}:
|
||||
return None
|
||||
return {
|
||||
"schema": "onec_runtime_form_inspection.v1",
|
||||
"status": "unsupported",
|
||||
"method": method,
|
||||
"error": "runtime_inspection_unsupported",
|
||||
"base_id": payload.get("base_id"),
|
||||
"diagnostics": {
|
||||
"message": "The SQL-only adapter does not open 1C forms, execute form handlers, or inspect runtime-generated controls. Read static metadata with metadata.form.decode; obtain runtime evidence through a separately authorised human-operated channel.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
request_start = now_ts()
|
||||
request_id = uuid.uuid4().hex
|
||||
@@ -2986,14 +3289,18 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
}
|
||||
request_payload["_mcp_request_id"] = request_id
|
||||
payload = request_payload
|
||||
runtime_guardrail = runtime_form_inspection_unsupported(method, payload)
|
||||
if runtime_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, runtime_guardrail, request_start)
|
||||
technical_field_guardrail = normal_agent_technical_field_guardrail(method, payload)
|
||||
if technical_field_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, technical_field_guardrail, request_start)
|
||||
code_guardrail = metadata_write_code_guardrail(method, payload)
|
||||
if code_guardrail is not None:
|
||||
return enrich_result_with_freshness(payload, method, code_guardrail, request_start)
|
||||
if method_requires_base_id(method) and not str(payload.get("base_id") or "").strip():
|
||||
return missing_base_id_policy(method)
|
||||
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not (
|
||||
truthy(payload.get("diagnostic")) or truthy(payload.get("_allow_diagnostic"))
|
||||
):
|
||||
if (method.startswith(DIAGNOSTIC_METHOD_PREFIXES) or method in DIAGNOSTIC_METHODS) and not diagnostic_mode_authorized(payload):
|
||||
return {
|
||||
"schema": "adapter_1c_mcp_policy.v1",
|
||||
"status": "blocked",
|
||||
@@ -3005,7 +3312,7 @@ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
|
||||
"Use metadata.object.attributes, metadata.object.full, metadata.object.forms, metadata.form.decode, "
|
||||
"metadata.resolve_overrides, code.search, code.read, modules.search, metadata.definition.find, templates.bindings, "
|
||||
"or modules.read. "
|
||||
"Pass diagnostic=true only for explicit adapter diagnostics."
|
||||
"Developer diagnostics require diagnostic=true and ONEC_MCP_ALLOW_DIAGNOSTIC=true in the MCP deployment."
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -3343,6 +3650,10 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
payload = args.get("payload") or {}
|
||||
if not isinstance(payload, dict):
|
||||
return tool_text(public_error(method or "onec_request", "invalid_payload", {"message": "payload must be an object"}))
|
||||
payload, selector_error = resolve_selector_token(method, payload)
|
||||
if selector_error is not None:
|
||||
return tool_text(selector_error)
|
||||
assert payload is not None
|
||||
if method in {"mcp.job.get", "adapter.job.get", "onec.job.get"}:
|
||||
job_id = str(payload.get("job_id") or "").strip()
|
||||
if not job_id:
|
||||
@@ -3356,7 +3667,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
):
|
||||
job = dict(job)
|
||||
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
|
||||
return tool_text(job)
|
||||
return tool_text(publicize_read_selectors(job))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.get", exc))
|
||||
if method in {"mcp.job.cancel", "adapter.job.cancel", "onec.job.cancel"}:
|
||||
@@ -3367,7 +3678,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
return tool_text(call_adapter_method("adapter.job.cancel", {"job_id": job_id}))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.cancel", exc))
|
||||
return tool_text(run_or_enqueue_adapter_method(method, payload))
|
||||
return tool_text(publicize_read_selectors(run_or_enqueue_adapter_method(method, payload)))
|
||||
if name == "onec_job_get":
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
if not job_id:
|
||||
@@ -3381,7 +3692,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any] | None) -> dict[str, A
|
||||
):
|
||||
job = dict(job)
|
||||
job["result"] = maybe_enrich_owner_fields(str(job.get("method") or "").strip(), job.get("payload"), job.get("result"))
|
||||
return tool_text(job)
|
||||
return tool_text(publicize_read_selectors(job))
|
||||
except AdapterError as exc:
|
||||
return tool_text(adapter_error_result("adapter.job.get", exc))
|
||||
if name == "onec_job_cancel":
|
||||
@@ -3460,7 +3771,10 @@ def handle_jsonrpc(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return jsonrpc_result(request_id, handle_tool_call(str(params.get("name") or ""), params.get("arguments") or {}))
|
||||
return jsonrpc_error(request_id, -32601, f"Method not found: {method}")
|
||||
except Exception as exc:
|
||||
return jsonrpc_error(request_id, -32000, str(exc), traceback.format_exc())
|
||||
data: dict[str, Any] = {"message": str(exc)}
|
||||
if truthy(os.environ.get("ONEC_MCP_DEBUG_DIAGNOSTICS")):
|
||||
data["traceback"] = traceback.format_exc()
|
||||
return jsonrpc_error(request_id, -32000, "MCP request failed", data)
|
||||
|
||||
|
||||
def payload_has_method(payload: Any, method: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Small periodic summary for MCP-to-REST availability telemetry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
path = Path("/data/mcp-audit.jsonl")
|
||||
rows: list[dict] = []
|
||||
malformed_rows = 0
|
||||
if path.exists():
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed_rows += 1
|
||||
continue
|
||||
if row.get("event") == "mcp_adapter_call":
|
||||
rows.append(row)
|
||||
failures = [row for row in rows if row.get("error") or row.get("status") == "exception"]
|
||||
availability = [row for row in failures if row.get("error") == "adapter_unavailable"]
|
||||
print(json.dumps({
|
||||
"schema": "onec_mcp_audit_summary.v1", "status": "ok" if path.exists() else "log_not_found",
|
||||
"events": len(rows), "malformed_rows": malformed_rows,
|
||||
"bases": dict(Counter(str((row.get("request") or {}).get("base_id") or "<none>") for row in rows)),
|
||||
"failures": len(failures),
|
||||
"failure_methods": dict(Counter(str(row.get("method") or "<none>") for row in failures)),
|
||||
"recent_failures": failures[-20:],
|
||||
"findings": [
|
||||
*([{"priority": "P1", "kind": "rest_unavailable_from_mcp", "count": len(availability), "next_action": "Check MCP-to-REST connectivity, then find the same request_id in REST telemetry if it exists."}] if availability else []),
|
||||
*([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect proxy container restarts and log rotation."}] if malformed_rows else []),
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY observer_server.py /app/observer_server.py
|
||||
COPY web /app/web
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV ONEC_OBSERVER_HOST=0.0.0.0
|
||||
ENV ONEC_OBSERVER_PORT=8031
|
||||
ENV ONEC_OBSERVER_AUDIT_DIR=/audit
|
||||
|
||||
EXPOSE 8031
|
||||
CMD ["python", "/app/observer_server.py"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Adapter Observer service
|
||||
|
||||
Standalone read-only analytics service for `adapter-1c` operational telemetry.
|
||||
|
||||
Run locally with:
|
||||
|
||||
```text
|
||||
ONEC_OBSERVER_AUDIT_DIR=<directory-with-adapter-audit.jsonl> python observer_server.py
|
||||
```
|
||||
|
||||
The production compose definition and operational contract are in
|
||||
`core/deploy/docker/adapter-observer/` and
|
||||
`docs/runbooks/adapter-observer.md` respectively. Do not make this service a
|
||||
dependency of the adapter or give it SQL credentials.
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Read-only operational observer for adapter-1c audit telemetry.
|
||||
|
||||
This service never connects to 1C SQL storage and never mutates adapter data.
|
||||
It reads the adapter's privacy-safe rotated JSONL files from a read-only mount.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from collections import Counter, defaultdict
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
WEB_ROOT = ROOT / "web"
|
||||
AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_AUDIT_DIR", "/audit"))
|
||||
MCP_AUDIT_DIR = Path(os.environ.get("ONEC_OBSERVER_MCP_AUDIT_DIR", "/mcp-audit"))
|
||||
STATE_DIR = Path(os.environ.get("ONEC_OBSERVER_STATE_DIR", "/state"))
|
||||
ADAPTER_URL = os.environ.get("ONEC_OBSERVER_ADAPTER_URL", "").rstrip("/")
|
||||
HOST = os.environ.get("ONEC_OBSERVER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("ONEC_OBSERVER_PORT", "8031"))
|
||||
MAX_ROWS = 10000
|
||||
|
||||
|
||||
def number(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
AUTO_COVERAGE_BASE = os.environ.get("ONEC_OBSERVER_COVERAGE_BASE_ID", "upo_test")
|
||||
AUTO_COVERAGE_INTERVAL = max(300, number(os.environ.get("ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS", "900")))
|
||||
LAST_COVERAGE: dict[str, object] = {"status": "not_started"}
|
||||
|
||||
|
||||
def percentile(values: list[int], q: float) -> int:
|
||||
if not values:
|
||||
return 0
|
||||
ordered = sorted(values)
|
||||
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * q) - 1))
|
||||
return ordered[index]
|
||||
|
||||
|
||||
def audit_files(directory: Path, prefix: str) -> list[Path]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
paths = [p for p in directory.glob(f"{prefix}*") if p.is_file()]
|
||||
return sorted(paths, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def read_events(directory: Path = AUDIT_DIR, prefix: str = "adapter-audit.jsonl", event_name: str = "adapter_rpc") -> tuple[list[dict], int]:
|
||||
events: list[dict] = []
|
||||
malformed = 0
|
||||
for path in audit_files(directory, prefix):
|
||||
try:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
malformed += 1
|
||||
continue
|
||||
if isinstance(row, dict) and row.get("event") == event_name:
|
||||
events.append(row)
|
||||
except OSError:
|
||||
continue
|
||||
return events[-MAX_ROWS:], malformed
|
||||
|
||||
|
||||
def event_view(row: dict, source: str = "rest") -> dict:
|
||||
request = row.get("request") if isinstance(row.get("request"), dict) else {}
|
||||
return {
|
||||
"source": source, "time": row.get("time"), "request_id": row.get("request_id"),
|
||||
"method": row.get("method"), "base_id": request.get("base_id"),
|
||||
"selector": {key: request.get(key) for key in ("ref", "kind", "name", "object_type", "object_name", "extension", "mode", "execution_mode") if request.get(key) not in (None, "")},
|
||||
"status": row.get("status") or "unknown", "error": row.get("error") or "",
|
||||
"exception_type": row.get("exception_type") or "", "duration_ms": number(row.get("duration_ms")),
|
||||
"result_duration_ms": row.get("result_duration_ms"),
|
||||
"result_summary": row.get("result_summary") if isinstance(row.get("result_summary"), dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def correlations(rest: list[dict], mcp: list[dict]) -> list[dict]:
|
||||
rest_by_id = {str(row.get("request_id")): row for row in rest if row.get("request_id")}
|
||||
rows = []
|
||||
for row in reversed(mcp):
|
||||
request_id = str(row.get("request_id") or "")
|
||||
if not request_id:
|
||||
continue
|
||||
rest_row = rest_by_id.get(request_id)
|
||||
mcp_view = event_view(row, "mcp")
|
||||
rows.append({"request_id": request_id, "mcp": mcp_view, "rest": event_view(rest_row, "rest") if rest_row else None, "correlation_status": "matched" if rest_row else "not_reached_rest"})
|
||||
return rows[:1000]
|
||||
|
||||
|
||||
def adapter_rpc(method: str, payload: dict) -> dict:
|
||||
if not ADAPTER_URL:
|
||||
raise RuntimeError("adapter_url_not_configured")
|
||||
request = Request(f"{ADAPTER_URL}/rpc", data=json.dumps({"method": method, "payload": payload}).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, method="POST")
|
||||
try:
|
||||
with urlopen(request, timeout=45) as response:
|
||||
value = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, TimeoutError) as exc:
|
||||
raise RuntimeError(f"adapter_read_failed:{type(exc).__name__}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("adapter_response_not_object")
|
||||
return value
|
||||
|
||||
|
||||
def coverage_snapshot(base_id: str) -> dict:
|
||||
methods = adapter_rpc("help.methods", {})
|
||||
audit = adapter_rpc("metadata.adapter.audit", {"base_id": base_id, "include_missing": True, "include_unmapped": True, "timeout_seconds": 45})
|
||||
snapshot = {"schema": "onec_adapter_observer_coverage.v1", "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "base_id": base_id, "methods": methods.get("methods") or [], "audit": audit}
|
||||
try:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
latest = STATE_DIR / f"coverage-{base_id}.json"
|
||||
previous = json.loads(latest.read_text(encoding="utf-8")) if latest.exists() else None
|
||||
if isinstance(previous, dict):
|
||||
old_methods = {str(item.get("name")) for item in previous.get("methods") or [] if isinstance(item, dict)}
|
||||
new_methods = {str(item.get("name")) for item in snapshot["methods"] if isinstance(item, dict)}
|
||||
def kind_counts(value: dict) -> dict[str, int]:
|
||||
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
|
||||
return {str(item.get("kind")): number(item.get("count")) for item in audit_value.get("metadata_kinds") or [] if isinstance(item, dict)}
|
||||
old_kinds, new_kinds = kind_counts(previous), kind_counts(snapshot)
|
||||
changed_kinds = [{"kind": kind, "before": old_kinds.get(kind, 0), "after": new_kinds.get(kind, 0)} for kind in sorted(set(old_kinds) | set(new_kinds)) if old_kinds.get(kind, 0) != new_kinds.get(kind, 0)]
|
||||
def unresolved(value: dict) -> set[str]:
|
||||
audit_value = value.get("audit") if isinstance(value.get("audit"), dict) else {}
|
||||
return {json.dumps(item, ensure_ascii=False, sort_keys=True) if isinstance(item, dict) else str(item) for item in audit_value.get("not_yet_decoded") or []}
|
||||
old_unresolved, new_unresolved = unresolved(previous), unresolved(snapshot)
|
||||
snapshot["comparison"] = {"previous_captured_at": previous.get("captured_at"), "methods_added": sorted(new_methods - old_methods), "methods_removed": sorted(old_methods - new_methods), "kind_count_changes": changed_kinds, "undecoded_added": sorted(new_unresolved - old_unresolved), "undecoded_removed": sorted(old_unresolved - new_unresolved)}
|
||||
temporary = STATE_DIR / "coverage-latest.json.tmp"
|
||||
temporary.write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(latest)
|
||||
history_path = STATE_DIR / f"coverage-{base_id}.history.jsonl"
|
||||
history = history_path.read_text(encoding="utf-8", errors="replace").splitlines()[-49:] if history_path.exists() else []
|
||||
history.append(json.dumps(snapshot, ensure_ascii=False))
|
||||
history_path.write_text("\n".join(history) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
snapshot["persistence_status"] = "unavailable"
|
||||
return snapshot
|
||||
|
||||
|
||||
def coverage_worker() -> None:
|
||||
"""Best-effort periodic read-only snapshot; failure must not stop the UI."""
|
||||
while True:
|
||||
try:
|
||||
snapshot = coverage_snapshot(AUTO_COVERAGE_BASE)
|
||||
LAST_COVERAGE.update({"status": "ok", "captured_at": snapshot.get("captured_at"), "base_id": AUTO_COVERAGE_BASE})
|
||||
except RuntimeError as exc:
|
||||
LAST_COVERAGE.update({"status": "error", "base_id": AUTO_COVERAGE_BASE, "error": str(exc), "checked_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
time.sleep(AUTO_COVERAGE_INTERVAL)
|
||||
|
||||
|
||||
def recommendation(event: dict) -> str:
|
||||
error = str(event.get("error") or "")
|
||||
status = str(event.get("status") or "")
|
||||
if error == "time_budget_exhausted":
|
||||
return "Сузить публичный selector (ref, форма или routine) либо выполнить тяжёлую операцию как job."
|
||||
if error == "public_write_route_unresolved":
|
||||
return "Передать request_id и resolver summary разработчикам адаптера; не подбирать storage coordinates вручную."
|
||||
if error == "ambiguous_fragment":
|
||||
return "Уточнить routine_name или заменить модуль целиком; фрагмент не должен подбираться по совпадению."
|
||||
if error == "base_id_required":
|
||||
return "Передать base_id из списка сконфигурированных баз; не пытаться подставлять SQL-параметры."
|
||||
if status in {"unsupported", "blocked", "invalid_argument"}:
|
||||
return "Это безопасная остановка. Проверить публичный контракт метода и next_action в результате."
|
||||
if status == "exception":
|
||||
return "Найти совпадающий request_id в REST/MCP telemetry и воспроизвести только на upo_test."
|
||||
return "Повторить read-операцию с тем же публичным selector-ом и сравнить длительность/статус."
|
||||
|
||||
|
||||
def build_summary(events: list[dict], malformed: int) -> dict:
|
||||
durations = [number(row.get("duration_ms")) for row in events]
|
||||
failures = [row for row in events if str(row.get("status")) == "exception"]
|
||||
groups: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
|
||||
for row in events:
|
||||
groups[(str(row.get("method") or "<none>"), str(row.get("status") or "unknown"), str(row.get("error") or ""))].append(row)
|
||||
findings = []
|
||||
normal_lifecycle = {"ok", "accepted", "running", "done", "cancelled", "not_found", "unknown"}
|
||||
for (method, status, error), rows in sorted(groups.items(), key=lambda item: len(item[1]), reverse=True):
|
||||
if status in normal_lifecycle and not error:
|
||||
continue
|
||||
finding = event_view(rows[-1])
|
||||
findings.append({"method": method, "status": status, "error": error, "count": len(rows), "last_request_id": finding["request_id"], "recommendation": recommendation(finding)})
|
||||
if len(findings) >= 20:
|
||||
break
|
||||
per_method: dict[str, list[int]] = defaultdict(list)
|
||||
for row in events:
|
||||
per_method[str(row.get("method") or "<none>")].append(number(row.get("duration_ms")))
|
||||
methods = [{"method": name, "calls": len(values), "p50_ms": percentile(values, .5), "p95_ms": percentile(values, .95), "max_ms": max(values)} for name, values in per_method.items()]
|
||||
slow = sorted((event_view(row) for row in events if number(row.get("duration_ms")) >= 5000), key=lambda row: row["duration_ms"], reverse=True)[:20]
|
||||
return {"schema": "onec_adapter_observer_summary.v1", "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "events": len(events), "malformed_rows": malformed, "exceptions": len(failures), "p50_ms": percentile(durations, .5), "p95_ms": percentile(durations, .95), "max_ms": max(durations, default=0), "methods": sorted(methods, key=lambda row: row["p95_ms"], reverse=True), "findings": findings, "slow_events": slow}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AdapterObserver/1.0"
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, status: int, value: object) -> None:
|
||||
body = json.dumps(value, ensure_ascii=False).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.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def serve_file(self, relative: str) -> None:
|
||||
target = (WEB_ROOT / relative).resolve()
|
||||
if WEB_ROOT not in target.parents and target != WEB_ROOT or not target.is_file():
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
body = target.read_bytes()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", mimetypes.guess_type(str(target))[0] or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/health":
|
||||
self.send_json(200, {"status": "ok", "service": "adapter-observer", "audit_dir": str(AUDIT_DIR), "files": [path.name for path in audit_files(AUDIT_DIR, "adapter-audit.jsonl")], "mcp_files": [path.name for path in audit_files(MCP_AUDIT_DIR, "mcp-audit.jsonl")], "coverage": LAST_COVERAGE})
|
||||
return
|
||||
events, malformed = read_events()
|
||||
mcp_events, mcp_malformed = read_events(MCP_AUDIT_DIR, "mcp-audit.jsonl", "mcp_adapter_call")
|
||||
if parsed.path == "/api/summary":
|
||||
self.send_json(200, build_summary(events, malformed))
|
||||
return
|
||||
if parsed.path == "/api/events":
|
||||
query = parse_qs(parsed.query)
|
||||
method, status, base_id = query.get("method", [""])[0], query.get("status", [""])[0], query.get("base_id", [""])[0]
|
||||
minimum_duration = number(query.get("min_duration_ms", [0])[0])
|
||||
since, until = query.get("since", [""])[0], query.get("until", [""])[0]
|
||||
rows = [event_view(row) for row in reversed(events)]
|
||||
if method: rows = [row for row in rows if row["method"] == method]
|
||||
if status: rows = [row for row in rows if row["status"] == status]
|
||||
if base_id: rows = [row for row in rows if row["base_id"] == base_id]
|
||||
if minimum_duration: rows = [row for row in rows if row["duration_ms"] >= minimum_duration]
|
||||
if since: rows = [row for row in rows if str(row.get("time") or "") >= since]
|
||||
if until: rows = [row for row in rows if str(row.get("time") or "") <= until]
|
||||
limit = min(max(number(query.get("limit", [200])[0]), 1), 1000)
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_events.v1", "events": rows[:limit], "total": len(rows), "malformed_rows": malformed})
|
||||
return
|
||||
if parsed.path == "/api/mcp-events":
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_mcp_events.v1", "events": [event_view(row, "mcp") for row in reversed(mcp_events[-1000:])], "total": len(mcp_events), "malformed_rows": mcp_malformed})
|
||||
return
|
||||
if parsed.path == "/api/correlations":
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_correlations.v1", "correlations": correlations(events, mcp_events), "mcp_events": len(mcp_events), "mcp_malformed_rows": mcp_malformed})
|
||||
return
|
||||
if parsed.path == "/api/coverage":
|
||||
base_id = parse_qs(parsed.query).get("base_id", ["upo_test"])[0]
|
||||
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё0-9_.-]{1,80}", base_id):
|
||||
self.send_json(400, {"error": "invalid_base_id"})
|
||||
return
|
||||
try:
|
||||
self.send_json(200, coverage_snapshot(base_id))
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path == "/api/objects":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, kind = query.get("base_id", ["upo"])[0], query.get("kind", [""])[0]
|
||||
if not kind:
|
||||
self.send_json(400, {"error": "kind_required"})
|
||||
return
|
||||
try:
|
||||
started = time.monotonic()
|
||||
result = adapter_rpc("metadata.objects.list", {"base_id": base_id, "kind": kind, "limit": min(max(number(query.get("limit", [200])[0]), 1), 1000), "offset": max(number(query.get("offset", [0])[0]), 0), "refresh_cache": True, "exact_counts": True})
|
||||
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": "metadata.objects.list"}})
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path == "/api/object":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
|
||||
if not ref:
|
||||
self.send_json(400, {"error": "ref_required"})
|
||||
return
|
||||
started = time.monotonic()
|
||||
sections = {}
|
||||
for name, method in (("attributes", "metadata.object.attributes"), ("forms", "metadata.object.forms"), ("modules", "metadata.object.modules"), ("templates", "metadata.object.templates")):
|
||||
section_started = time.monotonic()
|
||||
try:
|
||||
result = adapter_rpc(method, {"base_id": base_id, "ref": ref})
|
||||
sections[name] = {"status": result.get("status", "unknown"), "data": result, "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
|
||||
except RuntimeError as exc:
|
||||
sections[name] = {"status": "error", "error": str(exc), "duration_ms": int((time.monotonic() - section_started) * 1000), "method": method}
|
||||
self.send_json(200, {"schema": "onec_adapter_observer_object_node.v1", "base_id": base_id, "ref": ref, "status": "ok", "duration_ms": int((time.monotonic() - started) * 1000), "sections": sections})
|
||||
return
|
||||
if parsed.path == "/api/object/action":
|
||||
query = parse_qs(parsed.query)
|
||||
base_id, ref = query.get("base_id", ["upo"])[0], query.get("ref", [""])[0]
|
||||
action = query.get("action", [""])[0]
|
||||
actions = {
|
||||
"card": "metadata.object.get", "properties": "metadata.object.properties",
|
||||
"attributes": "metadata.object.attributes", "forms": "metadata.object.forms",
|
||||
"commands": "metadata.object.commands", "modules": "metadata.object.modules",
|
||||
"templates": "metadata.object.templates", "related": "metadata.object.related",
|
||||
}
|
||||
if not ref:
|
||||
self.send_json(400, {"error": "ref_required"})
|
||||
return
|
||||
if action not in actions:
|
||||
self.send_json(400, {"error": "unsupported_action", "supported_actions": list(actions)})
|
||||
return
|
||||
started = time.monotonic()
|
||||
try:
|
||||
result = adapter_rpc(actions[action], {"base_id": base_id, "ref": ref})
|
||||
self.send_json(200, {**result, "observer": {"duration_ms": int((time.monotonic() - started) * 1000), "method": actions[action]}})
|
||||
except RuntimeError as exc:
|
||||
self.send_json(502, {"error": str(exc)})
|
||||
return
|
||||
if parsed.path in {"/", "/index.html"}:
|
||||
self.serve_file("index.html")
|
||||
return
|
||||
if parsed.path.startswith("/assets/"):
|
||||
self.serve_file(parsed.path.lstrip("/"))
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_HEAD(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
known = parsed.path in {"/", "/index.html", "/health", "/api/summary", "/api/events", "/api/mcp-events", "/api/correlations", "/api/coverage", "/api/objects", "/api/object", "/api/object/action"} or parsed.path.startswith("/assets/")
|
||||
self.send_response(HTTPStatus.OK if known else HTTPStatus.NOT_FOUND)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if ADAPTER_URL:
|
||||
threading.Thread(target=coverage_worker, name="coverage-snapshot", daemon=True).start()
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,54 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
let allEvents = [];
|
||||
|
||||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
||||
const duration = (value) => {
|
||||
const seconds = Math.max(0, Number(value || 0) / 1000);
|
||||
const format = (number) => Number(number.toFixed(1)).toString().replace(".", ",");
|
||||
if (seconds < 60) return `${format(seconds)} с`;
|
||||
const minutes = Math.floor(seconds / 60), restSeconds = seconds - minutes * 60;
|
||||
if (minutes < 60) return `${minutes} мин ${format(restSeconds)} с`;
|
||||
return `${Math.floor(minutes / 60)} ч ${minutes % 60} мин ${format(restSeconds)} с`;
|
||||
};
|
||||
async function api(path) { const response = await fetch(path); if (!response.ok) throw new Error(await response.text()); return response.json(); }
|
||||
function displayDetails(value) { if (Array.isArray(value)) return value.map(displayDetails); if (!value || typeof value !== "object") return value; return Object.fromEntries(Object.entries(value).map(([key, item]) => /^(duration|result_duration|p50|p95|max)_ms$/.test(key) ? [key.slice(0, -3), duration(item)] : [key, displayDetails(item)])); }
|
||||
function showDetails(row) { const received = row.received || row, observer = row.observer || received.observer || {}, method = row.action || observer.method || received.method || "Детали"; $("#detail-title").textContent = actionLabels?.[method] || method; $("#detail-meta").textContent = [row.ref || received.ref || received.base_id, received.status, observer.duration_ms === undefined ? "" : duration(observer.duration_ms)].filter(Boolean).join(" · "); $("#detail-json").textContent = JSON.stringify(displayDetails(row), null, 2); $("#detail").showModal(); }
|
||||
|
||||
function renderEvents() {
|
||||
const method = $("#method").value.trim(), status = $("#status").value, base = $("#base").value.trim(), minimum = Number($("#min-duration").value || 0) * 1000, since = $("#since").value, until = $("#until").value;
|
||||
const rows = allEvents.filter((event) => (!method || event.method.includes(method)) && (!status || event.status === status) && (!base || event.base_id === base) && event.duration_ms >= minimum && (!since || event.time >= since) && (!until || event.time <= until));
|
||||
$("#events").innerHTML = rows.map((event, index) => `<tr><td class="muted">${esc(event.time)}</td><td><b>${esc(event.method)}</b><br><span class="muted">${esc(event.selector.ref || event.selector.object_name || event.base_id || "—")}</span></td><td class="status ${esc(event.status)}">${esc(event.status)}</td><td>${duration(event.duration_ms)}</td><td>${esc(event.error || event.exception_type || "—")}</td><td><button data-row="${index}">Детали</button></td></tr>`).join("") || '<tr><td colspan="6" class="muted">Запросов по выбранному фильтру нет.</td></tr>';
|
||||
$("#events").querySelectorAll("button").forEach((button) => { button.onclick = () => showDetails(rows[Number(button.dataset.row)]); });
|
||||
}
|
||||
|
||||
const treeGroups = [["Общие",["CommonModule","CommonForm","CommonCommand","CommonAttribute","CommonPicture","CommonTemplate","Constant","DefinedType","Role","Subsystem","ScheduledJob","EventSubscription","FunctionalOption","SessionParameter"]],["Справочники",["Catalog"]],["Документы",["Document","DocumentJournal","DocumentNumerator","Sequence"]],["Перечисления",["Enum"]],["Отчёты и обработки",["Report","DataProcessor"]],["Планы",["ChartOfAccounts","ChartOfCharacteristicTypes","ChartOfCalculationTypes","ExchangePlan"]],["Регистры",["InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister"]],["Бизнес-процессы и задачи",["BusinessProcess","Task"]],["Сервисы и интеграции",["WebService","HTTPService","IntegrationService","ExternalDataSource","XDTOPackage"]]];
|
||||
const catalogActions = [["card", "Карточка"], ["properties", "Свойства"], ["attributes", "Реквизиты"], ["forms", "Формы"], ["commands", "Команды"], ["modules", "Модули"], ["templates", "Макеты"], ["related", "Связи"]];
|
||||
const actionLabels = Object.fromEntries(catalogActions);
|
||||
document.addEventListener("click", (event) => { const button = event.target.closest("summary button[data-kind]"); if (button) { event.preventDefault(); button.closest("details").open = true; } }, true);
|
||||
function objectActions(base, object) { const ref = object.ref || `Catalog.${object.name}`; return `<span class="object-actions">${catalogActions.map(([action, label]) => `<button data-action="${action}" data-base="${esc(base)}" data-ref="${esc(ref)}">${label}</button>`).join("")}</span>`; }
|
||||
async function runObjectAction(button) { const { action, base, ref } = button.dataset; button.disabled = true; const label = button.textContent; button.textContent = "…"; try { const result = await api(`/api/object/action?base_id=${encodeURIComponent(base)}&ref=${encodeURIComponent(ref)}&action=${encodeURIComponent(action)}`); showDetails({ action, ref, duration: duration(result.observer?.duration_ms), received: result }); } catch (error) { showDetails({ action, ref, error: error.message }); } finally { button.disabled = false; button.textContent = label; } }
|
||||
function bindObjectActions(root) { root.querySelectorAll("button[data-action]").forEach((button) => { button.onclick = () => runObjectAction(button); }); if (!root.querySelector(".object-actions")) return; const search = document.createElement("input"), status = document.createElement("small"), rows = [...root.querySelectorAll(".object-row")]; search.className = "catalog-search"; search.type = "search"; search.placeholder = "Найти справочник"; search.setAttribute("aria-label", "Найти справочник"); status.className = "catalog-filter-status muted"; root.prepend(status); root.prepend(search); const filter = () => { const query = search.value.trim().toLocaleLowerCase(); let visible = 0; rows.forEach((row) => { const matches = !query || row.textContent.toLocaleLowerCase().includes(query); row.hidden = !matches; if (matches) visible += 1; }); status.textContent = `Показано: ${visible} из ${rows.length}`; }; search.oninput = filter; filter(); }
|
||||
async function loadAllKindObjects(base, kind) {
|
||||
const pageSize = 1000, first = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=0`), objects = [...(first.objects || [])], total = Number(first.counts?.total_visible ?? first.counts?.total ?? objects.length);
|
||||
let elapsed = Number(first.observer?.duration_ms || 0);
|
||||
for (let offset = objects.length; offset < total; offset += pageSize) { const page = await api(`/api/objects?base_id=${encodeURIComponent(base)}&kind=${encodeURIComponent(kind)}&limit=${pageSize}&offset=${offset}`), rows = page.objects || []; elapsed += Number(page.observer?.duration_ms || 0); objects.push(...rows); if (!rows.length) break; }
|
||||
return { ...first, objects, observer: { ...(first.observer || {}), duration_ms: elapsed } };
|
||||
}
|
||||
async function tree() {
|
||||
const root = $("#tree-result"), base = $("#tree-base").value.trim() || "upo"; root.textContent = "Читаю структуру конфигурации…";
|
||||
try {
|
||||
const data = await api(`/api/coverage?base_id=${encodeURIComponent(base)}`), kinds = (data.audit || {}).metadata_kinds || [], byKind = Object.fromEntries(kinds.map((x) => [x.kind, x]));
|
||||
root.innerHTML = `<h2>Конфигурация: ${esc(base)}</h2>${treeGroups.map(([title, names]) => { const rows = names.map((name) => byKind[name]).filter(Boolean); if (!rows.length) return ""; if (rows.length === 1) { const item = rows[0]; return `<details class="finding"><summary><b>▸ ${title}</b> · ${item.count || 0} объектов · <button data-kind="${esc(item.kind)}">↻ Читать</button></summary><div id="kind-${esc(item.kind)}"></div></details>`; } return `<details class="finding"><summary><b>▸ ${title}</b> · ${rows.reduce((sum, item) => sum + Number(item.count || 0), 0)} объектов</summary>${rows.map((item) => `<div> ├─ ${esc(item.kind_ru || item.kind)} · ${item.count || 0} <button data-kind="${esc(item.kind)}">↻ Читать</button><div id="kind-${esc(item.kind)}"></div></div>`).join("")}</details>`; }).join("")}`;
|
||||
root.querySelectorAll("button[data-kind]").forEach((button) => { button.onclick = async () => { const box = $("#kind-" + button.dataset.kind); box.textContent = "⏳ чтение всех записей…"; try { const result = await loadAllKindObjects(base, button.dataset.kind), objects = result.objects || [], observer = result.observer || {}; box.innerHTML = `<small class="muted">${objects.length} объектов · ${duration(observer.duration_ms)} · ${observer.method || ""} · <button class="node-log">Журнал</button></small>${objects.map((object) => `<div class="object-row"> └─ <span>${esc(object.name || object.ref || "—")}</span>${button.dataset.kind === "Catalog" ? objectActions(base, object) : ""}</div>`).join("") || "Нет объектов."}`; box.querySelector(".node-log").onclick = () => showDetails({ received: result, observer }); bindObjectActions(box); } catch (error) { box.textContent = `Не удалось загрузить объекты: ${error.message}`; } }; });
|
||||
} catch (error) { root.textContent = error.message; }
|
||||
}
|
||||
|
||||
function renderSummary(summary) { $("#cards").innerHTML = [["Запросов", summary.events], ["Исключений", summary.exceptions], ["p50", duration(summary.p50_ms)], ["p95", duration(summary.p95_ms)]].map(([label, value]) => `<div class="card">${label}<b>${value}</b></div>`).join(""); const maximum = Math.max(...summary.methods.map((item) => item.p95_ms), 1); $("#methods").innerHTML = summary.methods.slice(0, 12).map((item) => `<div class="bar"><span>${esc(item.method)} <small class="muted">${item.calls}</small></span><i style="width:${Math.max(2, item.p95_ms / maximum * 100)}%"></i><span>${duration(item.p95_ms)}</span></div>`).join(""); const findings = summary.findings.map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${esc(item.status)} · ${item.count} раз<br><span class="muted">${esc(item.error || "без кода")}</span><p>${esc(item.recommendation)}</p></div>`).join("") || '<p class="muted">Отклонений нет.</p>'; const slow = (summary.slow_events || []).map((item) => `<div class="finding"><b>${esc(item.method)}</b> · ${duration(item.duration_ms)} · <span class="muted">${esc(item.request_id || "—")}</span><br>${esc(item.selector.ref || item.selector.object_name || item.base_id || "без selector-а")}</div>`).join("") || '<p class="muted">Нет.</p>'; $("#findings").innerHTML = `${findings}<h2>Выбросы ≥ 5 сек</h2>${slow}`; }
|
||||
function renderCorrelations(data) { $("#correlations").innerHTML = data.correlations.slice(0, 250).map((item) => `<tr><td class="muted">${esc(item.request_id)}</td><td>${esc(item.mcp.method)}</td><td class="${esc(item.mcp.status)}">${esc(item.mcp.status)}</td><td class="${item.rest ? "ok" : "exception"}">${item.rest ? esc(item.rest.status) : "не достиг REST"}</td><td>${duration(item.mcp.duration_ms)}${item.rest ? ` / ${duration(item.rest.duration_ms)}` : ""}</td></tr>`).join("") || '<tr><td colspan="5" class="muted">Коррелируемых событий нет.</td></tr>'; }
|
||||
async function openKind(kind, baseId) { const root = $("#object-list"); root.textContent = `Загружаю ${kind}…`; try { const data = await api(`/api/objects?base_id=${encodeURIComponent(baseId)}&kind=${encodeURIComponent(kind)}`), objects = data.objects || []; root.innerHTML = `<h2>${esc(kind)} · ${objects.length}</h2><div class="table-wrap"><table><thead><tr><th>Объект</th><th>Синоним</th><th>Происхождение</th></tr></thead><tbody>${objects.map((item) => `<tr><td>${esc(item.name || item.ref || "—")}</td><td>${esc(item.synonym || "—")}</td><td>${esc((item.origin || {}).source || "—")}</td></tr>`).join("") || '<tr><td colspan="3" class="muted">Объектов нет.</td></tr>'}</tbody></table></div>`; } catch (error) { root.textContent = `Не удалось загрузить объекты: ${error.message}`; } }
|
||||
async function coverage() { const root = $("#coverage-result"); root.textContent = "Читаю контракт и coverage snapshot…"; try { const baseId = $("#coverage-base").value.trim() || "upo", data = await api(`/api/coverage?base_id=${encodeURIComponent(baseId)}`), audit = data.audit || {}; if (audit.status && audit.status !== "ok") { root.innerHTML = `<div class="finding"><b>База недоступна для metadata coverage: ${esc(audit.status)}</b><p>Это не нулевое покрытие.</p></div>`; return; } const kinds = audit.metadata_kinds || []; root.innerHTML = `<h2>База ${esc(baseId)} → типы метаданных</h2><div class="table-wrap"><table><thead><tr><th>Тип</th><th>Объектов</th><th></th></tr></thead><tbody>${kinds.map((item) => `<tr><td>└ ${esc(item.kind || "—")}</td><td>${esc(item.count || 0)}</td><td><button class="open-kind" data-kind="${esc(item.kind)}">Открыть</button></td></tr>`).join("")}</tbody></table></div><div id="object-list" class="muted"></div>`; root.querySelectorAll(".open-kind").forEach((button) => { button.onclick = () => openKind(button.dataset.kind, baseId); }); } catch (error) { root.textContent = `Не удалось получить coverage: ${error.message}`; } }
|
||||
async function load() { const [events, summary, correlations] = await Promise.all([api("/api/events?limit=500"), api("/api/summary"), api("/api/correlations")]); allEvents = events.events; renderEvents(); renderSummary(summary); renderCorrelations(correlations); }
|
||||
document.querySelectorAll(".tab").forEach((button) => { button.onclick = () => { document.querySelectorAll(".tab,.panel").forEach((node) => node.classList.remove("active")); button.classList.add("active"); $(`#${button.dataset.tab}`).classList.add("active"); }; });
|
||||
["method", "base", "min-duration", "since", "until"].forEach((id) => { $(`#${id}`).oninput = renderEvents; });
|
||||
$("#status").onchange = renderEvents; $("#refresh").onclick = load; $("#load-coverage").onclick = coverage; $("#close").onclick = () => $("#detail").close(); $("#load-tree").onclick = tree;
|
||||
load().catch((error) => { $("#events").innerHTML = `<tr><td colspan="6">Ошибка загрузки: ${esc(error.message)}</td></tr>`; });
|
||||
@@ -0,0 +1,13 @@
|
||||
:root{--ink:#e8edf1;--muted:#8f9ba6;--ground:#11161a;--panel:#182126;--line:#2b383f;--accent:#65d0b0;--warn:#f0bc65;--bad:#f27f77;--radius:7px}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--ground);color:var(--ink);font:14px ui-monospace,"Cascadia Code",monospace}
|
||||
header{min-height:92px;padding:22px max(24px,5vw);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:#141b1f}
|
||||
h1{margin:0;font:600 27px Georgia,serif;letter-spacing:.02em}.eyebrow{margin:0 0 5px;color:var(--accent);font-size:11px;letter-spacing:.12em}
|
||||
button,input,select{font:inherit;color:inherit;background:#202c31;border:1px solid var(--line);border-radius:5px;padding:9px 11px}button{cursor:pointer}button:hover,.tab.active{border-color:var(--accent);color:var(--accent)}button:disabled{cursor:wait;opacity:.65}
|
||||
main{max-width:1500px;margin:auto;padding:22px}nav{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px}.panel{display:none;padding-top:20px}.panel.active{display:block}.filters{display:flex;gap:10px;margin-bottom:14px}.filters input{min-width:280px}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius)}table{width:100%;border-collapse:collapse}th{text-align:left;color:var(--muted);font-weight:400;background:#141b1f}th,td{padding:11px 12px;border-bottom:1px solid #243137;vertical-align:top}tr:last-child td{border:0}
|
||||
.status{font-size:12px}.ok{color:var(--accent)}.partial,.blocked,.unsupported,.invalid_argument{color:var(--warn)}.exception{color:var(--bad)}.muted{color:var(--muted)}
|
||||
.cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.card{background:var(--panel);border-left:3px solid var(--accent);padding:16px;border-radius:0 var(--radius) var(--radius) 0}.card b{font-size:25px;display:block;margin-top:7px}h2{font:600 18px Georgia,serif;margin:30px 0 12px}.bar{display:grid;grid-template-columns:220px 1fr 80px;gap:12px;align-items:center;margin:8px 0}.bar i{height:8px;background:linear-gradient(90deg,var(--accent),var(--warn));display:block}.finding{padding:12px;border:1px solid var(--line);margin:8px 0;background:var(--panel)}
|
||||
.catalog-search{display:block;width:min(460px,100%);margin:10px 0;padding:7px 9px}.object-row{display:flex;align-items:flex-start;gap:10px;padding:5px 0}.object-actions{display:flex;flex-wrap:wrap;gap:5px}.object-actions button{padding:4px 7px;font-size:11px}
|
||||
dialog{width:min(850px,94vw);color:var(--ink);background:#11181c;border:1px solid var(--accent);border-radius:var(--radius)}dialog pre{white-space:pre-wrap;overflow:auto;max-height:70vh}dialog button{float:right}
|
||||
@media(max-width:720px){main{padding:14px}.cards{grid-template-columns:repeat(2,1fr)}.bar{grid-template-columns:1fr}.filters input{min-width:0;width:100%}header{padding:18px}.filters{flex-direction:column}.object-row{display:block}.object-actions{margin:6px 0 0 18px}}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Adapter Observer</title><link rel="stylesheet" href="/assets/style.css"></head>
|
||||
<body><header><div><p class="eyebrow">1C / SQL-ONLY / READ-ONLY</p><h1>Adapter Observer</h1></div><button id="refresh">Обновить</button></header>
|
||||
<main><nav><button class="tab active" data-tab="journal">Журнал запросов</button><button class="tab" data-tab="tree">Дерево объектов</button><button class="tab" data-tab="analytics">Аналитика</button><button class="tab" data-tab="transport">MCP ↔ REST</button><button class="tab" data-tab="coverage">Покрытие</button></nav>
|
||||
<section id="journal" class="panel active"><div class="filters"><input id="method" placeholder="Метод"><input id="base" placeholder="base_id"><input id="min-duration" type="number" min="0" step="0.1" placeholder="Мин. длительность, с"><input id="since" type="datetime-local" title="С"><input id="until" type="datetime-local" title="По"><select id="status"><option value="">Все статусы</option><option>ok</option><option>partial</option><option>blocked</option><option>unsupported</option><option>invalid_argument</option><option>exception</option></select></div><div class="table-wrap"><table><thead><tr><th>Время</th><th>Метод / объект</th><th>Статус</th><th>Длительность</th><th>Причина</th><th></th></tr></thead><tbody id="events"></tbody></table></div></section>
|
||||
<section id="tree" class="panel"><div class="filters"><input id="tree-base" value="upo" placeholder="base_id"><button id="load-tree">Загрузить базу</button></div><div id="tree-result" class="muted">База ещё не загружена.</div></section>
|
||||
<section id="analytics" class="panel"><div id="cards" class="cards"></div><h2>Медленные методы</h2><div id="methods"></div><h2>Требуют внимания</h2><div id="findings"></div></section>
|
||||
<section id="transport" class="panel"><h2>Корреляция транспорта</h2><p class="muted">MCP-событие без REST-пары означает, что вызов не дошёл до адаптера.</p><div class="table-wrap"><table><thead><tr><th>request_id</th><th>Метод</th><th>MCP</th><th>REST</th><th>Время</th></tr></thead><tbody id="correlations"></tbody></table></div></section>
|
||||
<section id="coverage" class="panel"><div class="filters"><input id="coverage-base" value="upo" placeholder="base_id"><button id="load-coverage">Обновить снимок</button></div><div id="coverage-result" class="muted">Снимок ещё не загружен.</div></section></main>
|
||||
<dialog id="detail"><button id="close" aria-label="Закрыть">×</button><h2 id="detail-title">Детали</h2><p id="detail-meta" class="muted"></p><pre id="detail-json"></pre></dialog><script src="/assets/app.js"></script></body></html>
|
||||
@@ -25,6 +25,11 @@ def normalized_text_sha1(text: str) -> str:
|
||||
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _text_with_line_ending(text: str, line_ending: str) -> str:
|
||||
"""Normalize caller text first, then render it in a stream's convention."""
|
||||
return str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending)
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> tuple[str | None, str | None]:
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
try:
|
||||
@@ -121,6 +126,188 @@ def stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[st
|
||||
return blocks
|
||||
|
||||
|
||||
def structural_stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Read one contiguous stream chain without matching headers inside data.
|
||||
|
||||
Some 1C stream payloads legitimately contain the ASCII sequence used by a
|
||||
stream header inside a binary member. ``stream_blocks_with_data`` remains
|
||||
a discovery heuristic for legacy readers; this function follows only the
|
||||
next header located exactly at the previous member's end and is suitable
|
||||
for evidence-bearing module decoding.
|
||||
"""
|
||||
blocks: list[dict[str, Any]] = []
|
||||
first = STREAM_HEADER_RE.search(payload)
|
||||
if first is None:
|
||||
return blocks
|
||||
match = first
|
||||
while match is not None and len(blocks) < limit:
|
||||
declared_1 = int(match.group(1), 16)
|
||||
declared_2 = int(match.group(2), 16)
|
||||
data_offset = match.end()
|
||||
data_end = data_offset + declared_2
|
||||
if declared_2 <= 0 or data_end > len(payload):
|
||||
break
|
||||
data = payload[data_offset:data_end]
|
||||
text, encoding = decode_text(data)
|
||||
blocks.append(
|
||||
{
|
||||
"header_offset": match.start(),
|
||||
"header_end": match.end(),
|
||||
"data_offset": data_offset,
|
||||
"data_end": data_end,
|
||||
"declared_1": declared_1,
|
||||
"declared_2": declared_2,
|
||||
"bytes": len(data),
|
||||
"sha1": sha1_hex(data),
|
||||
"encoding": encoding,
|
||||
"text": text,
|
||||
"data": data,
|
||||
"structural": True,
|
||||
}
|
||||
)
|
||||
match = STREAM_HEADER_RE.match(payload, data_end)
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_structural_stream_blocks(payload: bytes, *, include_text: bool = False, limit: int = 100) -> list[dict[str, Any]]:
|
||||
"""Public structural stream view with the same shape as discovery blocks."""
|
||||
result: list[dict[str, Any]] = []
|
||||
for block in structural_stream_blocks_with_data(payload, limit=limit):
|
||||
text = str(block.get("text") or "")
|
||||
clean = text.replace("\x00", "")
|
||||
item = {
|
||||
**{key: value for key, value in block.items() if key not in {"text", "data"}},
|
||||
"text_preview": clean[:500],
|
||||
"has_bsl_marker": bool(text and any(marker in clean for marker in BSL_MARKERS)),
|
||||
"has_html_marker": bool(text and any(marker in clean for marker in HTML_MARKERS)),
|
||||
}
|
||||
if include_text:
|
||||
item["text"] = block.get("text")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def decode_declared_utf8_bsl_prefix(payload: bytes, stream_index: int) -> dict[str, Any]:
|
||||
"""Decode a BSL prefix whose byte length is declared by a stream header.
|
||||
|
||||
Object-module containers observed in 1C keep the editable UTF-8 BSL bytes
|
||||
in the first ``declared_1`` bytes of a fixed-size member. The remaining
|
||||
member bytes are opaque platform metadata, not source text. This helper
|
||||
is read-only evidence; it intentionally does not construct replacements.
|
||||
"""
|
||||
blocks = structural_stream_blocks_with_data(payload)
|
||||
if stream_index < 0 or stream_index >= len(blocks):
|
||||
return {"status": "not_found", "error": "stream_index_not_found"}
|
||||
block = blocks[stream_index]
|
||||
data = bytes(block["data"])
|
||||
prefix_bytes = int(block["declared_1"])
|
||||
if prefix_bytes <= 0 or prefix_bytes > len(data):
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"error": "invalid_declared_bsl_prefix_length",
|
||||
"declared_1": prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
}
|
||||
prefix = data[:prefix_bytes]
|
||||
if not prefix.startswith(b"\xef\xbb\xbf"):
|
||||
return {
|
||||
"status": "unsupported",
|
||||
"error": "declared_bsl_prefix_not_utf8_bom",
|
||||
"declared_1": prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
}
|
||||
try:
|
||||
text = prefix.decode("utf-8-sig", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
return {"status": "unsupported", "error": "declared_bsl_prefix_decode_error", "diagnostics": {"message": str(exc)}}
|
||||
return {
|
||||
"status": "ok",
|
||||
"text": text,
|
||||
"stream_index": stream_index,
|
||||
"header_offset": block["header_offset"],
|
||||
"data_offset": block["data_offset"],
|
||||
"bsl_prefix_bytes": prefix_bytes,
|
||||
"opaque_tail_bytes": len(data) - prefix_bytes,
|
||||
"member_bytes": len(data),
|
||||
"text_sha1": normalized_text_sha1(text),
|
||||
"structural": True,
|
||||
}
|
||||
|
||||
|
||||
def replace_declared_utf8_bsl_prefix_same_width(
|
||||
payload: bytes,
|
||||
stream_index: int,
|
||||
*,
|
||||
text: str,
|
||||
expected_text_sha1: str | None = None,
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""Replace a proven fixed-width BSL prefix without touching its tail.
|
||||
|
||||
This is deliberately narrower than ``replace_stream_block``. The report
|
||||
object-module carrier has a fixed-size stream member whose first declared
|
||||
bytes are UTF-8 source and whose remaining bytes are opaque. A shorter
|
||||
source is right-padded with spaces *inside the declared source field*;
|
||||
longer source is rejected. Consequently the member, every following
|
||||
stream, and the opaque tail stay byte-for-byte identical.
|
||||
|
||||
It does not attempt to synthesize the platform's independent version
|
||||
atoms. The caller remains responsible for the proven paired
|
||||
``__configinfo`` SHA-1 update.
|
||||
"""
|
||||
decoded = decode_declared_utf8_bsl_prefix(payload, stream_index)
|
||||
if decoded.get("status") != "ok":
|
||||
raise ValueError(str(decoded.get("error") or "declared_bsl_prefix_unavailable"))
|
||||
old_text = str(decoded["text"])
|
||||
if not is_declared_utf8_bsl_source(old_text):
|
||||
raise ValueError("declared_bsl_prefix_is_not_bsl_source")
|
||||
old_sha1 = normalized_text_sha1(old_text)
|
||||
if expected_text_sha1 and expected_text_sha1.lower() != old_sha1:
|
||||
raise ValueError("expected_text_sha1 does not match declared BSL prefix")
|
||||
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
|
||||
rendered = _text_with_line_ending(text, line_ending)
|
||||
encoded = b"\xef\xbb\xbf" + rendered.encode("utf-8")
|
||||
prefix_bytes = int(decoded["bsl_prefix_bytes"])
|
||||
if len(encoded) > prefix_bytes:
|
||||
raise ValueError("replacement_declared_bsl_prefix_exceeds_fixed_width")
|
||||
# BSL whitespace outside string literals is semantically inert. Padding
|
||||
# is restricted to the fixed source field and is observable in readback.
|
||||
padded = encoded + (b" " * (prefix_bytes - len(encoded)))
|
||||
if len(padded) != prefix_bytes:
|
||||
raise AssertionError("declared BSL prefix width changed")
|
||||
data_offset = int(decoded["data_offset"])
|
||||
new_payload = payload[:data_offset] + padded + payload[data_offset + prefix_bytes :]
|
||||
if payload[data_offset + prefix_bytes :] != new_payload[data_offset + prefix_bytes :]:
|
||||
raise AssertionError("opaque member tail changed")
|
||||
return new_payload, {
|
||||
"stream_index": stream_index,
|
||||
"mode": "declared_utf8_bsl_prefix_same_width",
|
||||
"old_text_sha1": old_sha1,
|
||||
"new_text_sha1": normalized_text_sha1(rendered),
|
||||
"old_bsl_prefix_bytes": prefix_bytes,
|
||||
"new_bsl_source_bytes": len(encoded),
|
||||
"padding_bytes": prefix_bytes - len(encoded),
|
||||
"opaque_tail_bytes": int(decoded["opaque_tail_bytes"]),
|
||||
"opaque_tail_preserved": True,
|
||||
"old_text_preview": old_text[:500],
|
||||
"new_text_preview": rendered[:500],
|
||||
}
|
||||
|
||||
|
||||
def is_declared_utf8_bsl_source(text: str) -> bool:
|
||||
"""Recognize source evidence in a declared UTF-8 stream prefix.
|
||||
|
||||
A module may legitimately consist solely of comments, while other stream
|
||||
members can also have a UTF-8 prefix (for example a brace descriptor).
|
||||
The prefix is BSL evidence only when it has a normal BSL marker or every
|
||||
nonblank source line is a BSL line comment.
|
||||
"""
|
||||
source = str(text or "").lstrip("\ufeff")
|
||||
if any(marker in source for marker in BSL_MARKERS):
|
||||
return True
|
||||
lines = [line.strip() for line in source.replace("\r\n", "\n").replace("\r", "\n").split("\n") if line.strip()]
|
||||
return bool(lines) and all(line.startswith("//") for line in lines)
|
||||
|
||||
|
||||
def stream_header(size: int) -> bytes:
|
||||
if size < 0 or size > 0xFFFFFFFF:
|
||||
raise ValueError("stream size is outside 8-hex header range")
|
||||
@@ -159,9 +346,20 @@ def replace_stream_block(
|
||||
if not old:
|
||||
raise ValueError("replace.old is required")
|
||||
count = int(replace.get("count") or 1)
|
||||
if old not in old_text:
|
||||
# Public code.read normalizes BSL to LF while streams often retain
|
||||
# CRLF. Treat that representation difference as irrelevant, but do
|
||||
# not loosen matching of any other character (spaces/tabs remain
|
||||
# exact). The replacement is rendered back in the stream's original
|
||||
# line-ending convention to avoid unrelated formatting churn.
|
||||
line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n"
|
||||
source_old = old
|
||||
source_new = _text_with_line_ending(new, line_ending) if ("\n" in new or "\r" in new) else new
|
||||
if source_old not in old_text:
|
||||
source_old = _text_with_line_ending(old, line_ending)
|
||||
source_new = _text_with_line_ending(new, line_ending)
|
||||
if source_old not in old_text:
|
||||
raise ValueError("replace.old was not found in stream text")
|
||||
text = old_text.replace(old, new, count)
|
||||
text = old_text.replace(source_old, source_new, count)
|
||||
routine_edit = None
|
||||
if routine is not None:
|
||||
if old_text is None:
|
||||
@@ -261,7 +459,29 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = decoded.get("payload") if isinstance(decoded.get("payload"), (bytes, bytearray)) else b""
|
||||
markers = payload_markers(bytes(payload))
|
||||
stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text)
|
||||
# Prefer proven contiguous boundaries for normal container decoding. Keep
|
||||
# the regex scan only as a discovery fallback for legacy irregular blobs.
|
||||
stream_blocks = extract_structural_stream_blocks(bytes(payload), include_text=include_text)
|
||||
if not stream_blocks and "stream_headers" in markers:
|
||||
stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text)
|
||||
# A report object module observed in ConfigCAS stores source in the
|
||||
# declared UTF-8 prefix of a fixed-size stream member. The remainder is
|
||||
# opaque platform state and must never be exposed as BSL. Keep this as
|
||||
# read-only evidence: replacement still requires a separately proven
|
||||
# reverse codec for that carrier.
|
||||
if stream_blocks:
|
||||
for stream_index, stream in enumerate(stream_blocks):
|
||||
declared_prefix = decode_declared_utf8_bsl_prefix(bytes(payload), stream_index)
|
||||
if declared_prefix.get("status") != "ok" or not is_declared_utf8_bsl_source(str(declared_prefix.get("text") or "")):
|
||||
continue
|
||||
stream["declared_utf8_bsl_prefix"] = {
|
||||
key: declared_prefix[key]
|
||||
for key in ("bsl_prefix_bytes", "opaque_tail_bytes", "member_bytes", "text_sha1", "structural")
|
||||
if key in declared_prefix
|
||||
}
|
||||
if include_text:
|
||||
stream["text"] = declared_prefix["text"]
|
||||
stream["text_preview"] = str(declared_prefix["text"] or "").replace("\x00", "")[:500]
|
||||
text = decoded.get("text")
|
||||
tree = None
|
||||
root = None
|
||||
|
||||
@@ -2202,7 +2202,16 @@ def section_record_semantic_properties(row: dict[str, Any], parameters: list[dic
|
||||
mapped: set[int] = set()
|
||||
for index, (group, name) in SECTION_RECORD_SEMANTIC_PROPERTIES.items():
|
||||
mapped.add(index)
|
||||
add_grouped_property(groups, group, semantic_property(name, parameter_value(parameters, index), index=index))
|
||||
value = parameter_value(parameters, index)
|
||||
source = "form_payload"
|
||||
# A managed-form record can store the localized title outside its
|
||||
# direct parameter #3. The row decoder already resolves that exact
|
||||
# title path, so expose it instead of misleading an agent with an
|
||||
# empty semantic «Заголовок» beside a non-empty public row.title.
|
||||
if index == 3 and value in {None, ""} and row.get("title") not in {None, ""}:
|
||||
value = row.get("title")
|
||||
source = "form_payload_title_path"
|
||||
add_grouped_property(groups, group, semantic_property(name, value, index=index, source=source))
|
||||
if row.get("category"):
|
||||
add_grouped_property(groups, "Основные", semantic_property("Категория", row.get("category"), source="decoder"))
|
||||
add_grouped_property(groups, "Основные", semantic_property("Вид", row.get("category"), source="decoder"))
|
||||
@@ -3850,6 +3859,37 @@ def enrich_button_command_semantics(items: list[dict[str, Any]], links: list[dic
|
||||
)
|
||||
|
||||
|
||||
FORM_AUXILIARY_ITEM_TYPES = {
|
||||
"Контекстное меню", "Расширенная подсказка", "SearchStringAddition",
|
||||
"ViewStatusAddition", "SearchControlAddition",
|
||||
}
|
||||
|
||||
|
||||
def form_item_coverage_summary(items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Report semantic coverage without auxiliary form records hiding control quality."""
|
||||
buckets = {
|
||||
"all_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
"interactive_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
"auxiliary_items": {"items": 0, "mapped": 0, "unmapped": 0},
|
||||
}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
coverage = (item.get("semantic") or {}).get("coverage") if isinstance(item.get("semantic"), dict) else None
|
||||
if not isinstance(coverage, dict):
|
||||
continue
|
||||
target = "auxiliary_items" if str(item.get("type_name") or "") in FORM_AUXILIARY_ITEM_TYPES else "interactive_items"
|
||||
for bucket_name in ("all_items", target):
|
||||
bucket = buckets[bucket_name]
|
||||
bucket["items"] += 1
|
||||
bucket["mapped"] += int(coverage.get("mapped") or 0)
|
||||
bucket["unmapped"] += int(coverage.get("unmapped") or 0)
|
||||
for bucket in buckets.values():
|
||||
bucket["total"] = bucket["mapped"] + bucket["unmapped"]
|
||||
bucket["status"] = "partial" if bucket["unmapped"] else "ok"
|
||||
return buckets
|
||||
|
||||
|
||||
def decode_form_payload(
|
||||
tree: Any,
|
||||
*,
|
||||
@@ -3906,11 +3946,13 @@ def decode_form_payload(
|
||||
form_parameters = form_common_parameters(tree, limit=max_parameters)
|
||||
form_semantic = form_common_semantic(form_parameters, include_diagnostics=include_parameters)
|
||||
enrich_form_common_semantic(form_semantic, items)
|
||||
coverage_summary = form_item_coverage_summary(items)
|
||||
result = {
|
||||
"schema": "onec_form_payload_profile.v1",
|
||||
"status": "ok" if root.get("root_marker") == "4" else "not_form_payload",
|
||||
"root": root,
|
||||
"form_semantic": form_semantic,
|
||||
"item_coverage": coverage_summary,
|
||||
**({"form_parameters": form_parameters} if include_parameters else {}),
|
||||
"events": events,
|
||||
"items": items,
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Lossless read-only decoder for 1C Data Composition Schema SQL payloads.
|
||||
|
||||
The payload stored in ConfigCAS is commonly a compressed stream with a small
|
||||
binary prefix followed by an XML ``SchemaFile`` document. This module does
|
||||
not infer SCD semantics from names: every returned item is backed by an XML
|
||||
node in that document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import xml.parsers.expat as expat
|
||||
import html
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .payload import decode_payload_lossless
|
||||
|
||||
|
||||
QUERY_PARAMETER_RE = re.compile(r"&([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)")
|
||||
QUERY_SOURCE_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)", re.IGNORECASE)
|
||||
QUERY_SOURCE_BINDING_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)(?:\s+(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*))?", re.IGNORECASE)
|
||||
QUERY_SELECT_RE = re.compile(r"\b(?:ВЫБРАТЬ|SELECT)\b(.*?)(?=\b(?:ИЗ|FROM)\b)", re.IGNORECASE | re.DOTALL)
|
||||
QUERY_ALIAS_RE = re.compile(r"\b(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?=\s*(?:,|\r?\n|$))", re.IGNORECASE)
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
|
||||
|
||||
def direct_child(node: ET.Element, name: str) -> ET.Element | None:
|
||||
return next((child for child in node if local_name(child.tag) == name), None)
|
||||
|
||||
|
||||
def child_text(node: ET.Element, *names: str) -> str:
|
||||
for name in names:
|
||||
child = direct_child(node, name)
|
||||
if child is not None:
|
||||
value = "".join(child.itertext()).strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def query_without_line_comments(text: str) -> str:
|
||||
"""Remove 1C query ``//`` comments without touching quoted string literals."""
|
||||
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
quoted = False
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if char == '"':
|
||||
result.append(char)
|
||||
if quoted and index + 1 < len(text) and text[index + 1] == '"':
|
||||
result.append('"')
|
||||
index += 2
|
||||
continue
|
||||
quoted = not quoted
|
||||
index += 1
|
||||
continue
|
||||
if not quoted and char == "/" and index + 1 < len(text) and text[index + 1] == "/":
|
||||
line_end = text.find("\n", index)
|
||||
if line_end < 0:
|
||||
break
|
||||
result.append("\n")
|
||||
index = line_end + 1
|
||||
continue
|
||||
result.append(char)
|
||||
index += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def node_path(root: ET.Element, target: ET.Element) -> str:
|
||||
"""Produce a stable, human-readable evidence path without XML prefixes."""
|
||||
|
||||
def visit(node: ET.Element, prefix: str) -> str | None:
|
||||
name = local_name(node.tag)
|
||||
current = f"{prefix}/{name}" if prefix else f"/{name}"
|
||||
if node is target:
|
||||
return current
|
||||
positions: dict[str, int] = {}
|
||||
for child in node:
|
||||
child_name = local_name(child.tag)
|
||||
positions[child_name] = positions.get(child_name, 0) + 1
|
||||
child_prefix = f"{current}[{positions[child_name]}]"
|
||||
found = visit(child, child_prefix)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
return visit(root, "") or "/"
|
||||
|
||||
|
||||
def xml_from_scd_payload(data: bytes) -> tuple[ET.Element | None, dict[str, Any]]:
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = decoded.get("payload")
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
return None, {"status": "undecodable", "code": "SCD_PAYLOAD_EMPTY"}
|
||||
raw = bytes(payload)
|
||||
start = raw.find(b"<?xml")
|
||||
if start < 0:
|
||||
start = raw.find(b"<SchemaFile")
|
||||
if start < 0:
|
||||
return None, {
|
||||
"status": "undecodable",
|
||||
"code": "SCD_XML_NOT_FOUND",
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
}
|
||||
# 1C appends a binary trailer after the XML document in some releases.
|
||||
# ElementTree correctly rejects that trailer, so keep the exact XML range.
|
||||
end_marker = b"</SchemaFile>"
|
||||
end = raw.find(end_marker, start)
|
||||
xml_bytes = raw[start : end + len(end_marker)] if end >= 0 else raw[start:]
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes.decode("utf-8-sig"))
|
||||
except (UnicodeDecodeError, ET.ParseError) as exc:
|
||||
return None, {
|
||||
"status": "undecodable",
|
||||
"code": "SCD_XML_INVALID",
|
||||
"message": str(exc),
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
}
|
||||
return root, {
|
||||
"status": "ok",
|
||||
"compression": decoded.get("compression"),
|
||||
"raw_bytes": decoded.get("raw_bytes"),
|
||||
"payload_bytes": decoded.get("payload_bytes"),
|
||||
"xml_offset": start,
|
||||
"xml_bytes": len(xml_bytes),
|
||||
"xml_root": local_name(root.tag),
|
||||
}
|
||||
|
||||
|
||||
def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str, Any]:
|
||||
"""Return only direct, documented XML values for one SCD item."""
|
||||
|
||||
item_name = child_text(node, "name", "dataPath", "field")
|
||||
if not item_name and not list(node):
|
||||
item_name = (node.text or "").strip()
|
||||
item: dict[str, Any] = {
|
||||
"name": item_name,
|
||||
"source": {"kind": "scd_xml", "path": node_path(root, node)},
|
||||
}
|
||||
expression = child_text(node, "expression")
|
||||
if expression:
|
||||
item["expression"] = expression
|
||||
query = child_text(node, "query")
|
||||
if query:
|
||||
item["query"] = query
|
||||
value_type_node = direct_child(node, "valueType")
|
||||
if value_type_node is None:
|
||||
value_type_node = direct_child(node, "type")
|
||||
value_type = ""
|
||||
if value_type_node is not None:
|
||||
value_type = child_text(value_type_node, "type") or (value_type_node.text or "").strip()
|
||||
if value_type:
|
||||
item["value_type"] = value_type
|
||||
if category == "datasets":
|
||||
item["type"] = node.attrib.get("{http://www.w3.org/2001/XMLSchema-instance}type") or node.attrib.get("type") or ""
|
||||
return item
|
||||
|
||||
|
||||
def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Decode a DataCompositionSchema XML stream from SQL storage.
|
||||
|
||||
Unknown or absent XML nodes become empty lists. They are deliberately not
|
||||
synthesized from report code or form attributes.
|
||||
"""
|
||||
|
||||
requested = sections or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"]
|
||||
root, container = xml_from_scd_payload(data)
|
||||
if root is None:
|
||||
return {"status": "partial", "container": container, "sections": {name: [] for name in requested}}
|
||||
schema = next((node for node in root.iter() if local_name(node.tag) == "dataCompositionSchema"), None)
|
||||
if schema is None:
|
||||
return {
|
||||
"status": "partial",
|
||||
"container": {**container, "code": "SCD_SCHEMA_NODE_NOT_FOUND"},
|
||||
"sections": {name: [] for name in requested},
|
||||
}
|
||||
node_names = {
|
||||
"parameters": {"parameter"},
|
||||
"datasets": {"dataSet"},
|
||||
"fields": {"field"},
|
||||
"calculated_fields": {"calculatedField"},
|
||||
"resources": {"resource"},
|
||||
"settings": {"settings", "Settings"},
|
||||
"variants": {"settingsVariant", "variant"},
|
||||
"total_fields": {"totalField"},
|
||||
}
|
||||
result: dict[str, list[dict[str, Any]]] = {}
|
||||
skipped_unnamed: dict[str, int] = {}
|
||||
for section in requested:
|
||||
names = node_names.get(section)
|
||||
if not names:
|
||||
result[section] = []
|
||||
continue
|
||||
raw_items = [scd_node_item(schema, node, section) for node in schema.iter() if local_name(node.tag) in names]
|
||||
result[section] = [item for item in raw_items if item.get("name")]
|
||||
if len(raw_items) != len(result[section]):
|
||||
skipped_unnamed[section] = len(raw_items) - len(result[section])
|
||||
declared = [str(item.get("name")) for item in result.get("parameters") or [] if item.get("name")]
|
||||
declared_by_normalized = {name.casefold(): name for name in declared}
|
||||
query_references: list[dict[str, Any]] = []
|
||||
referenced_normalized: set[str] = set()
|
||||
for dataset in result.get("datasets") or []:
|
||||
references: list[str] = []
|
||||
for found in QUERY_PARAMETER_RE.finditer(query_without_line_comments(str(dataset.get("query") or ""))):
|
||||
name = found.group(1)
|
||||
if name.casefold() not in {value.casefold() for value in references}:
|
||||
references.append(name)
|
||||
referenced_normalized.add(name.casefold())
|
||||
if references:
|
||||
query_references.append({"dataset": dataset.get("name"), "parameters": references})
|
||||
analysis = {
|
||||
"kind": "raw_query_parameter_token_scan",
|
||||
"declared_parameters": declared,
|
||||
"query_parameter_references": query_references,
|
||||
"referenced_not_declared_in_schema": sorted(
|
||||
{name for item in query_references for name in item["parameters"] if name.casefold() not in declared_by_normalized},
|
||||
key=str.casefold,
|
||||
),
|
||||
"declared_not_referenced_in_dataset_queries": [name for name in declared if name.casefold() not in referenced_normalized],
|
||||
}
|
||||
settings_tags = {
|
||||
"groupings": {"groupItems", "grouping"},
|
||||
"filters": {"selection", "filter"},
|
||||
"orders": {"order", "sorting"},
|
||||
"conditional_appearance": {"appearance", "conditionalAppearance"},
|
||||
}
|
||||
settings_context: dict[str, Any] = {"status": "not_present", "sections": {}}
|
||||
for context_name, tags in settings_tags.items():
|
||||
nodes = [node for node in schema.iter() if local_name(node.tag) in tags]
|
||||
if not nodes:
|
||||
continue
|
||||
records: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
tokens = []
|
||||
for child in node.iter():
|
||||
if local_name(child.tag) not in {"field", "dataPath", "left", "right", "group"} or list(child):
|
||||
continue
|
||||
value = (child.text or "").strip()
|
||||
if value and value.casefold() not in {item.casefold() for item in tokens}:
|
||||
tokens.append(value)
|
||||
if tokens:
|
||||
records.append({"path": node_path(schema, node), "tokens": tokens})
|
||||
if records:
|
||||
settings_context["status"] = "found"
|
||||
settings_context["sections"][context_name] = records
|
||||
analysis["settings_context"] = settings_context
|
||||
query_sources: list[dict[str, Any]] = []
|
||||
query_output_aliases: list[dict[str, Any]] = []
|
||||
for dataset in result.get("datasets") or []:
|
||||
query = query_without_line_comments(str(dataset.get("query") or ""))
|
||||
sources = list(dict.fromkeys(match.group(1) for match in QUERY_SOURCE_RE.finditer(query)))
|
||||
if sources:
|
||||
bindings = []
|
||||
for match in QUERY_SOURCE_BINDING_RE.finditer(query):
|
||||
source, alias = match.group(1), match.group(2)
|
||||
item = {"source": source}
|
||||
if alias:
|
||||
item["alias"] = alias
|
||||
if item not in bindings:
|
||||
bindings.append(item)
|
||||
query_sources.append({"dataset": dataset.get("name"), "sources": sources, "bindings": bindings})
|
||||
select_match = QUERY_SELECT_RE.search(query)
|
||||
if select_match:
|
||||
aliases = list(dict.fromkeys(match.group(1) for match in QUERY_ALIAS_RE.finditer(select_match.group(1))))
|
||||
if aliases:
|
||||
query_output_aliases.append({"dataset": dataset.get("name"), "aliases": aliases})
|
||||
if query_sources:
|
||||
analysis["data_source_references"] = {"kind": "raw_query_source_token_scan", "datasets": query_sources}
|
||||
direct_field_references: list[dict[str, Any]] = []
|
||||
for dataset in query_sources:
|
||||
query = query_without_line_comments(str(next((item.get("query") for item in result.get("datasets") or [] if item.get("name") == dataset.get("dataset")), "")))
|
||||
references: list[dict[str, str]] = []
|
||||
for binding in dataset.get("bindings") or []:
|
||||
alias = str(binding.get("alias") or "")
|
||||
if not alias:
|
||||
continue
|
||||
matcher = re.compile(r"\b" + re.escape(alias) + r"\.([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?![A-Za-z0-9_\u0400-\u04ff.])", re.IGNORECASE)
|
||||
for match in matcher.finditer(query):
|
||||
item = {"alias": alias, "field": match.group(1)}
|
||||
if item not in references:
|
||||
references.append(item)
|
||||
if references:
|
||||
direct_field_references.append({"dataset": dataset.get("dataset"), "references": references})
|
||||
if direct_field_references:
|
||||
analysis["query_direct_field_references"] = {"kind": "direct_alias_field_token_scan", "datasets": direct_field_references}
|
||||
field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("fields") or [] if item.get("name")}
|
||||
calculated_field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("calculated_fields") or [] if item.get("name")}
|
||||
declared_field_names = {**field_names, **calculated_field_names}
|
||||
total_names = [str(item.get("name")) for item in result.get("total_fields") or [] if item.get("name")]
|
||||
if total_names:
|
||||
analysis["total_field_references"] = {
|
||||
"fields": total_names,
|
||||
"missing_from_declared_fields": [name for name in total_names if name.casefold() not in declared_field_names],
|
||||
"status": "checked" if "fields" in result and "calculated_fields" in result else "field_sections_not_requested",
|
||||
}
|
||||
if query_output_aliases:
|
||||
analysis["query_output_aliases"] = {
|
||||
"kind": "select_clause_alias_scan",
|
||||
"datasets": query_output_aliases,
|
||||
"not_declared_as_scd_fields": sorted(
|
||||
{
|
||||
alias
|
||||
for dataset in query_output_aliases
|
||||
for alias in dataset["aliases"]
|
||||
if alias.casefold() not in declared_field_names
|
||||
},
|
||||
key=str.casefold,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"container": container,
|
||||
"sections": result,
|
||||
"analysis": analysis,
|
||||
"diagnostics": {"skipped_unnamed_xml_nodes": skipped_unnamed} if skipped_unnamed else {},
|
||||
}
|
||||
|
||||
|
||||
def plan_scd_scalar_patch(
|
||||
data: bytes,
|
||||
*,
|
||||
section: str,
|
||||
name: str,
|
||||
property_name: str,
|
||||
value: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a byte-preserving patch for one direct scalar SCD XML property.
|
||||
|
||||
Only query/expression properties are accepted in this first writer layer.
|
||||
The XML element span is collected by Expat from the original byte stream;
|
||||
all bytes outside the scalar content stay unchanged, including the 1C
|
||||
binary prefix/trailer. No database operation is performed here.
|
||||
"""
|
||||
|
||||
allowed = {
|
||||
"datasets": ({"dataSet"}, {"query"}),
|
||||
"calculated_fields": ({"calculatedField"}, {"expression"}),
|
||||
"resources": ({"resource"}, {"expression"}),
|
||||
}
|
||||
tags_and_properties = allowed.get(section)
|
||||
if not tags_and_properties or property_name not in tags_and_properties[1]:
|
||||
return {
|
||||
"status": "invalid_argument",
|
||||
"code": "SCD_PATCH_PROPERTY_UNSUPPORTED",
|
||||
"message": "Only datasets.query, calculated_fields.expression, and resources.expression are writable.",
|
||||
}
|
||||
root, container = xml_from_scd_payload(data)
|
||||
if root is None:
|
||||
return {"status": "undecodable", "container": container}
|
||||
decoded = decode_payload_lossless(data)
|
||||
payload = bytes(decoded["payload"])
|
||||
xml_start = payload.find(b"<?xml")
|
||||
if xml_start < 0:
|
||||
xml_start = payload.find(b"<SchemaFile")
|
||||
xml_end_marker = b"</SchemaFile>"
|
||||
xml_end = payload.find(xml_end_marker, xml_start)
|
||||
if xml_start < 0 or xml_end < 0:
|
||||
return {"status": "undecodable", "container": container}
|
||||
xml_end += len(xml_end_marker)
|
||||
xml = payload[xml_start:xml_end]
|
||||
target_tags = tags_and_properties[0]
|
||||
stack: list[dict[str, Any]] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
def start_element(tag: str, _attrs: dict[str, str]) -> None:
|
||||
local = local_name(tag)
|
||||
position = parser.CurrentByteIndex
|
||||
end = xml.find(b">", position)
|
||||
frame: dict[str, Any] = {"tag": local, "depth": len(stack) + 1, "content_start": end + 1}
|
||||
if local in target_tags:
|
||||
frame["record"] = {"tag": local, "depth": len(stack) + 1, "properties": {}}
|
||||
if stack:
|
||||
parent_record = next((item.get("record") for item in reversed(stack) if item.get("record")), None)
|
||||
if parent_record and len(stack) + 1 == parent_record["depth"] + 1 and local in {"name", "dataPath", property_name}:
|
||||
frame["property_record"] = parent_record
|
||||
stack.append(frame)
|
||||
|
||||
def end_element(_tag: str) -> None:
|
||||
frame = stack.pop()
|
||||
end = parser.CurrentByteIndex
|
||||
property_record = frame.get("property_record")
|
||||
if property_record is not None:
|
||||
raw_text = xml[int(frame["content_start"]):end]
|
||||
if b"<" not in raw_text:
|
||||
property_record["properties"][frame["tag"]] = {
|
||||
"start": int(frame["content_start"]),
|
||||
"end": end,
|
||||
"text": html.unescape(raw_text.decode("utf-8")),
|
||||
}
|
||||
record = frame.get("record")
|
||||
if record is not None:
|
||||
identity = record["properties"].get("name") or record["properties"].get("dataPath")
|
||||
record["name"] = identity.get("text") if identity else ""
|
||||
records.append(record)
|
||||
|
||||
parser = expat.ParserCreate()
|
||||
parser.StartElementHandler = start_element
|
||||
parser.EndElementHandler = end_element
|
||||
try:
|
||||
parser.Parse(xml, True)
|
||||
except expat.ExpatError as exc:
|
||||
return {"status": "undecodable", "container": container, "code": "SCD_XML_INVALID", "message": str(exc)}
|
||||
matches = [record for record in records if str(record.get("name") or "") == name]
|
||||
if not matches:
|
||||
return {"status": "not_found", "code": "SCD_PATCH_TARGET_NOT_FOUND", "container": container}
|
||||
if len(matches) > 1:
|
||||
return {"status": "ambiguous", "code": "SCD_PATCH_TARGET_AMBIGUOUS", "container": container, "matches": len(matches)}
|
||||
property_record = (matches[0].get("properties") or {}).get(property_name)
|
||||
if not property_record:
|
||||
return {"status": "not_found", "code": "SCD_PATCH_PROPERTY_NOT_FOUND", "container": container}
|
||||
old = str(property_record["text"])
|
||||
if old == value:
|
||||
return {"status": "unchanged", "container": container, "old": old, "new": value}
|
||||
escaped = html.escape(value, quote=False).encode("utf-8")
|
||||
patched_xml = xml[: property_record["start"]] + escaped + xml[property_record["end"] :]
|
||||
patched_payload = payload[:xml_start] + patched_xml + payload[xml_end:]
|
||||
from .payload import encode_payload_lossless
|
||||
patched_data = encode_payload_lossless(decoded, payload=patched_payload)
|
||||
return {
|
||||
"status": "planned",
|
||||
"container": container,
|
||||
"old": old,
|
||||
"new": value,
|
||||
"payload": patched_data,
|
||||
"expected_sha1": hashlib.sha1(data).hexdigest(),
|
||||
"result_sha1": hashlib.sha1(patched_data).hexdigest(),
|
||||
"changed_bytes": len(patched_data) - len(data),
|
||||
}
|
||||
|
||||
|
||||
def compare_scd_semantics(active: dict[str, Any], saved: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare decoded SCD sections by semantic content, never by storage id."""
|
||||
|
||||
section_names = sorted(set((active.get("sections") or {}).keys()) | set((saved.get("sections") or {}).keys()))
|
||||
sections: dict[str, dict[str, Any]] = {}
|
||||
counts = {"added": 0, "removed": 0, "changed": 0, "unchanged": 0}
|
||||
for section in section_names:
|
||||
def index(items: Any) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for ordinal, item in enumerate(items or []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("name") or f"#{ordinal}")
|
||||
result[key] = {key: value for key, value in item.items() if key != "source"}
|
||||
return result
|
||||
active_items, saved_items = index((active.get("sections") or {}).get(section)), index((saved.get("sections") or {}).get(section))
|
||||
added = sorted(set(saved_items) - set(active_items), key=str.casefold)
|
||||
removed = sorted(set(active_items) - set(saved_items), key=str.casefold)
|
||||
changed = sorted([name for name in set(active_items) & set(saved_items) if active_items[name] != saved_items[name]], key=str.casefold)
|
||||
unchanged = len(set(active_items) & set(saved_items)) - len(changed)
|
||||
sections[section] = {"added": added, "removed": removed, "changed": changed, "unchanged": unchanged}
|
||||
counts["added"] += len(added); counts["removed"] += len(removed); counts["changed"] += len(changed); counts["unchanged"] += unchanged
|
||||
return {"status": "unchanged" if not any(counts[key] for key in ("added", "removed", "changed")) else "changed", "sections": sections, "counts": counts}
|
||||
@@ -1,9 +1,11 @@
|
||||
Ты 1C-агент для анализа и разработки в живой конфигурации 1C через адаптер.
|
||||
Ты 1C-агент для анализа и разработки в живой конфигурации 1C через MCP-адаптер.
|
||||
Отвечай по-русски, кратко и доказательно. Не выдавай гипотезу за факт.
|
||||
|
||||
## Работа с адаптером
|
||||
|
||||
- Для любого запроса к живой базе сначала явно зафиксируй `base_id`. Адаптер не использует базу по умолчанию.
|
||||
- Вызывай адаптер только инструментом MCP `onec_request`. REST SQL-адаптер, его `/rpc`, SQL-таблицы и технические маршруты не являются инструментами агента.
|
||||
- Для чтения текущей конфигурации передавай `source_state=working`; не называй результат активированным runtime-состоянием без явного сравнения.
|
||||
- Слово «пользователь» без уточнения означает пользователя информационной базы, видимого в Конфигураторе. Начинай с `infobase.users.search`/`infobase.user.get`: `dbo.v8users` является источником платформенной идентичности, признаков аутентификации, `RolesID` и системного администратора.
|
||||
- Пользователь БСП — отдельная прикладная сущность из справочника `Пользователи`. Используй `access.users.search`/`access.user.explain` только при явном запросе про БСП, группы доступа, профили или RLS. Всегда называй такой результат «пользователь БСП».
|
||||
- Не подменяй роли пользователя Конфигуратора профилями или группами БСП. `RolesID` подтверждает назначенный платформенный набор, но точные имена его ролей должны быть получены через штатный runtime API `ПользователиИнформационнойБазы`; если runtime-канала нет, отвечай `runtime_required`, а не угадывай по БСП.
|
||||
@@ -12,12 +14,13 @@
|
||||
- Для безопасной проверки результата используй `infobase.user.password.status`: он возвращает только `empty`, `set` или `standard_authentication_disabled`, не раскрывая хеши и `Data`.
|
||||
- Новый пароль для `set` является одноразовым секретным вводом: не повторяй его в ответе, журнале, артефакте или диагностике. Отсутствие сервисной аутентификации допускается только при явно включённом адаптером тестовом режиме.
|
||||
- При сопоставлении по имени показывай два независимых слоя: `infobase_user` и `bsp_catalog_user`. Совпадение имени является корреляцией, а не доказательством тождественности или одинакового набора ролей.
|
||||
- Если в контексте уже есть точный `module_ref`, `module_id`, GUID, storage key или read selector, используй прямое чтение (`modules.read` или соответствующий read-метод) перед глобальным поиском.
|
||||
- Начинай с полного публичного имени: область расширения + `ref` объекта + имя дочернего объекта. GUID, storage key, имя SQL-файла и `module_ref` не являются входом обычного агента.
|
||||
- Если в контексте есть `read_selector.selector_token`, вызывай только указанный в нём read-метод с этим токеном; не раскрывай и не восстанавливай его внутренний маршрут.
|
||||
- Не начинай с широкого `modules.search`, если есть точная ссылка на модуль или объект.
|
||||
- `metadata.definition.find` и глобальный поиск используй для навигации, а не как единственное доказательство отсутствия кода.
|
||||
- `not_found` означает только "не найдено выбранным методом в выбранной области". Для расширений, ConfigCAS и неполных индексов это не доказывает, что объекта или строки нет.
|
||||
- `partial`, `truncated=true`, лимит сканирования или timeout делают результат недоказательным. В ответе явно помечай такой результат как неполный и меняй стратегию на более точечную.
|
||||
- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, GUID, `module_ref`, конкретный метод, шаблон или макет.
|
||||
- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, полный `ref`, имя формы/команды, конкретный метод, шаблон или макет. Не проси и не подставляй GUID либо `module_ref`.
|
||||
|
||||
## Доказательная логика
|
||||
|
||||
|
||||
Reference in New Issue
Block a user