Initial project import

This commit is contained in:
2026-08-14 09:40:51 +03:00
parent 00040e5ce4
commit d7099bf80d
146 changed files with 30509 additions and 1055 deletions
+1
View File
@@ -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
+89 -13
View File
@@ -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):
+32
View File
@@ -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", Строка(УникальныйИдентификатор), Строка(ЗначениеСвойства));
Исключение
КонецПопытки;
Возврат Строка(ЗначениеСвойства);
КонецФункции
+11
View File
@@ -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
+2
View File
@@ -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
+168 -4
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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())
+375 -4
View File
@@ -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."
+522 -47
View File
@@ -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]:
+1
View File
@@ -0,0 +1 @@
"""Typed write-dispatch contracts for the SQL-only 1C adapter."""
+20
View File
@@ -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]]
+14
View File
@@ -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)
+45
View File
@@ -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
]
+1
View File
@@ -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
+355 -41
View File
@@ -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:
+34
View File
@@ -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))
+13
View File
@@ -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"]
+14
View File
@@ -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.
+345
View File
@@ -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()
+54
View File
@@ -0,0 +1,54 @@
const $ = (selector) => document.querySelector(selector);
let allEvents = [];
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>`; });
+13
View File
@@ -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}}
+10
View File
@@ -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>
+223 -3
View File
@@ -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
+43 -1
View File
@@ -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,
+463
View File
@@ -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}
+6 -3
View File
@@ -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`.
## Доказательная логика